@nelsonlongxiang/dsh-open-a2a-net 0.6.28 → 0.6.30
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 +87 -14
- package/README_zh.md +83 -7
- package/assets/nexusDist/assets/index-C_x3iAth.js +9 -0
- package/assets/nexusDist/assets/index-Dz8N80tI.css +1 -0
- package/assets/nexusDist/index.html +2 -2
- package/cli/a2a.mjs +0 -0
- package/cordis.patch.yml +21 -21
- package/lib/canvas-store.js +21 -1
- package/lib/client.js +64 -64
- package/lib/client.js.map +1 -1
- package/lib/faces/admin.js +755 -0
- package/lib/faces/canvas-notify.js +138 -0
- package/lib/faces/wire.js +669 -0
- package/lib/flood-guard.js +106 -0
- package/lib/index.js +200 -1392
- package/lib/recursion-guard.js +78 -0
- package/lib/runtime.js +1 -0
- package/lib/skill/SKILL.md +35 -9
- package/lib/skill/references/cli.md +24 -7
- package/lib/skill/references/collaboration.md +355 -0
- package/lib/skill/references/discovery.md +5 -2
- package/lib/skill/references/pitfalls.md +2 -1
- package/lib/skill/references/routing.md +18 -8
- package/lib/skill/references/setup.md +152 -0
- package/lib/task-ledger.js +40 -4
- package/lib/types/canvas-store.d.ts +10 -1
- package/lib/types/faces/admin.d.ts +133 -0
- package/lib/types/faces/canvas-notify.d.ts +74 -0
- package/lib/types/faces/wire.d.ts +171 -0
- package/lib/types/flood-guard.d.ts +29 -0
- package/lib/types/index.d.ts +11 -3
- package/lib/types/recursion-guard.d.ts +56 -0
- package/lib/types/runtime.d.ts +35 -0
- package/lib/types/task-ledger.d.ts +15 -1
- package/nexus-stage/package-lock.json +2262 -2262
- package/nexus-stage/src/canvas-ops.ts +175 -175
- package/nexus-stage/src/canvas-wire.ts +156 -156
- package/nexus-stage/src/edges.ts +72 -72
- package/nexus-stage/src/federation-overlay.ts +103 -103
- package/nexus-stage/src/federation.ts +240 -240
- package/nexus-stage/src/layout-doc.ts +143 -143
- package/nexus-stage/src/layout-wire.ts +158 -158
- package/nexus-stage/src/main.ts +5 -1
- package/nexus-stage/src/planning-card-text.ts +73 -0
- package/nexus-stage/src/planning-edges.ts +26 -0
- package/nexus-stage/src/planning-fit.ts +45 -0
- package/nexus-stage/src/planning-legend.ts +38 -0
- package/nexus-stage/src/planning-mirror.ts +31 -0
- package/nexus-stage/src/planning-overlap.ts +159 -0
- package/nexus-stage/src/planning-relayout.ts +121 -0
- package/nexus-stage/src/planning-search.ts +168 -0
- package/nexus-stage/src/planning-types.ts +1 -0
- package/nexus-stage/src/planning-undo.ts +68 -0
- package/nexus-stage/src/planning-view.ts +120 -121
- package/nexus-stage/src/planning.css +41 -8
- package/nexus-stage/src/seat.ts +35 -35
- package/nexus-stage/src/stage-name.ts +13 -13
- package/nexus-stage/src/tokens.ts +31 -31
- package/nexus-stage/src/viewport.ts +24 -1
- package/nexus-stage/src/wb-chat.ts +20 -6
- package/nexus-stage/src/wb-ledger.ts +20 -7
- package/nexus-stage/src/wb-names.ts +43 -0
- package/nexus-stage/src/workbench.css +8 -2
- package/nexus-stage/src/workbench.ts +55 -17
- package/nexus-stage/src/world.ts +13 -21
- package/package.json +216 -214
- package/assets/nexusDist/assets/index-1niOBYxp.js +0 -5
- package/assets/nexusDist/assets/index-uQf7NwjF.css +0 -1
- package/nexus-stage/scripts/__pycache__/mockhost.cpython-312.pyc +0 -0
|
@@ -1,156 +1,156 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The canvas write face's transport layer (POST /__dsh_a2a/canvas): action
|
|
3
|
-
* sequencing, per-team serialization, and the notice classification that
|
|
4
|
-
* design.md §3.4 mandates (host error text surfaces VERBATIM - the sidebar
|
|
5
|
-
* reference's swallowed `.catch(() => {})` is the anti-pattern here).
|
|
6
|
-
*
|
|
7
|
-
* Classification per request:
|
|
8
|
-
* - `ok:true` → success (silent)
|
|
9
|
-
* - `ok:false` WITH an `error` string → error notice, verbatim
|
|
10
|
-
* - `ok:false` without `error` (absent target, → idempotent no-op,
|
|
11
|
-
* member cap hit) silent success
|
|
12
|
-
* - HTTP >= 400 → body error verbatim, else `HTTP <status>`
|
|
13
|
-
* - send() throws → `画布不可达:<message>`
|
|
14
|
-
* - 200 + ok:false WITHOUT an error string → idempotent no-op
|
|
15
|
-
* (absent target, member/team cap hit) — silent success
|
|
16
|
-
* - 200 + non-JSON body (proxy artifact) → classified as success
|
|
17
|
-
* (known limitation: nothing parseable to classify)
|
|
18
|
-
*
|
|
19
|
-
* Ops on the same team run through a serial queue (a later gesture waits
|
|
20
|
-
* instead of racing; cross-team ops run in parallel) - mirrors the
|
|
21
|
-
* sidebar's per-team busy keys, minus the rejection hostility. Pure
|
|
22
|
-
* control flow: the transport is injected.
|
|
23
|
-
* @module nexus-stage/canvas-wire
|
|
24
|
-
*/
|
|
25
|
-
|
|
26
|
-
/** One send outcome: HTTP status plus the parsed JSON body (whatever it is). */
|
|
27
|
-
export interface CanvasWireResult {
|
|
28
|
-
status: number
|
|
29
|
-
body: { ok?: boolean; error?: string; teams?: readonly string[]; members?: readonly string[]; name?: string }
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/** Transport injection: POST the body, resolve with parsed outcome, throw on network failure. */
|
|
33
|
-
export type CanvasSend = (body: unknown) => Promise<CanvasWireResult>
|
|
34
|
-
|
|
35
|
-
export interface CanvasWireDeps {
|
|
36
|
-
send: CanvasSend
|
|
37
|
-
onNotice(kind: 'error' | 'info', text: string): void
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export interface CanvasWire {
|
|
41
|
-
/** create → add-member per id, serial, first failure stops. `created`:
|
|
42
|
-
* the team did not exist model-side before this action — a first-add
|
|
43
|
-
* failure would then leave a ghost EMPTY team on the host, compensated
|
|
44
|
-
* with a best-effort remove. */
|
|
45
|
-
createTeam(name: string, ids: readonly string[], created?: boolean): Promise<boolean>
|
|
46
|
-
addMembers(team: string, ids: readonly string[]): Promise<boolean>
|
|
47
|
-
removeMembers(team: string, ids: readonly string[]): Promise<boolean>
|
|
48
|
-
/** Pre-computed remove/add roster ops (from canvas-ops.reorderOps). */
|
|
49
|
-
runRosterOps(team: string, ops: ReadonlyArray<{ op: 'remove' | 'add'; id: string }>): Promise<boolean>
|
|
50
|
-
removeTeam(name: string): Promise<boolean>
|
|
51
|
-
hasPending(): boolean
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
type RosterOp = { op: 'remove' | 'add'; id: string }
|
|
55
|
-
|
|
56
|
-
function errorText(body: CanvasWireResult['body']): string | undefined {
|
|
57
|
-
return typeof body.error === 'string' ? body.error : undefined
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
export function createCanvasWire(deps: CanvasWireDeps): CanvasWire {
|
|
61
|
-
const queues = new Map<string, Promise<unknown>>()
|
|
62
|
-
let pending = 0
|
|
63
|
-
|
|
64
|
-
/** Serial per-team queue: same key waits, other keys run in parallel. */
|
|
65
|
-
function enqueue<T>(key: string, task: () => Promise<T>): Promise<T> {
|
|
66
|
-
pending += 1
|
|
67
|
-
const prev = queues.get(key) ?? Promise.resolve()
|
|
68
|
-
const run = async (): Promise<T> => {
|
|
69
|
-
try {
|
|
70
|
-
return await task()
|
|
71
|
-
} finally {
|
|
72
|
-
pending -= 1 // settles BEFORE the returned promise resolves
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
const next = prev.then(run, run) // run regardless of how the predecessor settled
|
|
76
|
-
queues.set(
|
|
77
|
-
key,
|
|
78
|
-
next.then(
|
|
79
|
-
() => undefined,
|
|
80
|
-
() => undefined,
|
|
81
|
-
),
|
|
82
|
-
)
|
|
83
|
-
return next
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/** One classified request — NEVER enqueues (public ops own the queue). */
|
|
87
|
-
async function raw(body: Record<string, unknown>): Promise<boolean> {
|
|
88
|
-
let res: CanvasWireResult
|
|
89
|
-
try {
|
|
90
|
-
res = await deps.send(body)
|
|
91
|
-
} catch (error) {
|
|
92
|
-
const message = String((error as Error | undefined)?.message ?? error).slice(0, 80)
|
|
93
|
-
deps.onNotice('error', `画布不可达:${message}`)
|
|
94
|
-
return false
|
|
95
|
-
}
|
|
96
|
-
if (res.status >= 400) {
|
|
97
|
-
deps.onNotice('error', errorText(res.body) ?? `HTTP ${res.status}`)
|
|
98
|
-
return false
|
|
99
|
-
}
|
|
100
|
-
if (res.body.ok === true) return true
|
|
101
|
-
const text = errorText(res.body)
|
|
102
|
-
if (text !== undefined) {
|
|
103
|
-
deps.onNotice('error', text)
|
|
104
|
-
return false
|
|
105
|
-
}
|
|
106
|
-
return true // ok:false without error: idempotent no-op (absent target / cap)
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
async function serial(body: Record<string, unknown>, ids: readonly string[]): Promise<boolean> {
|
|
110
|
-
for (const id of ids) {
|
|
111
|
-
if (!(await raw({ ...body, id }))) return false
|
|
112
|
-
}
|
|
113
|
-
return true
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
return {
|
|
117
|
-
createTeam(name, ids, created = false) {
|
|
118
|
-
return enqueue(name, async () => {
|
|
119
|
-
if (!(await raw({ action: 'create', name }))) return false
|
|
120
|
-
for (let i = 0; i < ids.length; i++) {
|
|
121
|
-
if (!(await raw({ action: 'add-member', name, id: ids[i]! }))) {
|
|
122
|
-
// A failure on the FIRST add of a team this action created leaves
|
|
123
|
-
// a ghost EMPTY team on the host — compensate with a remove.
|
|
124
|
-
// Later failures keep whatever members genuinely joined.
|
|
125
|
-
if (created && i === 0) await raw({ action: 'remove', name })
|
|
126
|
-
return false
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
return true
|
|
130
|
-
})
|
|
131
|
-
},
|
|
132
|
-
addMembers(team, ids) {
|
|
133
|
-
return enqueue(team, () => serial({ action: 'add-member', name: team }, ids))
|
|
134
|
-
},
|
|
135
|
-
removeMembers(team, ids) {
|
|
136
|
-
return enqueue(team, () => serial({ action: 'remove-member', name: team }, ids))
|
|
137
|
-
},
|
|
138
|
-
runRosterOps(team, ops) {
|
|
139
|
-
return enqueue(team, async () => {
|
|
140
|
-
for (const op of ops) {
|
|
141
|
-
const body = op.op === 'remove'
|
|
142
|
-
? { action: 'remove-member', name: team, id: op.id }
|
|
143
|
-
: { action: 'add-member', name: team, id: op.id }
|
|
144
|
-
if (!(await raw(body))) return false
|
|
145
|
-
}
|
|
146
|
-
return true
|
|
147
|
-
})
|
|
148
|
-
},
|
|
149
|
-
removeTeam(name) {
|
|
150
|
-
return enqueue(name, () => raw({ action: 'remove', name }))
|
|
151
|
-
},
|
|
152
|
-
hasPending() {
|
|
153
|
-
return pending > 0
|
|
154
|
-
},
|
|
155
|
-
}
|
|
156
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* The canvas write face's transport layer (POST /__dsh_a2a/canvas): action
|
|
3
|
+
* sequencing, per-team serialization, and the notice classification that
|
|
4
|
+
* design.md §3.4 mandates (host error text surfaces VERBATIM - the sidebar
|
|
5
|
+
* reference's swallowed `.catch(() => {})` is the anti-pattern here).
|
|
6
|
+
*
|
|
7
|
+
* Classification per request:
|
|
8
|
+
* - `ok:true` → success (silent)
|
|
9
|
+
* - `ok:false` WITH an `error` string → error notice, verbatim
|
|
10
|
+
* - `ok:false` without `error` (absent target, → idempotent no-op,
|
|
11
|
+
* member cap hit) silent success
|
|
12
|
+
* - HTTP >= 400 → body error verbatim, else `HTTP <status>`
|
|
13
|
+
* - send() throws → `画布不可达:<message>`
|
|
14
|
+
* - 200 + ok:false WITHOUT an error string → idempotent no-op
|
|
15
|
+
* (absent target, member/team cap hit) — silent success
|
|
16
|
+
* - 200 + non-JSON body (proxy artifact) → classified as success
|
|
17
|
+
* (known limitation: nothing parseable to classify)
|
|
18
|
+
*
|
|
19
|
+
* Ops on the same team run through a serial queue (a later gesture waits
|
|
20
|
+
* instead of racing; cross-team ops run in parallel) - mirrors the
|
|
21
|
+
* sidebar's per-team busy keys, minus the rejection hostility. Pure
|
|
22
|
+
* control flow: the transport is injected.
|
|
23
|
+
* @module nexus-stage/canvas-wire
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** One send outcome: HTTP status plus the parsed JSON body (whatever it is). */
|
|
27
|
+
export interface CanvasWireResult {
|
|
28
|
+
status: number
|
|
29
|
+
body: { ok?: boolean; error?: string; teams?: readonly string[]; members?: readonly string[]; name?: string }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Transport injection: POST the body, resolve with parsed outcome, throw on network failure. */
|
|
33
|
+
export type CanvasSend = (body: unknown) => Promise<CanvasWireResult>
|
|
34
|
+
|
|
35
|
+
export interface CanvasWireDeps {
|
|
36
|
+
send: CanvasSend
|
|
37
|
+
onNotice(kind: 'error' | 'info', text: string): void
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface CanvasWire {
|
|
41
|
+
/** create → add-member per id, serial, first failure stops. `created`:
|
|
42
|
+
* the team did not exist model-side before this action — a first-add
|
|
43
|
+
* failure would then leave a ghost EMPTY team on the host, compensated
|
|
44
|
+
* with a best-effort remove. */
|
|
45
|
+
createTeam(name: string, ids: readonly string[], created?: boolean): Promise<boolean>
|
|
46
|
+
addMembers(team: string, ids: readonly string[]): Promise<boolean>
|
|
47
|
+
removeMembers(team: string, ids: readonly string[]): Promise<boolean>
|
|
48
|
+
/** Pre-computed remove/add roster ops (from canvas-ops.reorderOps). */
|
|
49
|
+
runRosterOps(team: string, ops: ReadonlyArray<{ op: 'remove' | 'add'; id: string }>): Promise<boolean>
|
|
50
|
+
removeTeam(name: string): Promise<boolean>
|
|
51
|
+
hasPending(): boolean
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
type RosterOp = { op: 'remove' | 'add'; id: string }
|
|
55
|
+
|
|
56
|
+
function errorText(body: CanvasWireResult['body']): string | undefined {
|
|
57
|
+
return typeof body.error === 'string' ? body.error : undefined
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function createCanvasWire(deps: CanvasWireDeps): CanvasWire {
|
|
61
|
+
const queues = new Map<string, Promise<unknown>>()
|
|
62
|
+
let pending = 0
|
|
63
|
+
|
|
64
|
+
/** Serial per-team queue: same key waits, other keys run in parallel. */
|
|
65
|
+
function enqueue<T>(key: string, task: () => Promise<T>): Promise<T> {
|
|
66
|
+
pending += 1
|
|
67
|
+
const prev = queues.get(key) ?? Promise.resolve()
|
|
68
|
+
const run = async (): Promise<T> => {
|
|
69
|
+
try {
|
|
70
|
+
return await task()
|
|
71
|
+
} finally {
|
|
72
|
+
pending -= 1 // settles BEFORE the returned promise resolves
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const next = prev.then(run, run) // run regardless of how the predecessor settled
|
|
76
|
+
queues.set(
|
|
77
|
+
key,
|
|
78
|
+
next.then(
|
|
79
|
+
() => undefined,
|
|
80
|
+
() => undefined,
|
|
81
|
+
),
|
|
82
|
+
)
|
|
83
|
+
return next
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** One classified request — NEVER enqueues (public ops own the queue). */
|
|
87
|
+
async function raw(body: Record<string, unknown>): Promise<boolean> {
|
|
88
|
+
let res: CanvasWireResult
|
|
89
|
+
try {
|
|
90
|
+
res = await deps.send(body)
|
|
91
|
+
} catch (error) {
|
|
92
|
+
const message = String((error as Error | undefined)?.message ?? error).slice(0, 80)
|
|
93
|
+
deps.onNotice('error', `画布不可达:${message}`)
|
|
94
|
+
return false
|
|
95
|
+
}
|
|
96
|
+
if (res.status >= 400) {
|
|
97
|
+
deps.onNotice('error', errorText(res.body) ?? `HTTP ${res.status}`)
|
|
98
|
+
return false
|
|
99
|
+
}
|
|
100
|
+
if (res.body.ok === true) return true
|
|
101
|
+
const text = errorText(res.body)
|
|
102
|
+
if (text !== undefined) {
|
|
103
|
+
deps.onNotice('error', text)
|
|
104
|
+
return false
|
|
105
|
+
}
|
|
106
|
+
return true // ok:false without error: idempotent no-op (absent target / cap)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function serial(body: Record<string, unknown>, ids: readonly string[]): Promise<boolean> {
|
|
110
|
+
for (const id of ids) {
|
|
111
|
+
if (!(await raw({ ...body, id }))) return false
|
|
112
|
+
}
|
|
113
|
+
return true
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
createTeam(name, ids, created = false) {
|
|
118
|
+
return enqueue(name, async () => {
|
|
119
|
+
if (!(await raw({ action: 'create', name }))) return false
|
|
120
|
+
for (let i = 0; i < ids.length; i++) {
|
|
121
|
+
if (!(await raw({ action: 'add-member', name, id: ids[i]! }))) {
|
|
122
|
+
// A failure on the FIRST add of a team this action created leaves
|
|
123
|
+
// a ghost EMPTY team on the host — compensate with a remove.
|
|
124
|
+
// Later failures keep whatever members genuinely joined.
|
|
125
|
+
if (created && i === 0) await raw({ action: 'remove', name })
|
|
126
|
+
return false
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return true
|
|
130
|
+
})
|
|
131
|
+
},
|
|
132
|
+
addMembers(team, ids) {
|
|
133
|
+
return enqueue(team, () => serial({ action: 'add-member', name: team }, ids))
|
|
134
|
+
},
|
|
135
|
+
removeMembers(team, ids) {
|
|
136
|
+
return enqueue(team, () => serial({ action: 'remove-member', name: team }, ids))
|
|
137
|
+
},
|
|
138
|
+
runRosterOps(team, ops) {
|
|
139
|
+
return enqueue(team, async () => {
|
|
140
|
+
for (const op of ops) {
|
|
141
|
+
const body = op.op === 'remove'
|
|
142
|
+
? { action: 'remove-member', name: team, id: op.id }
|
|
143
|
+
: { action: 'add-member', name: team, id: op.id }
|
|
144
|
+
if (!(await raw(body))) return false
|
|
145
|
+
}
|
|
146
|
+
return true
|
|
147
|
+
})
|
|
148
|
+
},
|
|
149
|
+
removeTeam(name) {
|
|
150
|
+
return enqueue(name, () => raw({ action: 'remove', name }))
|
|
151
|
+
},
|
|
152
|
+
hasPending() {
|
|
153
|
+
return pending > 0
|
|
154
|
+
},
|
|
155
|
+
}
|
|
156
|
+
}
|
package/nexus-stage/src/edges.ts
CHANGED
|
@@ -1,72 +1,72 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Star-shaped membership edges for the 2D planning canvas: one spoke per
|
|
3
|
-
* (team frame -> member card), replacing the O(n^2) pairwise mesh. All
|
|
4
|
-
* geometry is world-space so the SVG layer can emit raw coordinates - the
|
|
5
|
-
* transformed #world container carries them to the screen for free.
|
|
6
|
-
*
|
|
7
|
-
* Anchor contract (must stay in lockstep with planning.css):
|
|
8
|
-
* - origin = the frame titlebar's bottom-edge midpoint. The head is a
|
|
9
|
-
* 24px border-box straddling the frame's top border at top:-13px, so
|
|
10
|
-
* its bottom edge sits at fy + 11, horizontally centered at fx + fw/2.
|
|
11
|
-
* - target = the member card's top-edge midpoint {nx, ny - NODE_H/2}.
|
|
12
|
-
*
|
|
13
|
-
* Cross-team members draw solid from their first membership (state order)
|
|
14
|
-
* and dashed from every later one - the many-to-many invariant made
|
|
15
|
-
* visible. Edges are derived only; there is no freehand edge in this
|
|
16
|
-
* canvas. Pure: no DOM, no three.
|
|
17
|
-
* @module nexus-stage/edges
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
|
-
import { NODE_H } from './world'
|
|
21
|
-
import type { FrameGeom } from './world'
|
|
22
|
-
|
|
23
|
-
/** Titlebar border-box height (planning.css .frame-head must match). */
|
|
24
|
-
export const FRAME_HEAD_H = 24
|
|
25
|
-
/** Titlebar CSS top offset, negative - straddles the frame's top border. */
|
|
26
|
-
export const FRAME_HEAD_TOP = -13
|
|
27
|
-
/** Origin anchor: frame top + the head's bottom edge offset. */
|
|
28
|
-
export const FRAME_HEAD_LIFT = FRAME_HEAD_TOP + FRAME_HEAD_H
|
|
29
|
-
|
|
30
|
-
/** Minimal team shape (topology.CanvasTeam satisfies it). */
|
|
31
|
-
export interface EdgeTeam { name: string; members: ReadonlyArray<{ id: string }> }
|
|
32
|
-
|
|
33
|
-
/** One derived spoke, ready for the SVG layer. */
|
|
34
|
-
export interface StarEdge {
|
|
35
|
-
x1: number
|
|
36
|
-
y1: number
|
|
37
|
-
x2: number
|
|
38
|
-
y2: number
|
|
39
|
-
/** True from a member's second membership on (cross-team many-to-many). */
|
|
40
|
-
dashed: boolean
|
|
41
|
-
/** Team name and member id, for keyed diffing. */
|
|
42
|
-
team: string
|
|
43
|
-
id: string
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Compute every membership spoke for the current world. Frames without a
|
|
48
|
-
* rect and member ids without a position are skipped - dangling rows are
|
|
49
|
-
* a polling transient, not an error.
|
|
50
|
-
*/
|
|
51
|
-
export function starEdges(
|
|
52
|
-
teams: ReadonlyArray<EdgeTeam>,
|
|
53
|
-
frames: ReadonlyMap<string, FrameGeom>,
|
|
54
|
-
positions: ReadonlyMap<string, { readonly x: number; readonly y: number }>,
|
|
55
|
-
): StarEdge[] {
|
|
56
|
-
const out: StarEdge[] = []
|
|
57
|
-
const seen = new Set<string>()
|
|
58
|
-
for (const team of teams) {
|
|
59
|
-
const rect = frames.get(team.name)
|
|
60
|
-
if (rect === undefined) continue
|
|
61
|
-
const x1 = rect.x + rect.w / 2
|
|
62
|
-
const y1 = rect.y + FRAME_HEAD_LIFT
|
|
63
|
-
for (const member of team.members) {
|
|
64
|
-
const pos = positions.get(member.id)
|
|
65
|
-
if (pos === undefined) continue
|
|
66
|
-
const dashed = seen.has(member.id)
|
|
67
|
-
seen.add(member.id)
|
|
68
|
-
out.push({ x1, y1, x2: pos.x, y2: pos.y - NODE_H / 2, dashed, team: team.name, id: member.id })
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
return out
|
|
72
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Star-shaped membership edges for the 2D planning canvas: one spoke per
|
|
3
|
+
* (team frame -> member card), replacing the O(n^2) pairwise mesh. All
|
|
4
|
+
* geometry is world-space so the SVG layer can emit raw coordinates - the
|
|
5
|
+
* transformed #world container carries them to the screen for free.
|
|
6
|
+
*
|
|
7
|
+
* Anchor contract (must stay in lockstep with planning.css):
|
|
8
|
+
* - origin = the frame titlebar's bottom-edge midpoint. The head is a
|
|
9
|
+
* 24px border-box straddling the frame's top border at top:-13px, so
|
|
10
|
+
* its bottom edge sits at fy + 11, horizontally centered at fx + fw/2.
|
|
11
|
+
* - target = the member card's top-edge midpoint {nx, ny - NODE_H/2}.
|
|
12
|
+
*
|
|
13
|
+
* Cross-team members draw solid from their first membership (state order)
|
|
14
|
+
* and dashed from every later one - the many-to-many invariant made
|
|
15
|
+
* visible. Edges are derived only; there is no freehand edge in this
|
|
16
|
+
* canvas. Pure: no DOM, no three.
|
|
17
|
+
* @module nexus-stage/edges
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { NODE_H } from './world'
|
|
21
|
+
import type { FrameGeom } from './world'
|
|
22
|
+
|
|
23
|
+
/** Titlebar border-box height (planning.css .frame-head must match). */
|
|
24
|
+
export const FRAME_HEAD_H = 24
|
|
25
|
+
/** Titlebar CSS top offset, negative - straddles the frame's top border. */
|
|
26
|
+
export const FRAME_HEAD_TOP = -13
|
|
27
|
+
/** Origin anchor: frame top + the head's bottom edge offset. */
|
|
28
|
+
export const FRAME_HEAD_LIFT = FRAME_HEAD_TOP + FRAME_HEAD_H
|
|
29
|
+
|
|
30
|
+
/** Minimal team shape (topology.CanvasTeam satisfies it). */
|
|
31
|
+
export interface EdgeTeam { name: string; members: ReadonlyArray<{ id: string }> }
|
|
32
|
+
|
|
33
|
+
/** One derived spoke, ready for the SVG layer. */
|
|
34
|
+
export interface StarEdge {
|
|
35
|
+
x1: number
|
|
36
|
+
y1: number
|
|
37
|
+
x2: number
|
|
38
|
+
y2: number
|
|
39
|
+
/** True from a member's second membership on (cross-team many-to-many). */
|
|
40
|
+
dashed: boolean
|
|
41
|
+
/** Team name and member id, for keyed diffing. */
|
|
42
|
+
team: string
|
|
43
|
+
id: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Compute every membership spoke for the current world. Frames without a
|
|
48
|
+
* rect and member ids without a position are skipped - dangling rows are
|
|
49
|
+
* a polling transient, not an error.
|
|
50
|
+
*/
|
|
51
|
+
export function starEdges(
|
|
52
|
+
teams: ReadonlyArray<EdgeTeam>,
|
|
53
|
+
frames: ReadonlyMap<string, FrameGeom>,
|
|
54
|
+
positions: ReadonlyMap<string, { readonly x: number; readonly y: number }>,
|
|
55
|
+
): StarEdge[] {
|
|
56
|
+
const out: StarEdge[] = []
|
|
57
|
+
const seen = new Set<string>()
|
|
58
|
+
for (const team of teams) {
|
|
59
|
+
const rect = frames.get(team.name)
|
|
60
|
+
if (rect === undefined) continue
|
|
61
|
+
const x1 = rect.x + rect.w / 2
|
|
62
|
+
const y1 = rect.y + FRAME_HEAD_LIFT
|
|
63
|
+
for (const member of team.members) {
|
|
64
|
+
const pos = positions.get(member.id)
|
|
65
|
+
if (pos === undefined) continue
|
|
66
|
+
const dashed = seen.has(member.id)
|
|
67
|
+
seen.add(member.id)
|
|
68
|
+
out.push({ x1, y1, x2: pos.x, y2: pos.y - NODE_H / 2, dashed, team: team.name, id: member.id })
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return out
|
|
72
|
+
}
|