@nelsonlongxiang/dsh-open-a2a-net 0.6.40 → 0.6.42
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/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/lib/a2a-client.js +3 -2
- package/lib/client.js +64 -64
- package/lib/client.js.map +1 -1
- package/lib/config.js +1 -0
- package/lib/faces/files.js +2 -2
- package/lib/faces/session-log.js +3 -3
- package/lib/faces/wire.js +11 -16
- package/lib/index.js +102 -18
- package/lib/link-keys.js +29 -0
- 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/skill/references/setup.md +5 -1
- package/lib/tools/files.js +3 -1
- package/lib/types/a2a-client.d.ts +3 -1
- package/lib/types/config.d.ts +6 -0
- package/lib/types/faces/files.d.ts +2 -1
- package/lib/types/faces/session-log.d.ts +2 -0
- package/lib/types/faces/wire.d.ts +2 -1
- package/lib/types/link-keys.d.ts +13 -0
- package/lib/types/origin-claim.d.ts +2 -0
- package/lib/types/tools/files.d.ts +1 -1
- 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 +513 -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 +1272 -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/nexus-stage/src/world.ts
CHANGED
|
@@ -1,509 +1,509 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The 2D planning-canvas world model: joined session nodes, team-frame
|
|
3
|
-
* rectangles, the selection set, and the drag/marquee mutations. Pure
|
|
4
|
-
* state + math, no DOM and no three (structural types redeclared here so
|
|
5
|
-
* this module stays unit-testable from the root suite; main.ts passes the
|
|
6
|
-
* topology.ts rows straight in - they satisfy these shapes structurally).
|
|
7
|
-
*
|
|
8
|
-
* Coordinate contract: a node's persisted {x, y} is its CARD CENTER (the
|
|
9
|
-
* 3D scene writes mesh centers into the same layout document), so every
|
|
10
|
-
* rectangle math goes through nodeRect(). Team frames own their saved
|
|
11
|
-
* rects; a frame absent from the layout derives one from its members'
|
|
12
|
-
* card bounding box. Group drag works from a pointerdown snapshot and
|
|
13
|
-
* applies total deltas - never incremental accumulation, so a long drag
|
|
14
|
-
* cannot drift.
|
|
15
|
-
* @module nexus-stage/world
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
import { clampDoc, type LayoutDoc, type LayoutPoint, type LayoutRect } from './layout-doc'
|
|
19
|
-
import { hashId } from './seat'
|
|
20
|
-
import type { WorldBounds } from './viewport'
|
|
21
|
-
|
|
22
|
-
/** Card geometry: the single source both CSS and edge math must agree with. */
|
|
23
|
-
export const NODE_W = 172
|
|
24
|
-
export const NODE_H = 56
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Deterministic 2D fallback seating. The 3D scene's polar ring (radius
|
|
28
|
-
* 12-30) is sized for unit spheres - 172px cards dropped onto it stack
|
|
29
|
-
* into one pile. Unarranged cards instead spread on three card-scaled
|
|
30
|
-
* rings (48 hash slots, y compressed for wide screens), so a fresh
|
|
31
|
-
* canvas is readable before the first save; once anything is saved, the
|
|
32
|
-
* persisted document wins in BOTH modes and this is only history.
|
|
33
|
-
*/
|
|
34
|
-
const PLAN_RINGS = [150, 290, 430]
|
|
35
|
-
const PLAN_SLOTS = 16
|
|
36
|
-
|
|
37
|
-
export function planSeatFor(sid: string): { x: number; y: number } {
|
|
38
|
-
const h = hashId(sid)
|
|
39
|
-
const ring = Math.floor(h / PLAN_SLOTS) % PLAN_RINGS.length
|
|
40
|
-
const slot = h % PLAN_SLOTS
|
|
41
|
-
const angle = (slot / PLAN_SLOTS) * Math.PI * 2 + ring * (Math.PI / PLAN_SLOTS)
|
|
42
|
-
const r = PLAN_RINGS[ring]!
|
|
43
|
-
return { x: Math.round(Math.cos(angle) * r), y: Math.round(Math.sin(angle) * r * 0.6) }
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Home team per member id: the first team in state order that lists it.
|
|
48
|
-
* That is the team whose membership edge is solid (edges.ts); every later
|
|
49
|
-
* membership is a dashed 跨队 edge. A cross-team member therefore sits in
|
|
50
|
-
* its home frame only — the other frames reach it by the dashed edge
|
|
51
|
-
* instead of stretching over the home frame.
|
|
52
|
-
*/
|
|
53
|
-
export function homeTeams(teams: ReadonlyArray<TeamLite>): Map<string, string> {
|
|
54
|
-
const home = new Map<string, string>()
|
|
55
|
-
for (const team of teams) {
|
|
56
|
-
for (const m of team.members) if (!home.has(m.id)) home.set(m.id, team.name)
|
|
57
|
-
}
|
|
58
|
-
return home
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
const CLUSTER_GAP = 64
|
|
62
|
-
const CLUSTER_COL_GAP = 24
|
|
63
|
-
const CLUSTER_ROW_GAP = 20
|
|
64
|
-
const CLUSTERS_PER_ROW = 4
|
|
65
|
-
/** Same padding deriveInitialFrame wraps around member cards. */
|
|
66
|
-
const FRAME_PAD_X = 24
|
|
67
|
-
const FRAME_PAD_TOP = 44
|
|
68
|
-
const FRAME_PAD_BOTTOM = 24
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* First-run seats grouped by team: each team's home members fill a small
|
|
72
|
-
* grid (2–3 columns), clusters sit side by side with a 64px gutter and wrap
|
|
73
|
-
* every four teams; members with no canvas team (declared roster teams
|
|
74
|
-
* only) form a trailing cluster. Hash ring seats ignored teams, so the
|
|
75
|
-
* frames derived from scattered members nested inside each other and their
|
|
76
|
-
* centered titles printed on top of one another. A row also wraps before it
|
|
77
|
-
* outgrows `maxRowW` (the board width), so a portrait phone stacks teams
|
|
78
|
-
* instead of fitting one long strip at ~30%. Deterministic for a fixed
|
|
79
|
-
* state order and width. Only ids in `ids` are seated.
|
|
80
|
-
*/
|
|
81
|
-
export function planTeamClusters(teams: ReadonlyArray<TeamLite>, ids: ReadonlyArray<string>, maxRowW = Number.POSITIVE_INFINITY): Map<string, { x: number; y: number }> {
|
|
82
|
-
const want = new Set(ids)
|
|
83
|
-
const home = homeTeams(teams)
|
|
84
|
-
const groups: string[][] = teams.map(t => [...new Set(t.members.map(m => m.id))].filter(id => want.has(id) && home.get(id) === t.name))
|
|
85
|
-
groups.push(ids.filter(id => !home.has(id)))
|
|
86
|
-
const seats = new Map<string, { x: number; y: number }>()
|
|
87
|
-
let cursorX = 0
|
|
88
|
-
let cursorY = 0
|
|
89
|
-
let rowH = 0
|
|
90
|
-
let placed = 0
|
|
91
|
-
for (const group of groups) {
|
|
92
|
-
if (group.length === 0) continue
|
|
93
|
-
const cols = Math.min(3, Math.max(group.length > 1 ? 2 : 1, Math.ceil(Math.sqrt(group.length))))
|
|
94
|
-
const rows = Math.ceil(group.length / cols)
|
|
95
|
-
const w = Math.max(260, cols * NODE_W + (cols - 1) * CLUSTER_COL_GAP + FRAME_PAD_X * 2)
|
|
96
|
-
const h = Math.max(160, rows * NODE_H + (rows - 1) * CLUSTER_ROW_GAP + FRAME_PAD_TOP + FRAME_PAD_BOTTOM)
|
|
97
|
-
if (placed > 0 && (placed % CLUSTERS_PER_ROW === 0 || cursorX + w > maxRowW)) {
|
|
98
|
-
cursorX = 0
|
|
99
|
-
cursorY += rowH + CLUSTER_GAP
|
|
100
|
-
rowH = 0
|
|
101
|
-
}
|
|
102
|
-
group.forEach((id, i) => {
|
|
103
|
-
const c = i % cols
|
|
104
|
-
const r = Math.floor(i / cols)
|
|
105
|
-
seats.set(id, {
|
|
106
|
-
x: cursorX + FRAME_PAD_X + NODE_W / 2 + c * (NODE_W + CLUSTER_COL_GAP),
|
|
107
|
-
y: cursorY + FRAME_PAD_TOP + NODE_H / 2 + r * (NODE_H + CLUSTER_ROW_GAP),
|
|
108
|
-
})
|
|
109
|
-
})
|
|
110
|
-
cursorX += w + CLUSTER_GAP
|
|
111
|
-
rowH = Math.max(rowH, h)
|
|
112
|
-
placed += 1
|
|
113
|
-
}
|
|
114
|
-
return seats
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/** Breathing room kept between two unarranged cards. */
|
|
118
|
-
const SEAT_GAP = 12
|
|
119
|
-
|
|
120
|
-
/**
|
|
121
|
-
* Collision-free fallback seat: the hashed ring seat when it is clear of
|
|
122
|
-
* every card in `occupied`, else the next clear slot walking the rings
|
|
123
|
-
* outward from the hashed one. The bare hash put 8 sessions into 48
|
|
124
|
-
* slots and stacked pairs pixel-for-pixel on top of each other (and the
|
|
125
|
-
* inner ring's slots sit ~60px apart, so neighbours overlapped too) —
|
|
126
|
-
* a fresh board read as fewer nodes than the fleet had. Deterministic for
|
|
127
|
-
* a fixed input order; when every ring slot is taken, cards queue on a
|
|
128
|
-
* grid row under the outer ring.
|
|
129
|
-
*/
|
|
130
|
-
export function planSeatClear(sid: string, occupied: ReadonlyArray<{ readonly x: number; readonly y: number }>): { x: number; y: number } {
|
|
131
|
-
const clear = (p: { x: number; y: number }): boolean => occupied.every(o =>
|
|
132
|
-
Math.abs(o.x - p.x) >= NODE_W + SEAT_GAP || Math.abs(o.y - p.y) >= NODE_H + SEAT_GAP)
|
|
133
|
-
const first = planSeatFor(sid)
|
|
134
|
-
if (clear(first)) return first
|
|
135
|
-
const h = hashId(sid)
|
|
136
|
-
const total = PLAN_SLOTS * PLAN_RINGS.length
|
|
137
|
-
const start = (Math.floor(h / PLAN_SLOTS) % PLAN_RINGS.length) * PLAN_SLOTS + (h % PLAN_SLOTS)
|
|
138
|
-
for (let k = 1; k < total; k++) {
|
|
139
|
-
const i = (start + k) % total
|
|
140
|
-
const ring = Math.floor(i / PLAN_SLOTS)
|
|
141
|
-
const angle = ((i % PLAN_SLOTS) / PLAN_SLOTS) * Math.PI * 2 + ring * (Math.PI / PLAN_SLOTS)
|
|
142
|
-
const r = PLAN_RINGS[ring]!
|
|
143
|
-
const p = { x: Math.round(Math.cos(angle) * r), y: Math.round(Math.sin(angle) * r * 0.6) }
|
|
144
|
-
if (clear(p)) return p
|
|
145
|
-
}
|
|
146
|
-
const outer = PLAN_RINGS[PLAN_RINGS.length - 1]!
|
|
147
|
-
for (let row = 0; ; row++) {
|
|
148
|
-
for (let col = 0; col < 6; col++) {
|
|
149
|
-
const p = { x: Math.round(-outer + col * (NODE_W + SEAT_GAP)), y: Math.round(outer * 0.6 + NODE_H * 2 + row * (NODE_H + SEAT_GAP)) }
|
|
150
|
-
if (clear(p)) return p
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
/** Minimal session row shape the model needs (topology.SessionRow satisfies it). */
|
|
156
|
-
export interface SessionLite {
|
|
157
|
-
id: string; label: string; team: string; name?: string; description?: string; joined: boolean; live?: boolean; teams?: readonly string[]
|
|
158
|
-
/** Live local sessions only: mid-turn ('running') or idle, and how many receipt-owing tasks wait on it. */
|
|
159
|
-
status?: 'running' | 'idle'; queued?: number
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
/** Minimal team shape (topology.CanvasTeam satisfies it). `team` is the
|
|
163
|
-
* mono route alias `<host>/canvas/<name>` when the payload carries it. */
|
|
164
|
-
export interface TeamLite { name: string; team?: string; members: ReadonlyArray<{ id: string }> }
|
|
165
|
-
|
|
166
|
-
/** One node's memberships in state order: team name + index (member order = routing priority). */
|
|
167
|
-
export interface Membership { readonly team: string; readonly index: number }
|
|
168
|
-
|
|
169
|
-
/** A placed session node: center position plus everything the card renders. */
|
|
170
|
-
export interface WorldNode {
|
|
171
|
-
x: number
|
|
172
|
-
y: number
|
|
173
|
-
readonly id: string
|
|
174
|
-
/** Rendered attributes refresh on each poll; the position never does. */
|
|
175
|
-
label: string
|
|
176
|
-
/** The route alias `<team>/<id8>` the state payload provides. */
|
|
177
|
-
team: string
|
|
178
|
-
name?: string
|
|
179
|
-
/** What the session is for (/state `description`) — the card's second line. */
|
|
180
|
-
description?: string
|
|
181
|
-
live: boolean
|
|
182
|
-
/** Peer node (PR D v1 ruling): a remote host entry rendered as a card.
|
|
183
|
-
* Excluded from local content bounds; canvas memberships allowed since
|
|
184
|
-
* gh#20 W-B (the peer rides its zone/id8 handle at the server boundary). */
|
|
185
|
-
remote?: boolean
|
|
186
|
-
/** Full peer URL (identity is the bare host:port in the node id). */
|
|
187
|
-
peerUrl?: string
|
|
188
|
-
/** gh#20 W-B: the peer's zone/id8 remote handle (its first remote team
|
|
189
|
-
* row) — the server's canvas member form for cross-host teams. */
|
|
190
|
-
remoteHandle?: string
|
|
191
|
-
/** Reachability score from a2a_probe (peers only). */
|
|
192
|
-
score?: number
|
|
193
|
-
/** Declared roster teams (a2a/teams.json) — the network-declaration half
|
|
194
|
-
* of "teamed"; canvas memberships are the visual half. Peers: never. */
|
|
195
|
-
teams?: readonly string[]
|
|
196
|
-
memberships: ReadonlyArray<Membership>
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
/** A team frame rectangle in world space (top-left + size). */
|
|
200
|
-
export interface FrameGeom { x: number; y: number; w: number; h: number }
|
|
201
|
-
|
|
202
|
-
/** Snapshot for a frame drag: the frame rect at pointerdown (owner ruling 2026-10-28: a frame always moves only itself — members never ride along). */
|
|
203
|
-
export interface FrameDragSnapshot {
|
|
204
|
-
readonly name: string
|
|
205
|
-
readonly rect: FrameGeom
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
/** The card rectangle (top-left + size) for a node centered at (x, y). */
|
|
209
|
-
export function nodeRect(n: { readonly x: number; readonly y: number }): LayoutRect {
|
|
210
|
-
return { x: n.x - NODE_W / 2, y: n.y - NODE_H / 2, w: NODE_W, h: NODE_H }
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
/**
|
|
214
|
-
* Derive an initial frame rect from member card rects: bounding box plus
|
|
215
|
-
* padding (extra headroom on top for the straddling titlebar), minimum
|
|
216
|
-
* 260x160, rounded to integers - deterministic, so the same roster always
|
|
217
|
-
* proposes the same frame until the user moves something.
|
|
218
|
-
*/
|
|
219
|
-
export function deriveInitialFrame(memberRects: ReadonlyArray<LayoutRect>): FrameGeom {
|
|
220
|
-
if (memberRects.length === 0) return { x: 0, y: 0, w: 260, h: 160 }
|
|
221
|
-
let minX = Number.POSITIVE_INFINITY
|
|
222
|
-
let minY = Number.POSITIVE_INFINITY
|
|
223
|
-
let maxX = Number.NEGATIVE_INFINITY
|
|
224
|
-
let maxY = Number.NEGATIVE_INFINITY
|
|
225
|
-
for (const r of memberRects) {
|
|
226
|
-
minX = Math.min(minX, r.x)
|
|
227
|
-
minY = Math.min(minY, r.y)
|
|
228
|
-
maxX = Math.max(maxX, r.x + r.w)
|
|
229
|
-
maxY = Math.max(maxY, r.y + r.h)
|
|
230
|
-
}
|
|
231
|
-
const padX = 24
|
|
232
|
-
const padTop = 44
|
|
233
|
-
const padBottom = 24
|
|
234
|
-
const w = Math.max(260, Math.round(maxX - minX + padX * 2))
|
|
235
|
-
const h = Math.max(160, Math.round(maxY - minY + padTop + padBottom))
|
|
236
|
-
return { x: Math.round(minX - padX), y: Math.round(minY - padTop), w, h }
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
/** The planning-canvas model. */
|
|
240
|
-
export class WorldModel {
|
|
241
|
-
private revisionCounter = 0
|
|
242
|
-
|
|
243
|
-
/** Monotonic mutation counter: the view re-renders only when this moves. */
|
|
244
|
-
get revision(): number { return this.revisionCounter }
|
|
245
|
-
|
|
246
|
-
private nodes = new Map<string, WorldNode>()
|
|
247
|
-
private frames = new Map<string, FrameGeom>()
|
|
248
|
-
private selection = new Set<string>()
|
|
249
|
-
viewport = { x: 0, y: 0, scale: 1 }
|
|
250
|
-
|
|
251
|
-
/** Read-only node access for rendering/edges. */
|
|
252
|
-
getNode(id: string): WorldNode | undefined { return this.nodes.get(id) }
|
|
253
|
-
allNodes(): ReadonlyArray<WorldNode> { return [...this.nodes.values()] }
|
|
254
|
-
nodeIds(): ReadonlyArray<string> { return [...this.nodes.keys()] }
|
|
255
|
-
getFrame(name: string): FrameGeom | undefined { return this.frames.get(name) }
|
|
256
|
-
allFrames(): ReadonlyArray<[string, FrameGeom]> { return [...this.frames.entries()] }
|
|
257
|
-
isSelected(id: string): boolean { return this.selection.has(id) }
|
|
258
|
-
selectedIds(): ReadonlyArray<string> { return [...this.selection] }
|
|
259
|
-
|
|
260
|
-
private bump(): void { this.revisionCounter += 1 }
|
|
261
|
-
|
|
262
|
-
/**
|
|
263
|
-
* Move nodes by a world-space delta (drag/nudge); also moves each
|
|
264
|
-
* selected node out of no frame - frames track rects, not membership,
|
|
265
|
-
* so positions and rects stay independent.
|
|
266
|
-
*/
|
|
267
|
-
dragNodes(ids: ReadonlyArray<string>, dx: number, dy: number): void {
|
|
268
|
-
let moved = false
|
|
269
|
-
for (const id of ids) {
|
|
270
|
-
const n = this.nodes.get(id)
|
|
271
|
-
if (n === undefined) continue
|
|
272
|
-
n.x += dx
|
|
273
|
-
n.y += dy
|
|
274
|
-
moved = true
|
|
275
|
-
}
|
|
276
|
-
if (moved) this.bump()
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
/**
|
|
280
|
-
* Start a frame drag: snapshot the rect so the drag applies total deltas
|
|
281
|
-
* from this origin (no float drift). Returns undefined for an unknown
|
|
282
|
-
* frame. Owner ruling 2026-10-28: the snapshot carries NO member state —
|
|
283
|
-
* a frame drag moves only the rect; members stay where the user put them.
|
|
284
|
-
*/
|
|
285
|
-
beginFrameDrag(name: string): FrameDragSnapshot | undefined {
|
|
286
|
-
const rect = this.frames.get(name)
|
|
287
|
-
if (rect === undefined) return undefined
|
|
288
|
-
return { name, rect: { ...rect } }
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
/** Apply a total world-space delta from the drag origin to the rect ONLY. */
|
|
292
|
-
applyFrameDrag(snap: FrameDragSnapshot, dx: number, dy: number): void {
|
|
293
|
-
const rect = this.frames.get(snap.name)
|
|
294
|
-
if (rect === undefined) return
|
|
295
|
-
rect.x = snap.rect.x + dx
|
|
296
|
-
rect.y = snap.rect.y + dy
|
|
297
|
-
this.bump()
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
/** Nudge (keyboard micro-move): moves the SELECTED CARDS only — frames are never in the card selection, so nudging already honours the owner ruling (a frame always moves only itself). */
|
|
301
|
-
nudge(ids: ReadonlyArray<string>, dx: number, dy: number): void { this.dragNodes(ids, dx, dy) }
|
|
302
|
-
|
|
303
|
-
/** Set selection: nodes whose card rect intersects the world-space marquee. */
|
|
304
|
-
marqueeSelect(rect: LayoutRect, additive: boolean): void {
|
|
305
|
-
if (!additive) this.selection.clear()
|
|
306
|
-
for (const n of this.nodes.values()) {
|
|
307
|
-
const r = nodeRect(n)
|
|
308
|
-
const intersects = r.x < rect.x + rect.w && r.x + r.w > rect.x && r.y < rect.y + rect.h && r.y + r.h > rect.y
|
|
309
|
-
if (intersects) this.selection.add(n.id)
|
|
310
|
-
}
|
|
311
|
-
this.bump()
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
/** Replace the whole selection (click / Shift-click add / Esc clears). */
|
|
315
|
-
setSelection(ids: ReadonlyArray<string>): void {
|
|
316
|
-
this.selection = new Set(ids)
|
|
317
|
-
this.bump()
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
/**
|
|
321
|
-
* Poll-cycle maintenance: insert or refresh one node without touching
|
|
322
|
-
* its position (the live model is authoritative between saves; a poll
|
|
323
|
-
* must never snap a dragged card back to its last-saved spot). New
|
|
324
|
-
* nodes seat at the given coordinates (saved spot or hash seat).
|
|
325
|
-
*/
|
|
326
|
-
upsertNode(n: WorldNode): void {
|
|
327
|
-
const existing = this.nodes.get(n.id)
|
|
328
|
-
if (existing === undefined) {
|
|
329
|
-
this.nodes.set(n.id, n)
|
|
330
|
-
this.bump()
|
|
331
|
-
return
|
|
332
|
-
}
|
|
333
|
-
existing.label = n.label
|
|
334
|
-
existing.name = n.name
|
|
335
|
-
existing.description = n.description
|
|
336
|
-
existing.live = n.live
|
|
337
|
-
existing.memberships = n.memberships
|
|
338
|
-
this.bump()
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
/** Drop a node that left the joined roster; clears it from selection. */
|
|
342
|
-
removeNode(id: string): void {
|
|
343
|
-
if (this.nodes.delete(id)) {
|
|
344
|
-
this.selection.delete(id)
|
|
345
|
-
this.bump()
|
|
346
|
-
}
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
/** Set or replace one frame rect (adopted layout or derived initial). */
|
|
350
|
-
setFrame(name: string, rect: FrameGeom): void {
|
|
351
|
-
this.frames.set(name, rect)
|
|
352
|
-
this.bump()
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
/** Drop a frame whose team vanished from the state payload. */
|
|
356
|
-
removeFrame(name: string): void {
|
|
357
|
-
if (this.frames.delete(name)) this.bump()
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
/** Update a peer node's reachability score (render attr; no bump unless it changed). */
|
|
361
|
-
upsertScore(id: string, score: number | undefined): void {
|
|
362
|
-
const n = this.nodes.get(id)
|
|
363
|
-
if (n === undefined || n.score === score) return
|
|
364
|
-
n.score = score
|
|
365
|
-
this.bump()
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
/** Node center positions, for the layout document builder. */
|
|
369
|
-
positions(): Map<string, LayoutPoint> {
|
|
370
|
-
const out = new Map<string, LayoutPoint>()
|
|
371
|
-
for (const [id, n] of this.nodes) out.set(id, { x: n.x, y: n.y })
|
|
372
|
-
return out
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
/** Frame rectangles, for the layout document builder. */
|
|
376
|
-
frameRects(): Map<string, LayoutRect> {
|
|
377
|
-
const out = new Map<string, LayoutRect>()
|
|
378
|
-
for (const [name, r] of this.frames) out.set(name, { ...r })
|
|
379
|
-
return out
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
/** Member ids of one team, in routing-priority (index) order. */
|
|
383
|
-
teamMemberIds(name: string): string[] {
|
|
384
|
-
const rows: Array<{ id: string; index: number }> = []
|
|
385
|
-
for (const n of this.nodes.values()) {
|
|
386
|
-
const m = n.memberships.find(x => x.team === name)
|
|
387
|
-
if (m !== undefined) rows.push({ id: n.id, index: m.index })
|
|
388
|
-
}
|
|
389
|
-
return rows.sort((a, b) => a.index - b.index).map(r => r.id)
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
/**
|
|
393
|
-
* Replace one team's roster (the single primitive every write action and
|
|
394
|
-
* its undo go through): entries for OTHER teams are preserved verbatim
|
|
395
|
-
* in their array position, this team's entries are rebuilt from `ids`
|
|
396
|
-
* (order = routing priority, indexes renormalized to 0..n-1 so the P
|
|
397
|
-
* badges stay continuous). One bump for the whole operation.
|
|
398
|
-
*/
|
|
399
|
-
setTeamMembers(name: string, ids: ReadonlyArray<string>): void {
|
|
400
|
-
for (const n of this.nodes.values()) {
|
|
401
|
-
const rest = n.memberships.filter(m => m.team !== name)
|
|
402
|
-
const idx = ids.indexOf(n.id)
|
|
403
|
-
if (idx >= 0) rest.push({ team: name, index: idx })
|
|
404
|
-
n.memberships = rest
|
|
405
|
-
}
|
|
406
|
-
this.bump()
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
/** Union of node cards and frame rects; null when the canvas is empty. */
|
|
410
|
-
contentBounds(): WorldBounds | null {
|
|
411
|
-
let minX = Number.POSITIVE_INFINITY
|
|
412
|
-
let minY = Number.POSITIVE_INFINITY
|
|
413
|
-
let maxX = Number.NEGATIVE_INFINITY
|
|
414
|
-
let maxY = Number.NEGATIVE_INFINITY
|
|
415
|
-
const absorb = (r: LayoutRect): void => {
|
|
416
|
-
minX = Math.min(minX, r.x)
|
|
417
|
-
minY = Math.min(minY, r.y)
|
|
418
|
-
maxX = Math.max(maxX, r.x + r.w)
|
|
419
|
-
maxY = Math.max(maxY, r.y + r.h)
|
|
420
|
-
}
|
|
421
|
-
for (const n of this.nodes.values()) {
|
|
422
|
-
if (n.remote === true) continue // peer cards hang outside the field
|
|
423
|
-
absorb(nodeRect(n))
|
|
424
|
-
}
|
|
425
|
-
for (const r of this.frames.values()) absorb(r)
|
|
426
|
-
return minX === Number.POSITIVE_INFINITY ? null : { minX, minY, maxX, maxY }
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
/**
|
|
430
|
-
* Build from one state payload plus the persisted layout (when the host
|
|
431
|
-
* has one). Joined sessions only (same roster rule as the 3D scene);
|
|
432
|
-
* saved positions win, the deterministic card-scaled ring seat
|
|
433
|
-
* falls back - the same world spot the 3D grid plane shows for an
|
|
434
|
-
* unsaved node. Frames come from the layout when present; a frame with
|
|
435
|
-
* neither saved rect nor members is skipped. The layout is clamped with
|
|
436
|
-
* the host-mirror discipline, so malformed rows fall through to seats
|
|
437
|
-
* exactly like the 3D scene's typeof guard.
|
|
438
|
-
*/
|
|
439
|
-
static fromState(input: {
|
|
440
|
-
sessions: ReadonlyArray<SessionLite>
|
|
441
|
-
teams: ReadonlyArray<TeamLite>
|
|
442
|
-
layout: unknown
|
|
443
|
-
}): WorldModel {
|
|
444
|
-
const model = new WorldModel()
|
|
445
|
-
const doc: LayoutDoc | undefined = clampDoc(input.layout)
|
|
446
|
-
|
|
447
|
-
const memberships = new Map<string, Array<Membership>>()
|
|
448
|
-
for (const team of input.teams) {
|
|
449
|
-
for (let i = 0; i < team.members.length; i++) {
|
|
450
|
-
const id = team.members[i]!.id
|
|
451
|
-
const list = memberships.get(id) ?? []
|
|
452
|
-
list.push({ team: team.name, index: i })
|
|
453
|
-
memberships.set(id, list)
|
|
454
|
-
}
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
for (const s of input.sessions) {
|
|
458
|
-
if (s.joined !== true) continue
|
|
459
|
-
const saved: LayoutPoint | undefined = doc?.nodes[s.id]
|
|
460
|
-
let x: number
|
|
461
|
-
let y: number
|
|
462
|
-
if (saved !== undefined && typeof saved.x === 'number' && typeof saved.y === 'number') {
|
|
463
|
-
x = saved.x
|
|
464
|
-
y = saved.y
|
|
465
|
-
} else {
|
|
466
|
-
const p = planSeatClear(s.id, [...model.nodes.values()])
|
|
467
|
-
x = p.x
|
|
468
|
-
y = p.y
|
|
469
|
-
}
|
|
470
|
-
model.nodes.set(s.id, {
|
|
471
|
-
id: s.id,
|
|
472
|
-
x,
|
|
473
|
-
y,
|
|
474
|
-
label: s.label,
|
|
475
|
-
team: s.team,
|
|
476
|
-
name: s.name,
|
|
477
|
-
description: s.description,
|
|
478
|
-
live: s.live !== false,
|
|
479
|
-
memberships: memberships.get(s.id) ?? [],
|
|
480
|
-
})
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
for (const team of input.teams) {
|
|
484
|
-
const savedRect = doc?.frames[team.name]
|
|
485
|
-
if (savedRect !== undefined) {
|
|
486
|
-
model.frames.set(team.name, { x: savedRect.x, y: savedRect.y, w: savedRect.w, h: savedRect.h })
|
|
487
|
-
continue
|
|
488
|
-
}
|
|
489
|
-
const memberRects: Array<LayoutRect> = []
|
|
490
|
-
for (const m of team.members) {
|
|
491
|
-
const n = model.nodes.get(m.id)
|
|
492
|
-
if (n !== undefined) memberRects.push(nodeRect(n))
|
|
493
|
-
}
|
|
494
|
-
if (memberRects.length > 0) model.frames.set(team.name, deriveInitialFrame(memberRects))
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
model.viewport = doc !== undefined ? { ...doc.viewport } : { x: 0, y: 0, scale: 1 }
|
|
498
|
-
return model
|
|
499
|
-
}
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
/** Functional entry point; see {@link WorldModel.fromState} for the contract. */
|
|
503
|
-
export function buildWorld(input: {
|
|
504
|
-
sessions: ReadonlyArray<SessionLite>
|
|
505
|
-
teams: ReadonlyArray<TeamLite>
|
|
506
|
-
layout: unknown
|
|
507
|
-
}): WorldModel {
|
|
508
|
-
return WorldModel.fromState(input)
|
|
509
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* The 2D planning-canvas world model: joined session nodes, team-frame
|
|
3
|
+
* rectangles, the selection set, and the drag/marquee mutations. Pure
|
|
4
|
+
* state + math, no DOM and no three (structural types redeclared here so
|
|
5
|
+
* this module stays unit-testable from the root suite; main.ts passes the
|
|
6
|
+
* topology.ts rows straight in - they satisfy these shapes structurally).
|
|
7
|
+
*
|
|
8
|
+
* Coordinate contract: a node's persisted {x, y} is its CARD CENTER (the
|
|
9
|
+
* 3D scene writes mesh centers into the same layout document), so every
|
|
10
|
+
* rectangle math goes through nodeRect(). Team frames own their saved
|
|
11
|
+
* rects; a frame absent from the layout derives one from its members'
|
|
12
|
+
* card bounding box. Group drag works from a pointerdown snapshot and
|
|
13
|
+
* applies total deltas - never incremental accumulation, so a long drag
|
|
14
|
+
* cannot drift.
|
|
15
|
+
* @module nexus-stage/world
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { clampDoc, type LayoutDoc, type LayoutPoint, type LayoutRect } from './layout-doc'
|
|
19
|
+
import { hashId } from './seat'
|
|
20
|
+
import type { WorldBounds } from './viewport'
|
|
21
|
+
|
|
22
|
+
/** Card geometry: the single source both CSS and edge math must agree with. */
|
|
23
|
+
export const NODE_W = 172
|
|
24
|
+
export const NODE_H = 56
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Deterministic 2D fallback seating. The 3D scene's polar ring (radius
|
|
28
|
+
* 12-30) is sized for unit spheres - 172px cards dropped onto it stack
|
|
29
|
+
* into one pile. Unarranged cards instead spread on three card-scaled
|
|
30
|
+
* rings (48 hash slots, y compressed for wide screens), so a fresh
|
|
31
|
+
* canvas is readable before the first save; once anything is saved, the
|
|
32
|
+
* persisted document wins in BOTH modes and this is only history.
|
|
33
|
+
*/
|
|
34
|
+
const PLAN_RINGS = [150, 290, 430]
|
|
35
|
+
const PLAN_SLOTS = 16
|
|
36
|
+
|
|
37
|
+
export function planSeatFor(sid: string): { x: number; y: number } {
|
|
38
|
+
const h = hashId(sid)
|
|
39
|
+
const ring = Math.floor(h / PLAN_SLOTS) % PLAN_RINGS.length
|
|
40
|
+
const slot = h % PLAN_SLOTS
|
|
41
|
+
const angle = (slot / PLAN_SLOTS) * Math.PI * 2 + ring * (Math.PI / PLAN_SLOTS)
|
|
42
|
+
const r = PLAN_RINGS[ring]!
|
|
43
|
+
return { x: Math.round(Math.cos(angle) * r), y: Math.round(Math.sin(angle) * r * 0.6) }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Home team per member id: the first team in state order that lists it.
|
|
48
|
+
* That is the team whose membership edge is solid (edges.ts); every later
|
|
49
|
+
* membership is a dashed 跨队 edge. A cross-team member therefore sits in
|
|
50
|
+
* its home frame only — the other frames reach it by the dashed edge
|
|
51
|
+
* instead of stretching over the home frame.
|
|
52
|
+
*/
|
|
53
|
+
export function homeTeams(teams: ReadonlyArray<TeamLite>): Map<string, string> {
|
|
54
|
+
const home = new Map<string, string>()
|
|
55
|
+
for (const team of teams) {
|
|
56
|
+
for (const m of team.members) if (!home.has(m.id)) home.set(m.id, team.name)
|
|
57
|
+
}
|
|
58
|
+
return home
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const CLUSTER_GAP = 64
|
|
62
|
+
const CLUSTER_COL_GAP = 24
|
|
63
|
+
const CLUSTER_ROW_GAP = 20
|
|
64
|
+
const CLUSTERS_PER_ROW = 4
|
|
65
|
+
/** Same padding deriveInitialFrame wraps around member cards. */
|
|
66
|
+
const FRAME_PAD_X = 24
|
|
67
|
+
const FRAME_PAD_TOP = 44
|
|
68
|
+
const FRAME_PAD_BOTTOM = 24
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* First-run seats grouped by team: each team's home members fill a small
|
|
72
|
+
* grid (2–3 columns), clusters sit side by side with a 64px gutter and wrap
|
|
73
|
+
* every four teams; members with no canvas team (declared roster teams
|
|
74
|
+
* only) form a trailing cluster. Hash ring seats ignored teams, so the
|
|
75
|
+
* frames derived from scattered members nested inside each other and their
|
|
76
|
+
* centered titles printed on top of one another. A row also wraps before it
|
|
77
|
+
* outgrows `maxRowW` (the board width), so a portrait phone stacks teams
|
|
78
|
+
* instead of fitting one long strip at ~30%. Deterministic for a fixed
|
|
79
|
+
* state order and width. Only ids in `ids` are seated.
|
|
80
|
+
*/
|
|
81
|
+
export function planTeamClusters(teams: ReadonlyArray<TeamLite>, ids: ReadonlyArray<string>, maxRowW = Number.POSITIVE_INFINITY): Map<string, { x: number; y: number }> {
|
|
82
|
+
const want = new Set(ids)
|
|
83
|
+
const home = homeTeams(teams)
|
|
84
|
+
const groups: string[][] = teams.map(t => [...new Set(t.members.map(m => m.id))].filter(id => want.has(id) && home.get(id) === t.name))
|
|
85
|
+
groups.push(ids.filter(id => !home.has(id)))
|
|
86
|
+
const seats = new Map<string, { x: number; y: number }>()
|
|
87
|
+
let cursorX = 0
|
|
88
|
+
let cursorY = 0
|
|
89
|
+
let rowH = 0
|
|
90
|
+
let placed = 0
|
|
91
|
+
for (const group of groups) {
|
|
92
|
+
if (group.length === 0) continue
|
|
93
|
+
const cols = Math.min(3, Math.max(group.length > 1 ? 2 : 1, Math.ceil(Math.sqrt(group.length))))
|
|
94
|
+
const rows = Math.ceil(group.length / cols)
|
|
95
|
+
const w = Math.max(260, cols * NODE_W + (cols - 1) * CLUSTER_COL_GAP + FRAME_PAD_X * 2)
|
|
96
|
+
const h = Math.max(160, rows * NODE_H + (rows - 1) * CLUSTER_ROW_GAP + FRAME_PAD_TOP + FRAME_PAD_BOTTOM)
|
|
97
|
+
if (placed > 0 && (placed % CLUSTERS_PER_ROW === 0 || cursorX + w > maxRowW)) {
|
|
98
|
+
cursorX = 0
|
|
99
|
+
cursorY += rowH + CLUSTER_GAP
|
|
100
|
+
rowH = 0
|
|
101
|
+
}
|
|
102
|
+
group.forEach((id, i) => {
|
|
103
|
+
const c = i % cols
|
|
104
|
+
const r = Math.floor(i / cols)
|
|
105
|
+
seats.set(id, {
|
|
106
|
+
x: cursorX + FRAME_PAD_X + NODE_W / 2 + c * (NODE_W + CLUSTER_COL_GAP),
|
|
107
|
+
y: cursorY + FRAME_PAD_TOP + NODE_H / 2 + r * (NODE_H + CLUSTER_ROW_GAP),
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
cursorX += w + CLUSTER_GAP
|
|
111
|
+
rowH = Math.max(rowH, h)
|
|
112
|
+
placed += 1
|
|
113
|
+
}
|
|
114
|
+
return seats
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Breathing room kept between two unarranged cards. */
|
|
118
|
+
const SEAT_GAP = 12
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Collision-free fallback seat: the hashed ring seat when it is clear of
|
|
122
|
+
* every card in `occupied`, else the next clear slot walking the rings
|
|
123
|
+
* outward from the hashed one. The bare hash put 8 sessions into 48
|
|
124
|
+
* slots and stacked pairs pixel-for-pixel on top of each other (and the
|
|
125
|
+
* inner ring's slots sit ~60px apart, so neighbours overlapped too) —
|
|
126
|
+
* a fresh board read as fewer nodes than the fleet had. Deterministic for
|
|
127
|
+
* a fixed input order; when every ring slot is taken, cards queue on a
|
|
128
|
+
* grid row under the outer ring.
|
|
129
|
+
*/
|
|
130
|
+
export function planSeatClear(sid: string, occupied: ReadonlyArray<{ readonly x: number; readonly y: number }>): { x: number; y: number } {
|
|
131
|
+
const clear = (p: { x: number; y: number }): boolean => occupied.every(o =>
|
|
132
|
+
Math.abs(o.x - p.x) >= NODE_W + SEAT_GAP || Math.abs(o.y - p.y) >= NODE_H + SEAT_GAP)
|
|
133
|
+
const first = planSeatFor(sid)
|
|
134
|
+
if (clear(first)) return first
|
|
135
|
+
const h = hashId(sid)
|
|
136
|
+
const total = PLAN_SLOTS * PLAN_RINGS.length
|
|
137
|
+
const start = (Math.floor(h / PLAN_SLOTS) % PLAN_RINGS.length) * PLAN_SLOTS + (h % PLAN_SLOTS)
|
|
138
|
+
for (let k = 1; k < total; k++) {
|
|
139
|
+
const i = (start + k) % total
|
|
140
|
+
const ring = Math.floor(i / PLAN_SLOTS)
|
|
141
|
+
const angle = ((i % PLAN_SLOTS) / PLAN_SLOTS) * Math.PI * 2 + ring * (Math.PI / PLAN_SLOTS)
|
|
142
|
+
const r = PLAN_RINGS[ring]!
|
|
143
|
+
const p = { x: Math.round(Math.cos(angle) * r), y: Math.round(Math.sin(angle) * r * 0.6) }
|
|
144
|
+
if (clear(p)) return p
|
|
145
|
+
}
|
|
146
|
+
const outer = PLAN_RINGS[PLAN_RINGS.length - 1]!
|
|
147
|
+
for (let row = 0; ; row++) {
|
|
148
|
+
for (let col = 0; col < 6; col++) {
|
|
149
|
+
const p = { x: Math.round(-outer + col * (NODE_W + SEAT_GAP)), y: Math.round(outer * 0.6 + NODE_H * 2 + row * (NODE_H + SEAT_GAP)) }
|
|
150
|
+
if (clear(p)) return p
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Minimal session row shape the model needs (topology.SessionRow satisfies it). */
|
|
156
|
+
export interface SessionLite {
|
|
157
|
+
id: string; label: string; team: string; name?: string; description?: string; joined: boolean; live?: boolean; teams?: readonly string[]
|
|
158
|
+
/** Live local sessions only: mid-turn ('running') or idle, and how many receipt-owing tasks wait on it. */
|
|
159
|
+
status?: 'running' | 'idle'; queued?: number
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Minimal team shape (topology.CanvasTeam satisfies it). `team` is the
|
|
163
|
+
* mono route alias `<host>/canvas/<name>` when the payload carries it. */
|
|
164
|
+
export interface TeamLite { name: string; team?: string; members: ReadonlyArray<{ id: string }> }
|
|
165
|
+
|
|
166
|
+
/** One node's memberships in state order: team name + index (member order = routing priority). */
|
|
167
|
+
export interface Membership { readonly team: string; readonly index: number }
|
|
168
|
+
|
|
169
|
+
/** A placed session node: center position plus everything the card renders. */
|
|
170
|
+
export interface WorldNode {
|
|
171
|
+
x: number
|
|
172
|
+
y: number
|
|
173
|
+
readonly id: string
|
|
174
|
+
/** Rendered attributes refresh on each poll; the position never does. */
|
|
175
|
+
label: string
|
|
176
|
+
/** The route alias `<team>/<id8>` the state payload provides. */
|
|
177
|
+
team: string
|
|
178
|
+
name?: string
|
|
179
|
+
/** What the session is for (/state `description`) — the card's second line. */
|
|
180
|
+
description?: string
|
|
181
|
+
live: boolean
|
|
182
|
+
/** Peer node (PR D v1 ruling): a remote host entry rendered as a card.
|
|
183
|
+
* Excluded from local content bounds; canvas memberships allowed since
|
|
184
|
+
* gh#20 W-B (the peer rides its zone/id8 handle at the server boundary). */
|
|
185
|
+
remote?: boolean
|
|
186
|
+
/** Full peer URL (identity is the bare host:port in the node id). */
|
|
187
|
+
peerUrl?: string
|
|
188
|
+
/** gh#20 W-B: the peer's zone/id8 remote handle (its first remote team
|
|
189
|
+
* row) — the server's canvas member form for cross-host teams. */
|
|
190
|
+
remoteHandle?: string
|
|
191
|
+
/** Reachability score from a2a_probe (peers only). */
|
|
192
|
+
score?: number
|
|
193
|
+
/** Declared roster teams (a2a/teams.json) — the network-declaration half
|
|
194
|
+
* of "teamed"; canvas memberships are the visual half. Peers: never. */
|
|
195
|
+
teams?: readonly string[]
|
|
196
|
+
memberships: ReadonlyArray<Membership>
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** A team frame rectangle in world space (top-left + size). */
|
|
200
|
+
export interface FrameGeom { x: number; y: number; w: number; h: number }
|
|
201
|
+
|
|
202
|
+
/** Snapshot for a frame drag: the frame rect at pointerdown (owner ruling 2026-10-28: a frame always moves only itself — members never ride along). */
|
|
203
|
+
export interface FrameDragSnapshot {
|
|
204
|
+
readonly name: string
|
|
205
|
+
readonly rect: FrameGeom
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** The card rectangle (top-left + size) for a node centered at (x, y). */
|
|
209
|
+
export function nodeRect(n: { readonly x: number; readonly y: number }): LayoutRect {
|
|
210
|
+
return { x: n.x - NODE_W / 2, y: n.y - NODE_H / 2, w: NODE_W, h: NODE_H }
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Derive an initial frame rect from member card rects: bounding box plus
|
|
215
|
+
* padding (extra headroom on top for the straddling titlebar), minimum
|
|
216
|
+
* 260x160, rounded to integers - deterministic, so the same roster always
|
|
217
|
+
* proposes the same frame until the user moves something.
|
|
218
|
+
*/
|
|
219
|
+
export function deriveInitialFrame(memberRects: ReadonlyArray<LayoutRect>): FrameGeom {
|
|
220
|
+
if (memberRects.length === 0) return { x: 0, y: 0, w: 260, h: 160 }
|
|
221
|
+
let minX = Number.POSITIVE_INFINITY
|
|
222
|
+
let minY = Number.POSITIVE_INFINITY
|
|
223
|
+
let maxX = Number.NEGATIVE_INFINITY
|
|
224
|
+
let maxY = Number.NEGATIVE_INFINITY
|
|
225
|
+
for (const r of memberRects) {
|
|
226
|
+
minX = Math.min(minX, r.x)
|
|
227
|
+
minY = Math.min(minY, r.y)
|
|
228
|
+
maxX = Math.max(maxX, r.x + r.w)
|
|
229
|
+
maxY = Math.max(maxY, r.y + r.h)
|
|
230
|
+
}
|
|
231
|
+
const padX = 24
|
|
232
|
+
const padTop = 44
|
|
233
|
+
const padBottom = 24
|
|
234
|
+
const w = Math.max(260, Math.round(maxX - minX + padX * 2))
|
|
235
|
+
const h = Math.max(160, Math.round(maxY - minY + padTop + padBottom))
|
|
236
|
+
return { x: Math.round(minX - padX), y: Math.round(minY - padTop), w, h }
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** The planning-canvas model. */
|
|
240
|
+
export class WorldModel {
|
|
241
|
+
private revisionCounter = 0
|
|
242
|
+
|
|
243
|
+
/** Monotonic mutation counter: the view re-renders only when this moves. */
|
|
244
|
+
get revision(): number { return this.revisionCounter }
|
|
245
|
+
|
|
246
|
+
private nodes = new Map<string, WorldNode>()
|
|
247
|
+
private frames = new Map<string, FrameGeom>()
|
|
248
|
+
private selection = new Set<string>()
|
|
249
|
+
viewport = { x: 0, y: 0, scale: 1 }
|
|
250
|
+
|
|
251
|
+
/** Read-only node access for rendering/edges. */
|
|
252
|
+
getNode(id: string): WorldNode | undefined { return this.nodes.get(id) }
|
|
253
|
+
allNodes(): ReadonlyArray<WorldNode> { return [...this.nodes.values()] }
|
|
254
|
+
nodeIds(): ReadonlyArray<string> { return [...this.nodes.keys()] }
|
|
255
|
+
getFrame(name: string): FrameGeom | undefined { return this.frames.get(name) }
|
|
256
|
+
allFrames(): ReadonlyArray<[string, FrameGeom]> { return [...this.frames.entries()] }
|
|
257
|
+
isSelected(id: string): boolean { return this.selection.has(id) }
|
|
258
|
+
selectedIds(): ReadonlyArray<string> { return [...this.selection] }
|
|
259
|
+
|
|
260
|
+
private bump(): void { this.revisionCounter += 1 }
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Move nodes by a world-space delta (drag/nudge); also moves each
|
|
264
|
+
* selected node out of no frame - frames track rects, not membership,
|
|
265
|
+
* so positions and rects stay independent.
|
|
266
|
+
*/
|
|
267
|
+
dragNodes(ids: ReadonlyArray<string>, dx: number, dy: number): void {
|
|
268
|
+
let moved = false
|
|
269
|
+
for (const id of ids) {
|
|
270
|
+
const n = this.nodes.get(id)
|
|
271
|
+
if (n === undefined) continue
|
|
272
|
+
n.x += dx
|
|
273
|
+
n.y += dy
|
|
274
|
+
moved = true
|
|
275
|
+
}
|
|
276
|
+
if (moved) this.bump()
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Start a frame drag: snapshot the rect so the drag applies total deltas
|
|
281
|
+
* from this origin (no float drift). Returns undefined for an unknown
|
|
282
|
+
* frame. Owner ruling 2026-10-28: the snapshot carries NO member state —
|
|
283
|
+
* a frame drag moves only the rect; members stay where the user put them.
|
|
284
|
+
*/
|
|
285
|
+
beginFrameDrag(name: string): FrameDragSnapshot | undefined {
|
|
286
|
+
const rect = this.frames.get(name)
|
|
287
|
+
if (rect === undefined) return undefined
|
|
288
|
+
return { name, rect: { ...rect } }
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Apply a total world-space delta from the drag origin to the rect ONLY. */
|
|
292
|
+
applyFrameDrag(snap: FrameDragSnapshot, dx: number, dy: number): void {
|
|
293
|
+
const rect = this.frames.get(snap.name)
|
|
294
|
+
if (rect === undefined) return
|
|
295
|
+
rect.x = snap.rect.x + dx
|
|
296
|
+
rect.y = snap.rect.y + dy
|
|
297
|
+
this.bump()
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Nudge (keyboard micro-move): moves the SELECTED CARDS only — frames are never in the card selection, so nudging already honours the owner ruling (a frame always moves only itself). */
|
|
301
|
+
nudge(ids: ReadonlyArray<string>, dx: number, dy: number): void { this.dragNodes(ids, dx, dy) }
|
|
302
|
+
|
|
303
|
+
/** Set selection: nodes whose card rect intersects the world-space marquee. */
|
|
304
|
+
marqueeSelect(rect: LayoutRect, additive: boolean): void {
|
|
305
|
+
if (!additive) this.selection.clear()
|
|
306
|
+
for (const n of this.nodes.values()) {
|
|
307
|
+
const r = nodeRect(n)
|
|
308
|
+
const intersects = r.x < rect.x + rect.w && r.x + r.w > rect.x && r.y < rect.y + rect.h && r.y + r.h > rect.y
|
|
309
|
+
if (intersects) this.selection.add(n.id)
|
|
310
|
+
}
|
|
311
|
+
this.bump()
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Replace the whole selection (click / Shift-click add / Esc clears). */
|
|
315
|
+
setSelection(ids: ReadonlyArray<string>): void {
|
|
316
|
+
this.selection = new Set(ids)
|
|
317
|
+
this.bump()
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Poll-cycle maintenance: insert or refresh one node without touching
|
|
322
|
+
* its position (the live model is authoritative between saves; a poll
|
|
323
|
+
* must never snap a dragged card back to its last-saved spot). New
|
|
324
|
+
* nodes seat at the given coordinates (saved spot or hash seat).
|
|
325
|
+
*/
|
|
326
|
+
upsertNode(n: WorldNode): void {
|
|
327
|
+
const existing = this.nodes.get(n.id)
|
|
328
|
+
if (existing === undefined) {
|
|
329
|
+
this.nodes.set(n.id, n)
|
|
330
|
+
this.bump()
|
|
331
|
+
return
|
|
332
|
+
}
|
|
333
|
+
existing.label = n.label
|
|
334
|
+
existing.name = n.name
|
|
335
|
+
existing.description = n.description
|
|
336
|
+
existing.live = n.live
|
|
337
|
+
existing.memberships = n.memberships
|
|
338
|
+
this.bump()
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** Drop a node that left the joined roster; clears it from selection. */
|
|
342
|
+
removeNode(id: string): void {
|
|
343
|
+
if (this.nodes.delete(id)) {
|
|
344
|
+
this.selection.delete(id)
|
|
345
|
+
this.bump()
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** Set or replace one frame rect (adopted layout or derived initial). */
|
|
350
|
+
setFrame(name: string, rect: FrameGeom): void {
|
|
351
|
+
this.frames.set(name, rect)
|
|
352
|
+
this.bump()
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Drop a frame whose team vanished from the state payload. */
|
|
356
|
+
removeFrame(name: string): void {
|
|
357
|
+
if (this.frames.delete(name)) this.bump()
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** Update a peer node's reachability score (render attr; no bump unless it changed). */
|
|
361
|
+
upsertScore(id: string, score: number | undefined): void {
|
|
362
|
+
const n = this.nodes.get(id)
|
|
363
|
+
if (n === undefined || n.score === score) return
|
|
364
|
+
n.score = score
|
|
365
|
+
this.bump()
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** Node center positions, for the layout document builder. */
|
|
369
|
+
positions(): Map<string, LayoutPoint> {
|
|
370
|
+
const out = new Map<string, LayoutPoint>()
|
|
371
|
+
for (const [id, n] of this.nodes) out.set(id, { x: n.x, y: n.y })
|
|
372
|
+
return out
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** Frame rectangles, for the layout document builder. */
|
|
376
|
+
frameRects(): Map<string, LayoutRect> {
|
|
377
|
+
const out = new Map<string, LayoutRect>()
|
|
378
|
+
for (const [name, r] of this.frames) out.set(name, { ...r })
|
|
379
|
+
return out
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** Member ids of one team, in routing-priority (index) order. */
|
|
383
|
+
teamMemberIds(name: string): string[] {
|
|
384
|
+
const rows: Array<{ id: string; index: number }> = []
|
|
385
|
+
for (const n of this.nodes.values()) {
|
|
386
|
+
const m = n.memberships.find(x => x.team === name)
|
|
387
|
+
if (m !== undefined) rows.push({ id: n.id, index: m.index })
|
|
388
|
+
}
|
|
389
|
+
return rows.sort((a, b) => a.index - b.index).map(r => r.id)
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Replace one team's roster (the single primitive every write action and
|
|
394
|
+
* its undo go through): entries for OTHER teams are preserved verbatim
|
|
395
|
+
* in their array position, this team's entries are rebuilt from `ids`
|
|
396
|
+
* (order = routing priority, indexes renormalized to 0..n-1 so the P
|
|
397
|
+
* badges stay continuous). One bump for the whole operation.
|
|
398
|
+
*/
|
|
399
|
+
setTeamMembers(name: string, ids: ReadonlyArray<string>): void {
|
|
400
|
+
for (const n of this.nodes.values()) {
|
|
401
|
+
const rest = n.memberships.filter(m => m.team !== name)
|
|
402
|
+
const idx = ids.indexOf(n.id)
|
|
403
|
+
if (idx >= 0) rest.push({ team: name, index: idx })
|
|
404
|
+
n.memberships = rest
|
|
405
|
+
}
|
|
406
|
+
this.bump()
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** Union of node cards and frame rects; null when the canvas is empty. */
|
|
410
|
+
contentBounds(): WorldBounds | null {
|
|
411
|
+
let minX = Number.POSITIVE_INFINITY
|
|
412
|
+
let minY = Number.POSITIVE_INFINITY
|
|
413
|
+
let maxX = Number.NEGATIVE_INFINITY
|
|
414
|
+
let maxY = Number.NEGATIVE_INFINITY
|
|
415
|
+
const absorb = (r: LayoutRect): void => {
|
|
416
|
+
minX = Math.min(minX, r.x)
|
|
417
|
+
minY = Math.min(minY, r.y)
|
|
418
|
+
maxX = Math.max(maxX, r.x + r.w)
|
|
419
|
+
maxY = Math.max(maxY, r.y + r.h)
|
|
420
|
+
}
|
|
421
|
+
for (const n of this.nodes.values()) {
|
|
422
|
+
if (n.remote === true) continue // peer cards hang outside the field
|
|
423
|
+
absorb(nodeRect(n))
|
|
424
|
+
}
|
|
425
|
+
for (const r of this.frames.values()) absorb(r)
|
|
426
|
+
return minX === Number.POSITIVE_INFINITY ? null : { minX, minY, maxX, maxY }
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Build from one state payload plus the persisted layout (when the host
|
|
431
|
+
* has one). Joined sessions only (same roster rule as the 3D scene);
|
|
432
|
+
* saved positions win, the deterministic card-scaled ring seat
|
|
433
|
+
* falls back - the same world spot the 3D grid plane shows for an
|
|
434
|
+
* unsaved node. Frames come from the layout when present; a frame with
|
|
435
|
+
* neither saved rect nor members is skipped. The layout is clamped with
|
|
436
|
+
* the host-mirror discipline, so malformed rows fall through to seats
|
|
437
|
+
* exactly like the 3D scene's typeof guard.
|
|
438
|
+
*/
|
|
439
|
+
static fromState(input: {
|
|
440
|
+
sessions: ReadonlyArray<SessionLite>
|
|
441
|
+
teams: ReadonlyArray<TeamLite>
|
|
442
|
+
layout: unknown
|
|
443
|
+
}): WorldModel {
|
|
444
|
+
const model = new WorldModel()
|
|
445
|
+
const doc: LayoutDoc | undefined = clampDoc(input.layout)
|
|
446
|
+
|
|
447
|
+
const memberships = new Map<string, Array<Membership>>()
|
|
448
|
+
for (const team of input.teams) {
|
|
449
|
+
for (let i = 0; i < team.members.length; i++) {
|
|
450
|
+
const id = team.members[i]!.id
|
|
451
|
+
const list = memberships.get(id) ?? []
|
|
452
|
+
list.push({ team: team.name, index: i })
|
|
453
|
+
memberships.set(id, list)
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
for (const s of input.sessions) {
|
|
458
|
+
if (s.joined !== true) continue
|
|
459
|
+
const saved: LayoutPoint | undefined = doc?.nodes[s.id]
|
|
460
|
+
let x: number
|
|
461
|
+
let y: number
|
|
462
|
+
if (saved !== undefined && typeof saved.x === 'number' && typeof saved.y === 'number') {
|
|
463
|
+
x = saved.x
|
|
464
|
+
y = saved.y
|
|
465
|
+
} else {
|
|
466
|
+
const p = planSeatClear(s.id, [...model.nodes.values()])
|
|
467
|
+
x = p.x
|
|
468
|
+
y = p.y
|
|
469
|
+
}
|
|
470
|
+
model.nodes.set(s.id, {
|
|
471
|
+
id: s.id,
|
|
472
|
+
x,
|
|
473
|
+
y,
|
|
474
|
+
label: s.label,
|
|
475
|
+
team: s.team,
|
|
476
|
+
name: s.name,
|
|
477
|
+
description: s.description,
|
|
478
|
+
live: s.live !== false,
|
|
479
|
+
memberships: memberships.get(s.id) ?? [],
|
|
480
|
+
})
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
for (const team of input.teams) {
|
|
484
|
+
const savedRect = doc?.frames[team.name]
|
|
485
|
+
if (savedRect !== undefined) {
|
|
486
|
+
model.frames.set(team.name, { x: savedRect.x, y: savedRect.y, w: savedRect.w, h: savedRect.h })
|
|
487
|
+
continue
|
|
488
|
+
}
|
|
489
|
+
const memberRects: Array<LayoutRect> = []
|
|
490
|
+
for (const m of team.members) {
|
|
491
|
+
const n = model.nodes.get(m.id)
|
|
492
|
+
if (n !== undefined) memberRects.push(nodeRect(n))
|
|
493
|
+
}
|
|
494
|
+
if (memberRects.length > 0) model.frames.set(team.name, deriveInitialFrame(memberRects))
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
model.viewport = doc !== undefined ? { ...doc.viewport } : { x: 0, y: 0, scale: 1 }
|
|
498
|
+
return model
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/** Functional entry point; see {@link WorldModel.fromState} for the contract. */
|
|
503
|
+
export function buildWorld(input: {
|
|
504
|
+
sessions: ReadonlyArray<SessionLite>
|
|
505
|
+
teams: ReadonlyArray<TeamLite>
|
|
506
|
+
layout: unknown
|
|
507
|
+
}): WorldModel {
|
|
508
|
+
return WorldModel.fromState(input)
|
|
509
|
+
}
|