@nelsonlongxiang/dsh-open-a2a-net 0.6.41 → 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 +1 -1
- package/README_zh.md +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/lib/client.js +64 -64
- package/lib/client.js.map +1 -1
- package/lib/index.js +57 -9
- 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/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 +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
|
@@ -1,31 +1,31 @@
|
|
|
1
|
-
/** Human owner form only: labels never grant membership or become routing ids. */
|
|
2
|
-
export function createSeatNameEditor(sessions: readonly { id: string; name?: string; label?: string }[], names: Record<string, string>, save: (names: Record<string, string>) => Promise<{ ok: boolean; error?: string }>): HTMLElement {
|
|
3
|
-
const box = document.createElement('section')
|
|
4
|
-
const heading = document.createElement('h4'); heading.textContent = '会话席位名(仅人工设定)'; box.appendChild(heading)
|
|
5
|
-
const note = document.createElement('p'); note.className = 'wb-hint'; note.textContent = '稳定展示名,不改变会话标题、路由句柄或准入。留空并保存可清除。'; box.appendChild(note)
|
|
6
|
-
const select = document.createElement('select'); select.setAttribute('aria-label', '选择命名的本机会话')
|
|
7
|
-
for (const id of new Set([...sessions.map(row => row.id), ...Object.keys(names)])) {
|
|
8
|
-
const row = sessions.find(row => row.id === id), option = document.createElement('option')
|
|
9
|
-
option.value = id; option.textContent = (names[id] ?? row?.name ?? row?.label ?? id) + ' · ' + id; select.appendChild(option)
|
|
10
|
-
}
|
|
11
|
-
const input = document.createElement('input'); input.type = 'text'; input.maxLength = 64; input.setAttribute('aria-label', '人工席位名'); input.placeholder = '例如:开发席'
|
|
12
|
-
const button = document.createElement('button'); button.type = 'submit'; button.textContent = '保存席位名'; button.className = 'wb-member-submit'
|
|
13
|
-
const form = document.createElement('form'); form.className = 'wb-member-add'; form.append(select, input, button)
|
|
14
|
-
const status = document.createElement('p'); status.className = 'wb-hint'; status.setAttribute('role', 'status'); status.setAttribute('aria-live', 'polite')
|
|
15
|
-
let pending = false
|
|
16
|
-
const load = () => { input.value = names[select.value] ?? ''; button.disabled = pending || select.value === '' }
|
|
17
|
-
select.addEventListener('change', load); load()
|
|
18
|
-
form.addEventListener('submit', async event => {
|
|
19
|
-
event.preventDefault(); if (pending || select.value === '') return
|
|
20
|
-
const id = select.value, value = input.value.trim(), next = { ...names }
|
|
21
|
-
if (value === '') delete next[id]; else next[id] = value
|
|
22
|
-
pending = true; button.disabled = true; select.disabled = true; input.disabled = true
|
|
23
|
-
try {
|
|
24
|
-
const result = await save(next)
|
|
25
|
-
if (result.ok) { delete names[id]; if (value !== '') names[id] = value; status.textContent = '席位名已保存;目录将随状态刷新。' }
|
|
26
|
-
else status.textContent = result.error ?? '保存失败'
|
|
27
|
-
} catch (error) { status.textContent = '保存失败:' + String(error) }
|
|
28
|
-
finally { pending = false; select.disabled = false; input.disabled = false; load() }
|
|
29
|
-
})
|
|
30
|
-
box.append(form, status); return box
|
|
31
|
-
}
|
|
1
|
+
/** Human owner form only: labels never grant membership or become routing ids. */
|
|
2
|
+
export function createSeatNameEditor(sessions: readonly { id: string; name?: string; label?: string }[], names: Record<string, string>, save: (names: Record<string, string>) => Promise<{ ok: boolean; error?: string }>): HTMLElement {
|
|
3
|
+
const box = document.createElement('section')
|
|
4
|
+
const heading = document.createElement('h4'); heading.textContent = '会话席位名(仅人工设定)'; box.appendChild(heading)
|
|
5
|
+
const note = document.createElement('p'); note.className = 'wb-hint'; note.textContent = '稳定展示名,不改变会话标题、路由句柄或准入。留空并保存可清除。'; box.appendChild(note)
|
|
6
|
+
const select = document.createElement('select'); select.setAttribute('aria-label', '选择命名的本机会话')
|
|
7
|
+
for (const id of new Set([...sessions.map(row => row.id), ...Object.keys(names)])) {
|
|
8
|
+
const row = sessions.find(row => row.id === id), option = document.createElement('option')
|
|
9
|
+
option.value = id; option.textContent = (names[id] ?? row?.name ?? row?.label ?? id) + ' · ' + id; select.appendChild(option)
|
|
10
|
+
}
|
|
11
|
+
const input = document.createElement('input'); input.type = 'text'; input.maxLength = 64; input.setAttribute('aria-label', '人工席位名'); input.placeholder = '例如:开发席'
|
|
12
|
+
const button = document.createElement('button'); button.type = 'submit'; button.textContent = '保存席位名'; button.className = 'wb-member-submit'
|
|
13
|
+
const form = document.createElement('form'); form.className = 'wb-member-add'; form.append(select, input, button)
|
|
14
|
+
const status = document.createElement('p'); status.className = 'wb-hint'; status.setAttribute('role', 'status'); status.setAttribute('aria-live', 'polite')
|
|
15
|
+
let pending = false
|
|
16
|
+
const load = () => { input.value = names[select.value] ?? ''; button.disabled = pending || select.value === '' }
|
|
17
|
+
select.addEventListener('change', load); load()
|
|
18
|
+
form.addEventListener('submit', async event => {
|
|
19
|
+
event.preventDefault(); if (pending || select.value === '') return
|
|
20
|
+
const id = select.value, value = input.value.trim(), next = { ...names }
|
|
21
|
+
if (value === '') delete next[id]; else next[id] = value
|
|
22
|
+
pending = true; button.disabled = true; select.disabled = true; input.disabled = true
|
|
23
|
+
try {
|
|
24
|
+
const result = await save(next)
|
|
25
|
+
if (result.ok) { delete names[id]; if (value !== '') names[id] = value; status.textContent = '席位名已保存;目录将随状态刷新。' }
|
|
26
|
+
else status.textContent = result.error ?? '保存失败'
|
|
27
|
+
} catch (error) { status.textContent = '保存失败:' + String(error) }
|
|
28
|
+
finally { pending = false; select.disabled = false; input.disabled = false; load() }
|
|
29
|
+
})
|
|
30
|
+
box.append(form, status); return box
|
|
31
|
+
}
|
|
@@ -1,64 +1,64 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Drag a local session from the workbench rail (目录 · 本机会话) straight into
|
|
3
|
-
* a canvas team frame on the board (owner request 2026-11-04). The rail row
|
|
4
|
-
* carries the session id in a private drag type; the board accepts it only
|
|
5
|
-
* over a frame and turns the drop into the same add-member action a card drop
|
|
6
|
-
* makes, so roster frames, mirror frames (fleet relay) and the host's own
|
|
7
|
-
* refusals all behave exactly as for an on-board drag.
|
|
8
|
-
* @module nexus-stage/session-drag
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
/** Private drag type: an arbitrary page or file dropped on the board is ignored. */
|
|
12
|
-
export const SESSION_DRAG_TYPE = 'application/x-dsh-session'
|
|
13
|
-
|
|
14
|
-
/** Make one rail row draggable as a local session. */
|
|
15
|
-
export function makeSessionDraggable(item: HTMLElement, sessionId: string): void {
|
|
16
|
-
item.draggable = true
|
|
17
|
-
item.classList.add('is-draggable')
|
|
18
|
-
item.title = `${item.title === '' ? sessionId : item.title} · 可拖入画布队`
|
|
19
|
-
item.addEventListener('dragstart', ev => {
|
|
20
|
-
if (ev.dataTransfer === null) return
|
|
21
|
-
ev.dataTransfer.setData(SESSION_DRAG_TYPE, sessionId)
|
|
22
|
-
ev.dataTransfer.effectAllowed = 'copy'
|
|
23
|
-
})
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export interface SessionDropDeps {
|
|
27
|
-
/** The innermost team frame under a client point, if any. */
|
|
28
|
-
frameAt(clientX: number, clientY: number): string | undefined
|
|
29
|
-
/** Frame elements by team name, for the drop-target highlight. */
|
|
30
|
-
frames(): ReadonlyMap<string, HTMLElement>
|
|
31
|
-
/** A session dropped on a frame. */
|
|
32
|
-
drop(team: string, sessionId: string): void
|
|
33
|
-
readonly signal: AddEventListenerOptions
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/** Accept rail session drags on the board root. */
|
|
37
|
-
export function attachSessionDrop(root: HTMLElement, d: SessionDropDeps): void {
|
|
38
|
-
const carries = (ev: DragEvent): boolean => ev.dataTransfer !== null && [...ev.dataTransfer.types].includes(SESSION_DRAG_TYPE)
|
|
39
|
-
const highlight = (team: string | undefined): void => {
|
|
40
|
-
for (const [name, el] of d.frames()) el.classList.toggle('drop-target', name === team)
|
|
41
|
-
}
|
|
42
|
-
root.addEventListener('dragover', ev => {
|
|
43
|
-
if (!carries(ev)) return
|
|
44
|
-
const team = d.frameAt(ev.clientX, ev.clientY)
|
|
45
|
-
highlight(team)
|
|
46
|
-
if (team === undefined) return
|
|
47
|
-
ev.preventDefault() // only a frame is a drop target
|
|
48
|
-
ev.dataTransfer!.dropEffect = 'copy'
|
|
49
|
-
}, d.signal)
|
|
50
|
-
root.addEventListener('dragleave', ev => {
|
|
51
|
-
if (carries(ev) && (ev.relatedTarget === null || !root.contains(ev.relatedTarget as Node))) highlight(undefined)
|
|
52
|
-
}, d.signal)
|
|
53
|
-
// A drag cancelled with Esc over a frame fires no dragleave on the board: the source's dragend clears it.
|
|
54
|
-
root.ownerDocument.addEventListener('dragend', () => highlight(undefined), d.signal)
|
|
55
|
-
root.addEventListener('drop', ev => {
|
|
56
|
-
if (!carries(ev)) return
|
|
57
|
-
highlight(undefined)
|
|
58
|
-
const team = d.frameAt(ev.clientX, ev.clientY)
|
|
59
|
-
const id = ev.dataTransfer!.getData(SESSION_DRAG_TYPE)
|
|
60
|
-
if (team === undefined || id === '') return
|
|
61
|
-
ev.preventDefault()
|
|
62
|
-
d.drop(team, id)
|
|
63
|
-
}, d.signal)
|
|
64
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Drag a local session from the workbench rail (目录 · 本机会话) straight into
|
|
3
|
+
* a canvas team frame on the board (owner request 2026-11-04). The rail row
|
|
4
|
+
* carries the session id in a private drag type; the board accepts it only
|
|
5
|
+
* over a frame and turns the drop into the same add-member action a card drop
|
|
6
|
+
* makes, so roster frames, mirror frames (fleet relay) and the host's own
|
|
7
|
+
* refusals all behave exactly as for an on-board drag.
|
|
8
|
+
* @module nexus-stage/session-drag
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Private drag type: an arbitrary page or file dropped on the board is ignored. */
|
|
12
|
+
export const SESSION_DRAG_TYPE = 'application/x-dsh-session'
|
|
13
|
+
|
|
14
|
+
/** Make one rail row draggable as a local session. */
|
|
15
|
+
export function makeSessionDraggable(item: HTMLElement, sessionId: string): void {
|
|
16
|
+
item.draggable = true
|
|
17
|
+
item.classList.add('is-draggable')
|
|
18
|
+
item.title = `${item.title === '' ? sessionId : item.title} · 可拖入画布队`
|
|
19
|
+
item.addEventListener('dragstart', ev => {
|
|
20
|
+
if (ev.dataTransfer === null) return
|
|
21
|
+
ev.dataTransfer.setData(SESSION_DRAG_TYPE, sessionId)
|
|
22
|
+
ev.dataTransfer.effectAllowed = 'copy'
|
|
23
|
+
})
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface SessionDropDeps {
|
|
27
|
+
/** The innermost team frame under a client point, if any. */
|
|
28
|
+
frameAt(clientX: number, clientY: number): string | undefined
|
|
29
|
+
/** Frame elements by team name, for the drop-target highlight. */
|
|
30
|
+
frames(): ReadonlyMap<string, HTMLElement>
|
|
31
|
+
/** A session dropped on a frame. */
|
|
32
|
+
drop(team: string, sessionId: string): void
|
|
33
|
+
readonly signal: AddEventListenerOptions
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Accept rail session drags on the board root. */
|
|
37
|
+
export function attachSessionDrop(root: HTMLElement, d: SessionDropDeps): void {
|
|
38
|
+
const carries = (ev: DragEvent): boolean => ev.dataTransfer !== null && [...ev.dataTransfer.types].includes(SESSION_DRAG_TYPE)
|
|
39
|
+
const highlight = (team: string | undefined): void => {
|
|
40
|
+
for (const [name, el] of d.frames()) el.classList.toggle('drop-target', name === team)
|
|
41
|
+
}
|
|
42
|
+
root.addEventListener('dragover', ev => {
|
|
43
|
+
if (!carries(ev)) return
|
|
44
|
+
const team = d.frameAt(ev.clientX, ev.clientY)
|
|
45
|
+
highlight(team)
|
|
46
|
+
if (team === undefined) return
|
|
47
|
+
ev.preventDefault() // only a frame is a drop target
|
|
48
|
+
ev.dataTransfer!.dropEffect = 'copy'
|
|
49
|
+
}, d.signal)
|
|
50
|
+
root.addEventListener('dragleave', ev => {
|
|
51
|
+
if (carries(ev) && (ev.relatedTarget === null || !root.contains(ev.relatedTarget as Node))) highlight(undefined)
|
|
52
|
+
}, d.signal)
|
|
53
|
+
// A drag cancelled with Esc over a frame fires no dragleave on the board: the source's dragend clears it.
|
|
54
|
+
root.ownerDocument.addEventListener('dragend', () => highlight(undefined), d.signal)
|
|
55
|
+
root.addEventListener('drop', ev => {
|
|
56
|
+
if (!carries(ev)) return
|
|
57
|
+
highlight(undefined)
|
|
58
|
+
const team = d.frameAt(ev.clientX, ev.clientY)
|
|
59
|
+
const id = ev.dataTransfer!.getData(SESSION_DRAG_TYPE)
|
|
60
|
+
if (team === undefined || id === '') return
|
|
61
|
+
ev.preventDefault()
|
|
62
|
+
d.drop(team, id)
|
|
63
|
+
}, d.signal)
|
|
64
|
+
}
|
|
@@ -1,106 +1,106 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Viewport math for the 2D planning canvas: pan/zoom state with the same
|
|
3
|
-
* clamp contract as the persisted layout (scale 0.25-3, coordinates
|
|
4
|
-
* bounded), pointer-anchored zoom, and the CSS transform that carries the
|
|
5
|
-
* world container. Pure functions throughout - the DOM layer applies the
|
|
6
|
-
* returned transform, it never computes one.
|
|
7
|
-
*
|
|
8
|
-
* Convention (shared with the host LayoutStore): (x, y) is the world
|
|
9
|
-
* point displayed at the viewport's top-left corner; scale multiplies
|
|
10
|
-
* world units into screen pixels.
|
|
11
|
-
* @module nexus-stage/viewport
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import { SCALE_MAX, SCALE_MIN, type LayoutViewport } from './layout-doc'
|
|
15
|
-
|
|
16
|
-
/** Re-export for callers that only want the viewport vocabulary. */
|
|
17
|
-
export type { LayoutViewport, LayoutViewport as ViewportState } from './layout-doc'
|
|
18
|
-
|
|
19
|
-
/** The zero viewport: origin, unzoomed - what a fresh canvas shows. */
|
|
20
|
-
export const IDENTITY_VIEWPORT: LayoutViewport = { x: 0, y: 0, scale: 1 }
|
|
21
|
-
|
|
22
|
-
function clampScale(scale: number): number {
|
|
23
|
-
return Math.max(SCALE_MIN, Math.min(SCALE_MAX, scale))
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/** Clamp an untrusted viewport into the persisted-layout envelope. */
|
|
27
|
-
export function clampViewport(vp: LayoutViewport): LayoutViewport {
|
|
28
|
-
const safe = Number.isFinite(vp.scale) ? vp.scale : 1
|
|
29
|
-
const x = Number.isFinite(vp.x) ? vp.x : 0
|
|
30
|
-
const y = Number.isFinite(vp.y) ? vp.y : 0
|
|
31
|
-
return { x, y, scale: clampScale(safe) }
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/** Screen (client-relative px) -> world point. */
|
|
35
|
-
export function screenToWorld(vp: LayoutViewport, sx: number, sy: number): { x: number; y: number } {
|
|
36
|
-
return { x: vp.x + sx / vp.scale, y: vp.y + sy / vp.scale }
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/** World point -> screen (client-relative px). */
|
|
40
|
-
export function worldToScreen(vp: LayoutViewport, wx: number, wy: number): { x: number; y: number } {
|
|
41
|
-
return { x: (wx - vp.x) * vp.scale, y: (wy - vp.y) * vp.scale }
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/** Pan by screen-space pixels; dividing by scale keeps 1px = 1px on screen. */
|
|
45
|
-
export function panBy(vp: LayoutViewport, dx: number, dy: number): LayoutViewport {
|
|
46
|
-
return { x: vp.x + dx / vp.scale, y: vp.y + dy / vp.scale, scale: vp.scale }
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Zoom by a multiplicative factor keeping the world point under the
|
|
51
|
-
* anchor screen position (ax, ay) fixed: the card under the cursor stays
|
|
52
|
-
* under the cursor. Invariant: vp' = vp + a/s - a/s'.
|
|
53
|
-
*/
|
|
54
|
-
export function zoomAt(vp: LayoutViewport, factor: number, ax: number, ay: number): LayoutViewport {
|
|
55
|
-
const scale = clampScale(vp.scale * factor)
|
|
56
|
-
if (scale === vp.scale) return vp
|
|
57
|
-
return { x: vp.x + ax / vp.scale - ax / scale, y: vp.y + ay / vp.scale - ay / scale, scale }
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/** Content bounds in world space (null = nothing to fit). */
|
|
61
|
-
export interface WorldBounds { readonly minX: number; readonly minY: number; readonly maxX: number; readonly maxY: number }
|
|
62
|
-
|
|
63
|
-
/** A fit never shrinks past a readable floor: below {@link NARROW_FIT_WIDTH}
|
|
64
|
-
* (the board's phone breakpoint) {@link NARROW_FIT_FLOOR}, at any wider
|
|
65
|
-
* viewport {@link WIDE_FIT_FLOOR}. Fitting a whole board drew 13px card
|
|
66
|
-
* names at ~7px on a phone, and a real 66-card board at 30% on a desktop
|
|
67
|
-
* (review 2026-10-27, real-host check). Past the floor the fit anchors the
|
|
68
|
-
* content's top-left corner instead of its centre; the rest is a pan away. */
|
|
69
|
-
export const NARROW_FIT_WIDTH = 680
|
|
70
|
-
export const NARROW_FIT_FLOOR = 0.8
|
|
71
|
-
export const WIDE_FIT_FLOOR = 0.6
|
|
72
|
-
const FIT_MARGIN = 16
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* Fit the content bounds into a viewW x viewH viewport at 90% fill,
|
|
76
|
-
* clamped to the persisted scale envelope; empty bounds reset to identity.
|
|
77
|
-
* The scale is floored (see {@link WIDE_FIT_FLOOR}); an explicit maxScale
|
|
78
|
-
* below the floor still wins.
|
|
79
|
-
*/
|
|
80
|
-
export function fitView(bounds: WorldBounds | null, viewW: number, viewH: number, maxScale = Number.POSITIVE_INFINITY): LayoutViewport {
|
|
81
|
-
if (bounds === null || viewW <= 0 || viewH <= 0) return IDENTITY_VIEWPORT
|
|
82
|
-
const bw = bounds.maxX - bounds.minX
|
|
83
|
-
const bh = bounds.maxY - bounds.minY
|
|
84
|
-
if (!(bw > 0) || !(bh > 0)) return IDENTITY_VIEWPORT
|
|
85
|
-
// The floor is about content that does not FIT at a readable scale, so it
|
|
86
|
-
// is judged on the natural fit — a caller's maxScale cap below the floor
|
|
87
|
-
// must not turn a board that fits into a top-left-anchored one (PR #79 review).
|
|
88
|
-
const natural = 0.9 * Math.min(viewW / bw, viewH / bh)
|
|
89
|
-
const fitted = Math.min(maxScale, natural)
|
|
90
|
-
const floor = viewW < NARROW_FIT_WIDTH ? NARROW_FIT_FLOOR : WIDE_FIT_FLOOR
|
|
91
|
-
if (natural < floor) {
|
|
92
|
-
const scale = clampScale(Math.min(maxScale, floor))
|
|
93
|
-
return { x: bounds.minX - FIT_MARGIN / scale, y: bounds.minY - FIT_MARGIN / scale, scale }
|
|
94
|
-
}
|
|
95
|
-
const scale = clampScale(fitted)
|
|
96
|
-
return {
|
|
97
|
-
x: (bounds.minX + bounds.maxX) / 2 - viewW / (2 * scale),
|
|
98
|
-
y: (bounds.minY + bounds.maxY) / 2 - viewH / (2 * scale),
|
|
99
|
-
scale,
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/** The CSS transform for the world container: translate then scale. */
|
|
104
|
-
export function worldTransformCss(vp: LayoutViewport): string {
|
|
105
|
-
return `translate(${-vp.x * vp.scale}px, ${-vp.y * vp.scale}px) scale(${vp.scale})`
|
|
106
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Viewport math for the 2D planning canvas: pan/zoom state with the same
|
|
3
|
+
* clamp contract as the persisted layout (scale 0.25-3, coordinates
|
|
4
|
+
* bounded), pointer-anchored zoom, and the CSS transform that carries the
|
|
5
|
+
* world container. Pure functions throughout - the DOM layer applies the
|
|
6
|
+
* returned transform, it never computes one.
|
|
7
|
+
*
|
|
8
|
+
* Convention (shared with the host LayoutStore): (x, y) is the world
|
|
9
|
+
* point displayed at the viewport's top-left corner; scale multiplies
|
|
10
|
+
* world units into screen pixels.
|
|
11
|
+
* @module nexus-stage/viewport
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { SCALE_MAX, SCALE_MIN, type LayoutViewport } from './layout-doc'
|
|
15
|
+
|
|
16
|
+
/** Re-export for callers that only want the viewport vocabulary. */
|
|
17
|
+
export type { LayoutViewport, LayoutViewport as ViewportState } from './layout-doc'
|
|
18
|
+
|
|
19
|
+
/** The zero viewport: origin, unzoomed - what a fresh canvas shows. */
|
|
20
|
+
export const IDENTITY_VIEWPORT: LayoutViewport = { x: 0, y: 0, scale: 1 }
|
|
21
|
+
|
|
22
|
+
function clampScale(scale: number): number {
|
|
23
|
+
return Math.max(SCALE_MIN, Math.min(SCALE_MAX, scale))
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Clamp an untrusted viewport into the persisted-layout envelope. */
|
|
27
|
+
export function clampViewport(vp: LayoutViewport): LayoutViewport {
|
|
28
|
+
const safe = Number.isFinite(vp.scale) ? vp.scale : 1
|
|
29
|
+
const x = Number.isFinite(vp.x) ? vp.x : 0
|
|
30
|
+
const y = Number.isFinite(vp.y) ? vp.y : 0
|
|
31
|
+
return { x, y, scale: clampScale(safe) }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Screen (client-relative px) -> world point. */
|
|
35
|
+
export function screenToWorld(vp: LayoutViewport, sx: number, sy: number): { x: number; y: number } {
|
|
36
|
+
return { x: vp.x + sx / vp.scale, y: vp.y + sy / vp.scale }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** World point -> screen (client-relative px). */
|
|
40
|
+
export function worldToScreen(vp: LayoutViewport, wx: number, wy: number): { x: number; y: number } {
|
|
41
|
+
return { x: (wx - vp.x) * vp.scale, y: (wy - vp.y) * vp.scale }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Pan by screen-space pixels; dividing by scale keeps 1px = 1px on screen. */
|
|
45
|
+
export function panBy(vp: LayoutViewport, dx: number, dy: number): LayoutViewport {
|
|
46
|
+
return { x: vp.x + dx / vp.scale, y: vp.y + dy / vp.scale, scale: vp.scale }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Zoom by a multiplicative factor keeping the world point under the
|
|
51
|
+
* anchor screen position (ax, ay) fixed: the card under the cursor stays
|
|
52
|
+
* under the cursor. Invariant: vp' = vp + a/s - a/s'.
|
|
53
|
+
*/
|
|
54
|
+
export function zoomAt(vp: LayoutViewport, factor: number, ax: number, ay: number): LayoutViewport {
|
|
55
|
+
const scale = clampScale(vp.scale * factor)
|
|
56
|
+
if (scale === vp.scale) return vp
|
|
57
|
+
return { x: vp.x + ax / vp.scale - ax / scale, y: vp.y + ay / vp.scale - ay / scale, scale }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Content bounds in world space (null = nothing to fit). */
|
|
61
|
+
export interface WorldBounds { readonly minX: number; readonly minY: number; readonly maxX: number; readonly maxY: number }
|
|
62
|
+
|
|
63
|
+
/** A fit never shrinks past a readable floor: below {@link NARROW_FIT_WIDTH}
|
|
64
|
+
* (the board's phone breakpoint) {@link NARROW_FIT_FLOOR}, at any wider
|
|
65
|
+
* viewport {@link WIDE_FIT_FLOOR}. Fitting a whole board drew 13px card
|
|
66
|
+
* names at ~7px on a phone, and a real 66-card board at 30% on a desktop
|
|
67
|
+
* (review 2026-10-27, real-host check). Past the floor the fit anchors the
|
|
68
|
+
* content's top-left corner instead of its centre; the rest is a pan away. */
|
|
69
|
+
export const NARROW_FIT_WIDTH = 680
|
|
70
|
+
export const NARROW_FIT_FLOOR = 0.8
|
|
71
|
+
export const WIDE_FIT_FLOOR = 0.6
|
|
72
|
+
const FIT_MARGIN = 16
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Fit the content bounds into a viewW x viewH viewport at 90% fill,
|
|
76
|
+
* clamped to the persisted scale envelope; empty bounds reset to identity.
|
|
77
|
+
* The scale is floored (see {@link WIDE_FIT_FLOOR}); an explicit maxScale
|
|
78
|
+
* below the floor still wins.
|
|
79
|
+
*/
|
|
80
|
+
export function fitView(bounds: WorldBounds | null, viewW: number, viewH: number, maxScale = Number.POSITIVE_INFINITY): LayoutViewport {
|
|
81
|
+
if (bounds === null || viewW <= 0 || viewH <= 0) return IDENTITY_VIEWPORT
|
|
82
|
+
const bw = bounds.maxX - bounds.minX
|
|
83
|
+
const bh = bounds.maxY - bounds.minY
|
|
84
|
+
if (!(bw > 0) || !(bh > 0)) return IDENTITY_VIEWPORT
|
|
85
|
+
// The floor is about content that does not FIT at a readable scale, so it
|
|
86
|
+
// is judged on the natural fit — a caller's maxScale cap below the floor
|
|
87
|
+
// must not turn a board that fits into a top-left-anchored one (PR #79 review).
|
|
88
|
+
const natural = 0.9 * Math.min(viewW / bw, viewH / bh)
|
|
89
|
+
const fitted = Math.min(maxScale, natural)
|
|
90
|
+
const floor = viewW < NARROW_FIT_WIDTH ? NARROW_FIT_FLOOR : WIDE_FIT_FLOOR
|
|
91
|
+
if (natural < floor) {
|
|
92
|
+
const scale = clampScale(Math.min(maxScale, floor))
|
|
93
|
+
return { x: bounds.minX - FIT_MARGIN / scale, y: bounds.minY - FIT_MARGIN / scale, scale }
|
|
94
|
+
}
|
|
95
|
+
const scale = clampScale(fitted)
|
|
96
|
+
return {
|
|
97
|
+
x: (bounds.minX + bounds.maxX) / 2 - viewW / (2 * scale),
|
|
98
|
+
y: (bounds.minY + bounds.maxY) / 2 - viewH / (2 * scale),
|
|
99
|
+
scale,
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The CSS transform for the world container: translate then scale. */
|
|
104
|
+
export function worldTransformCss(vp: LayoutViewport): string {
|
|
105
|
+
return `translate(${-vp.x * vp.scale}px, ${-vp.y * vp.scale}px) scale(${vp.scale})`
|
|
106
|
+
}
|