@songsid/agend 2.3.2-beta.1 → 2.3.2-beta.3
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/dist/general-knowledge/skills/web-dashboard/SKILL.canvas-worker-off.md +7 -0
- package/dist/general-knowledge/skills/web-dashboard/SKILL.md +9 -1
- package/dist/ui/chat-strings.js +2 -2
- package/dist/ui/fleet-office-overview.js +167 -0
- package/dist/ui/fleet-office-skin.js +10 -8
- package/dist/ui/fleet-office-store.js +10 -2
- package/dist/ui/fleet-office.js +358 -155
- package/dist/ui/panel-chat.js +8 -1
- package/dist/ui/shared/app.css +15 -0
- package/package.json +1 -1
|
@@ -274,6 +274,13 @@ saved the offices meanwhile, Save says so and keeps your layout — Save again p
|
|
|
274
274
|
only calls and work get bubbles (a few at most) and name tags show on hover or focus. **Hide paused and stopped**
|
|
275
275
|
(per browser, off by default) leaves the sleepers out. Now and then someone says a short line for their state in a
|
|
276
276
|
bubble (never message content). Reduced motion: still poses, nothing in the air, bubbles shown whole.
|
|
277
|
+
**All offices** (in the switcher once two or more offices are stored, kept per device) shows every stored office as
|
|
278
|
+
a tile on one canvas, each exactly as its own view (its style, members, seats, bubbles, walking), labelled with its
|
|
279
|
+
name and head count, each as large as its office's content, packed to fill the panel; **Lobby tile** (on by
|
|
280
|
+
default; disabled when everyone is in an office) adds the instances in no office. Below 75% name tags show on hover
|
|
281
|
+
only. Hover and click work across
|
|
282
|
+
tiles; a tile's label, or a double-click on it, opens that office; traffic between offices flies straight across.
|
|
283
|
+
Edit offices and Edit layout wait for a single office.
|
|
277
284
|
The Office fills the Fleet panel and moves like the Canvas (#2540): drag to pan, the wheel or a pinch to zoom,
|
|
278
285
|
**+** / **−** / **Fit** / **Fill** at the top right (the zoom shown as a percentage above them) or the `+ - 0` keys
|
|
279
286
|
(with the office focused), an overview at the bottom right to jump (not on a phone). It takes the whole panel: the
|
|
@@ -152,7 +152,8 @@ workspace `inbox/`; an unused upload is deleted after 30 minutes.
|
|
|
152
152
|
|
|
153
153
|
**In replies.** Images inline; video and audio play inline; Markdown, CSV, JSON, code and text files get a **Show**
|
|
154
154
|
card (200 lines, then "Show all … lines"); other files download. Code blocks have a language tag, **Wrap** and
|
|
155
|
-
**Copy**, and fold after 30 lines.
|
|
155
|
+
**Copy**, and fold after 30 lines. **⋯ → Full width** (desk only, off by default, this browser's choice) widens the
|
|
156
|
+
thread and the composer to the whole chat panel for wide tables and code; a person's bubbles keep their size. Each reply has **Copy this reply**. Markdown: headings, lists, quotes, bold/italic/
|
|
156
157
|
strike, links, and tables (header row plus separator, alignment, up to 20 columns). Discord mentions show as names;
|
|
157
158
|
a Discord server emoji (`<:name:id>`) of the fleet's servers shows as its picture, and any other (or one whose picture
|
|
158
159
|
can't load) as `:name:`. Unicode emoji are drawn by the system's colour emoji font under every theme font (boxes on
|
|
@@ -284,6 +285,13 @@ saved the offices meanwhile, Save says so and keeps your layout — Save again p
|
|
|
284
285
|
only calls and work get bubbles (a few at most) and name tags show on hover or focus. **Hide paused and stopped**
|
|
285
286
|
(per browser, off by default) leaves the sleepers out. Now and then someone says a short line for their state in a
|
|
286
287
|
bubble (never message content). Reduced motion: still poses, nothing in the air, bubbles shown whole.
|
|
288
|
+
**All offices** (in the switcher once two or more offices are stored, kept per device) shows every stored office as
|
|
289
|
+
a tile on one canvas, each exactly as its own view (its style, members, seats, bubbles, walking), labelled with its
|
|
290
|
+
name and head count, each as large as its office's content, packed to fill the panel; **Lobby tile** (on by
|
|
291
|
+
default; disabled when everyone is in an office) adds the instances in no office. Below 75% name tags show on hover
|
|
292
|
+
only. Hover and click work across
|
|
293
|
+
tiles; a tile's label, or a double-click on it, opens that office; traffic between offices flies straight across.
|
|
294
|
+
Edit offices and Edit layout wait for a single office.
|
|
287
295
|
The Office fills the Fleet panel and moves like the Canvas (#2540): drag to pan, the wheel or a pinch to zoom,
|
|
288
296
|
**+** / **−** / **Fit** / **Fill** at the top right (the zoom shown as a percentage above them) or the `+ - 0` keys
|
|
289
297
|
(with the office focused), an overview at the bottom right to jump (not on a phone). It takes the whole panel: the
|
package/dist/ui/chat-strings.js
CHANGED
|
@@ -116,7 +116,7 @@ register("chat", {
|
|
|
116
116
|
file_empty: "empty file", file_too_large: "over {0} MB", file_too_large_public: "over {0} MB — the most the public link carries in one upload", file_too_many: "at most {0} files per message", file_too_much: "the files together would be over {0} MB", removeFile: "Remove", putBack: "Put back",
|
|
117
117
|
noMessages: "Type a task below.",
|
|
118
118
|
pick: "Choose an agent to start chatting.", noAgents: "Create an agent to start chatting.", newAgent: "New agent", notFound: "No instance called “{0}”", notFoundHint: "It may have been deleted or renamed.", backToList: "Choose an agent",
|
|
119
|
-
details: "Instance details", start: "Start instance", stopInstance: "Stop instance", restart: "Restart instance", delete: "Delete instance…",
|
|
119
|
+
details: "Instance details", fullWidth: "Full width", start: "Start instance", stopInstance: "Stop instance", restart: "Restart instance", delete: "Delete instance…",
|
|
120
120
|
started: "{0} started", stoppedInst: "{0} stopped", restarted: "{0} restarted", instanceDeleted: "{0} was deleted",
|
|
121
121
|
deleteTitle: "Delete {0}?", deleteHelp: "This removes the instance from the fleet and stops it. Its working directory is kept. Type “delete {0}” to confirm.", deleteGo: "Delete", cancel: "Cancel",
|
|
122
122
|
loadFailed: "Failed to load", tour: "Chat tour", tourBtnTitle: "Show the chat tour on an agent's chat", tourNoAgent: "Choose an agent before opening the chat tour.",
|
|
@@ -255,7 +255,7 @@ register("chat", {
|
|
|
255
255
|
file_empty: "空檔案", file_too_large: "超過 {0} MB", file_too_large_public: "超過 {0} MB,這是公開連結單次上傳的上限", file_too_many: "每則訊息最多 {0} 個檔案", file_too_much: "檔案合計會超過 {0} MB", removeFile: "移除", putBack: "放回輸入框",
|
|
256
256
|
noMessages: "直接在下方輸入任務。",
|
|
257
257
|
pick: "選一個 Agent 開始聊天。", noAgents: "先建立一個 Agent,再開始聊天。", newAgent: "新增 Agent", notFound: "沒有叫「{0}」的 instance", notFoundHint: "它可能已被刪除或改名。", backToList: "選擇 Agent",
|
|
258
|
-
details: "Instance 詳細資料", start: "啟動 instance", stopInstance: "停止 instance", restart: "重新啟動 instance", delete: "刪除 instance…",
|
|
258
|
+
details: "Instance 詳細資料", fullWidth: "全寬", start: "啟動 instance", stopInstance: "停止 instance", restart: "重新啟動 instance", delete: "刪除 instance…",
|
|
259
259
|
started: "{0} 已啟動", stoppedInst: "{0} 已停止", restarted: "{0} 已重新啟動", instanceDeleted: "{0} 已刪除",
|
|
260
260
|
deleteTitle: "要刪除 {0} 嗎?", deleteHelp: "這會把 instance 從 fleet 移除並停止它,工作目錄會保留。請輸入「delete {0}」確認。", deleteGo: "刪除", cancel: "取消",
|
|
261
261
|
loadFailed: "載入失敗", tour: "聊天導覽", tourBtnTitle: "在 Agent 的聊天上開啟聊天導覽", tourNoAgent: "請先選一個 Agent,再開啟聊天導覽。",
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// #2554: the Office view's "All offices" — every stored office as a tile on one canvas, side by side, each drawn exactly
|
|
2
|
+
// as its own view would be (its style, its members, seats, poses, bubbles, walking), scaled into its tile. This module
|
|
3
|
+
// is the pure part (fleet-office.js draws it): who is in no office (the lobby tile), where the tiles go, what a point
|
|
4
|
+
// on the overview hits, which tiles are on screen, and the traffic that crosses between tiles.
|
|
5
|
+
//
|
|
6
|
+
// - Shown when at least two offices are stored (overviewOk); an id no stored office can have (ALL_ID: '*' is not an
|
|
7
|
+
// office id's character), kept per device as any office pick is.
|
|
8
|
+
// - The tiles: each as large as its office's drawn content — a zones scene its 1280×720, a desks office its rooms'
|
|
9
|
+
// bounds (cropToRooms: an office of three is small) — scaled down to at most CELL_W × CELL_H, never up; a LABEL band
|
|
10
|
+
// above each (the page's label sits there), GAP apart, M from the edge. Shelf packing in the stored order (the lobby
|
|
11
|
+
// last): a row takes tiles while they fit a row width, each row as tall as its tallest tile and centred across. The
|
|
12
|
+
// row width: whichever (of the widths a run of tiles makes) lets the whole overview show largest in the panel (Fit's
|
|
13
|
+
// contain); a tie keeps the narrower. The same input always gives the same picture.
|
|
14
|
+
// - Traffic between people in different tiles is a straight flight between their seats in overview coordinates
|
|
15
|
+
// (createCrossFlights); between two people in the same tile it is that tile's own (createFlights, along its corridors).
|
|
16
|
+
import { FLIGHT, along, desksOf, membersOf, pathLength } from "./fleet-office-model.js";
|
|
17
|
+
import { hue } from "./fleet-traffic.js";
|
|
18
|
+
|
|
19
|
+
/** The switcher's "All offices" value: '*' is never in a stored office's id (fleet-office-model.js officeOk). */
|
|
20
|
+
export const ALL_ID = "*all";
|
|
21
|
+
/** The lobby tile's key: never an office's id. */
|
|
22
|
+
export const LOBBY_TILE = "\u0000lobby";
|
|
23
|
+
/** The overview's geometry, in logical units. */
|
|
24
|
+
export const OV = { CELL_W: 1280, CELL_H: 720, LABEL: 56, GAP: 24, M: 24 };
|
|
25
|
+
|
|
26
|
+
/** Whether "All offices" is offered: at least two stored offices. */
|
|
27
|
+
export const overviewOk = (stored) => Array.isArray(stored) && stored.length >= 2;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The people in no stored office, in the org chart's order (General, the teams, the rest): the lobby tile's. An office
|
|
31
|
+
* with no members holds everyone (fleet-office-model.js membersOf), so then nobody is left for the lobby.
|
|
32
|
+
*/
|
|
33
|
+
export function lobbyNodes(stored, view) {
|
|
34
|
+
const placed = new Set((stored || []).flatMap((o) => membersOf(o, view).map((n) => n.name)));
|
|
35
|
+
const out = new Map();
|
|
36
|
+
for (const n of [...view.top, ...view.teams.flatMap((tm) => tm.nodes), ...view.rest.nodes]) if (!placed.has(n.name) && !out.has(n.name)) out.set(n.name, n);
|
|
37
|
+
return [...out.values()];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A scene's scale in its tile: its own size, scaled down to fit a CELL_W × CELL_H cell (never up). */
|
|
41
|
+
export const tileScale = (w, h) => Math.min(1, OV.CELL_W / Math.max(1, w), OV.CELL_H / Math.max(1, h));
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A desks layout cropped to its rooms: everything moved so the rooms' bounds start at (0, 0), its size theirs (the
|
|
45
|
+
* corridors outside the rooms and the margins are not part of a tile). Unchanged when it has no rooms.
|
|
46
|
+
*/
|
|
47
|
+
export function cropToRooms(L) {
|
|
48
|
+
if (!L.rooms.length) return L;
|
|
49
|
+
const x0 = Math.min(...L.rooms.map((r) => r.x)), y0 = Math.min(...L.rooms.map((r) => r.y));
|
|
50
|
+
const x1 = Math.max(...L.rooms.map((r) => r.x + r.w)), y1 = Math.max(...L.rooms.map((r) => r.y + r.h));
|
|
51
|
+
const mv = (p) => ({ ...p, x: p.x - x0, y: p.y - y0 });
|
|
52
|
+
return { ...L, rooms: L.rooms.map((r) => ({ ...mv(r), desks: r.desks.map((d) => ({ ...mv(d), seat: mv(d.seat) })) })), waypoints: (L.waypoints || []).map(mv),
|
|
53
|
+
width: x1 - x0, height: y1 - y0 };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The tiles of `scenes` ([{ key, w, h }], in order) on shelves at most `rowW` wide (a tile wider than that has a row of
|
|
58
|
+
* its own): each { key, x, y, w, h, s (its scale), label: { x, y, w, h } (the band above it) }; and the overview's size.
|
|
59
|
+
* Each row is centred across the widest; its tiles stand at its top, under their labels.
|
|
60
|
+
*/
|
|
61
|
+
export function arrangeTiles(scenes, rowW) {
|
|
62
|
+
const { LABEL, GAP, M } = OV;
|
|
63
|
+
const rows = [];
|
|
64
|
+
let row = null;
|
|
65
|
+
for (const sc of scenes) {
|
|
66
|
+
const s = tileScale(sc.w, sc.h), it = { key: sc.key, s, w: Math.max(1, sc.w) * s, h: Math.max(1, sc.h) * s };
|
|
67
|
+
if (!row || row.w + GAP + it.w > rowW + 1e-6) { row = { items: [], w: -GAP, h: 0 }; rows.push(row); }
|
|
68
|
+
row.items.push(it); row.w += GAP + it.w; row.h = Math.max(row.h, it.h);
|
|
69
|
+
}
|
|
70
|
+
const inner = rows.length ? Math.max(...rows.map((r) => r.w)) : 0, tiles = [];
|
|
71
|
+
let y = M;
|
|
72
|
+
for (const r of rows) {
|
|
73
|
+
let x = M + (inner - r.w) / 2;
|
|
74
|
+
for (const it of r.items) {
|
|
75
|
+
tiles.push({ key: it.key, x, y: y + LABEL, w: it.w, h: it.h, s: it.s, label: { x, y, w: it.w, h: LABEL } });
|
|
76
|
+
x += it.w + GAP;
|
|
77
|
+
}
|
|
78
|
+
y += LABEL + r.h + GAP;
|
|
79
|
+
}
|
|
80
|
+
return { rows: rows.length, rowW, tiles, width: 2 * M + inner, height: rows.length ? y - GAP + M : 2 * M };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The tiles packed for a `size` panel: of the row widths a run of consecutive tiles makes, the one whose overview shows
|
|
85
|
+
* largest there (min of the two fits); a tie keeps the narrower.
|
|
86
|
+
*/
|
|
87
|
+
export function tileLayout(scenes, size) {
|
|
88
|
+
const ws = scenes.map((sc) => Math.max(1, sc.w) * tileScale(sc.w, sc.h)), cands = new Set();
|
|
89
|
+
for (let i = 0; i < ws.length; i++) { let run = -OV.GAP; for (let j = i; j < ws.length; j++) { run += OV.GAP + ws[j]; cands.add(run); } }
|
|
90
|
+
let best = arrangeTiles(scenes, 0), bestK = -1;
|
|
91
|
+
for (const rowW of [...cands].sort((a, b) => a - b)) {
|
|
92
|
+
const a = arrangeTiles(scenes, rowW), k = Math.min(size.w / a.width, size.h / a.height);
|
|
93
|
+
if (k > bestK + 1e-9) { best = a; bestK = k; }
|
|
94
|
+
}
|
|
95
|
+
return best;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The tile whose scene holds the overview point (x, y), or null. */
|
|
99
|
+
export const tileAt = (lay, x, y) => lay.tiles.find((t) => x >= t.x && x < t.x + t.w && y >= t.y && y < t.y + t.h) || null;
|
|
100
|
+
/** The tile whose label band holds the overview point, or null. */
|
|
101
|
+
export const labelAt = (lay, x, y) => lay.tiles.find((t) => x >= t.label.x && x < t.label.x + t.label.w && y >= t.label.y && y < t.label.y + t.label.h) || null;
|
|
102
|
+
/** Overview → the tile's own scene units, and back; a box in the scene → the overview. */
|
|
103
|
+
export const inTile = (t, x, y) => ({ x: (x - t.x) / t.s, y: (y - t.y) / t.s });
|
|
104
|
+
export const fromTile = (t, p) => ({ x: t.x + p.x * t.s, y: t.y + p.y * t.s });
|
|
105
|
+
export const tileBox = (t, b) => ({ x: t.x + b.x * t.s, y: t.y + b.y * t.s, w: b.w * t.s, h: b.h * t.s });
|
|
106
|
+
/** The tiles a view ({ k, x, y }) shows any of in a `size` viewport: the rest are not drawn. */
|
|
107
|
+
export function visibleTiles(lay, v, size) {
|
|
108
|
+
return lay.tiles.filter((t) => {
|
|
109
|
+
const x = v.x + t.x * v.k, y = v.y + t.y * v.k;
|
|
110
|
+
return x < size.w && x + t.w * v.k > 0 && y < size.h && y + t.h * v.k > 0;
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* A straight flight between two people in different tiles: [the sender's seat, the receiver's] in overview units (the
|
|
116
|
+
* first tile each is in), or null — when either is drawn nowhere, or some tile holds both (that tile carries it along
|
|
117
|
+
* its corridors). `parts`: [{ tile, L }] in the tiles' order.
|
|
118
|
+
*/
|
|
119
|
+
export function crossPath(parts, from, to) {
|
|
120
|
+
let a = null, z = null;
|
|
121
|
+
for (const p of parts) {
|
|
122
|
+
const ds = desksOf(p.L), da = ds.find((d) => d.node.name === from), dz = ds.find((d) => d.node.name === to);
|
|
123
|
+
if (da && dz) return null;
|
|
124
|
+
if (da && !a) a = fromTile(p.tile, da.seat);
|
|
125
|
+
if (dz && !z) z = fromTile(p.tile, dz.seat);
|
|
126
|
+
}
|
|
127
|
+
return a && z ? [a, z] : null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const MAX_SEEN = 2000;
|
|
131
|
+
/**
|
|
132
|
+
* The items in the air between tiles — fleet-office-model.js createFlights's lifecycle (each new item once; none for a
|
|
133
|
+
* stale delegation or with reduced motion), on crossPath's straight line: `frame(now, parts)` → { id, hue, kind, x, y,
|
|
134
|
+
* t } in overview units; a trip with no crossing (both in one tile, or either gone) is dropped.
|
|
135
|
+
*/
|
|
136
|
+
export function createCrossFlights({ max = FLIGHT.MAX } = {}) {
|
|
137
|
+
const seen = new Set();
|
|
138
|
+
let trips = [];
|
|
139
|
+
return {
|
|
140
|
+
sync(items, now, still = false) {
|
|
141
|
+
let k = 0, started = 0;
|
|
142
|
+
for (const it of items || []) {
|
|
143
|
+
if (seen.has(it.id)) continue;
|
|
144
|
+
seen.add(it.id);
|
|
145
|
+
if (seen.size > MAX_SEEN) seen.delete(seen.values().next().value);
|
|
146
|
+
if (still || it.phase === "stale") continue;
|
|
147
|
+
trips.push({ id: it.id, from: it.from, to: it.to, kind: it.kind, hue: hue(it.kind), start: now + k++ * FLIGHT.STAGGER_MS, parts: null, pts: null, ms: 0 });
|
|
148
|
+
started++;
|
|
149
|
+
}
|
|
150
|
+
if (trips.length > max) trips = trips.slice(trips.length - max);
|
|
151
|
+
return started;
|
|
152
|
+
},
|
|
153
|
+
frame(now, parts) {
|
|
154
|
+
const out = [];
|
|
155
|
+
trips = trips.filter((f) => {
|
|
156
|
+
if (f.parts !== parts) { f.parts = parts; f.pts = crossPath(parts, f.from, f.to); f.ms = f.pts ? Math.min(FLIGHT.MAX_MS, Math.max(FLIGHT.MIN_MS, pathLength(f.pts) / FLIGHT.SPEED)) : 0; }
|
|
157
|
+
if (!f.pts) return false;
|
|
158
|
+
const t = (now - f.start) / f.ms;
|
|
159
|
+
if (t >= 1) return false;
|
|
160
|
+
if (t >= 0) { const p = along(f.pts, t); out.push({ id: f.id, hue: f.hue, kind: f.kind, x: p.x, y: p.y, t }); }
|
|
161
|
+
return true;
|
|
162
|
+
});
|
|
163
|
+
return out;
|
|
164
|
+
},
|
|
165
|
+
size: () => trips.length,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
@@ -222,14 +222,16 @@ function drawDeskFront(ctx, d, look, env) {
|
|
|
222
222
|
ctx.fillStyle = pal.text2; ctx.font = "600 8px system-ui, sans-serif"; ctx.textAlign = "center"; ctx.textBaseline = "middle";
|
|
223
223
|
ctx.fillText(fitText(ctx, env.labels.closed, 32), d.x + 48, d.y + 63);
|
|
224
224
|
}
|
|
225
|
-
// The name tag.
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
225
|
+
// The name tag (#2554: none when `notag` — a tile too small to read it; the page draws it again on hover).
|
|
226
|
+
if (!look.notag) {
|
|
227
|
+
ctx.fillStyle = pal.surface;
|
|
228
|
+
ctx.fillRect(d.x + 6, d.y + 78, d.w - 12, 16);
|
|
229
|
+
ctx.strokeStyle = look.match ? pal.focus : pal.border;
|
|
230
|
+
ctx.lineWidth = look.match ? 2 : 1;
|
|
231
|
+
ctx.strokeRect(d.x + 6.5, d.y + 78.5, d.w - 13, 15);
|
|
232
|
+
ctx.fillStyle = pal.text; ctx.font = "600 11px system-ui, sans-serif"; ctx.textAlign = "center"; ctx.textBaseline = "middle";
|
|
233
|
+
ctx.fillText(fitText(ctx, look.label, d.w - 18), d.x + d.w / 2, d.y + 86.5);
|
|
234
|
+
}
|
|
233
235
|
ctx.restore();
|
|
234
236
|
if (look.match) { ctx.save(); ctx.strokeStyle = pal.focus; ctx.lineWidth = 2; ctx.strokeRect(d.x + 2, d.y + 2, d.w - 4, d.h - 6); ctx.restore(); }
|
|
235
237
|
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// what comes back is checked again here (fleet-office-model.js officesOk), never trusted;
|
|
4
4
|
// - this browser's own choices (localStorage; blocked storage: this page only, as the canvas's Canvas/List): which
|
|
5
5
|
// office is shown, the auto office's member filter, and its style (skin); #2540: the view (zoom and centre) and the
|
|
6
|
-
// fit mode (Fit or Fill) per office.
|
|
6
|
+
// fit mode (Fit or Fill) per office; #2554: whether All offices shows the lobby tile.
|
|
7
7
|
// - the skin packs an admin uploaded (#2513: GET /ui/office-skins, POST to upload one, DELETE to remove one;
|
|
8
8
|
// src/web-office-skins.ts). The office editor and the layout editor call saveOffices() with the whole list and the rev
|
|
9
9
|
// they read (a change in between answers 409).
|
|
@@ -11,6 +11,8 @@ import { AUTO_ID, filterOk, officesOk } from "./fleet-office-model.js";
|
|
|
11
11
|
import { fitModeOk, savedOk } from "./fleet-office-view.js";
|
|
12
12
|
|
|
13
13
|
const CHOICE_KEY = "agend_org_office", FILTER_KEY = "agend_org_office_filter", SKIN_KEY = "agend_org_office_skin", ASLEEP_KEY = "agend_org_office_hide_asleep";
|
|
14
|
+
/** #2554: "0" when All offices leaves the lobby tile out (default: shown). */
|
|
15
|
+
const LOBBY_KEY = "agend_org_office_lobby";
|
|
14
16
|
/** #2540: { "<office>@<style>": [k, cx, cy] (and a 1 after them: a fit) } — the last VIEWS_MAX views, the most recent last. */
|
|
15
17
|
const VIEW_KEY = "agend_org_office_view", VIEWS_MAX = 32;
|
|
16
18
|
/** #2540: { "<office>@<style>": "cover" } — the offices whose fit fills the panel (the rest: "contain"), the last VIEWS_MAX. */
|
|
@@ -80,7 +82,10 @@ export function createOfficeStore({ fetch: get = globalThis.fetch, storage } = {
|
|
|
80
82
|
return r.ok ? { ok: true } : { ok: false, status: r.status, error: d.error || "skin_failed" };
|
|
81
83
|
} catch { return { ok: false, status: 0, error: "skin_failed" }; }
|
|
82
84
|
},
|
|
83
|
-
/**
|
|
85
|
+
/**
|
|
86
|
+
* The office shown here: an id (one the host no longer has falls back to the auto office where it is used), or
|
|
87
|
+
* #2554's All offices (fleet-office-overview.js ALL_ID; with fewer than two offices stored: the auto office).
|
|
88
|
+
*/
|
|
84
89
|
choice: () => { const v = read(CHOICE_KEY); return typeof v === "string" && v && v.length <= 40 ? v : AUTO_ID; },
|
|
85
90
|
saveChoice: (id) => write(CHOICE_KEY, String(id)),
|
|
86
91
|
filter: () => { const s = read(FILTER_KEY); try { return filterOk(s ? JSON.parse(s) : null); } catch { return filterOk(null); } },
|
|
@@ -91,6 +96,9 @@ export function createOfficeStore({ fetch: get = globalThis.fetch, storage } = {
|
|
|
91
96
|
/** Whether paused and stopped people are left out of the Office here (default: shown). */
|
|
92
97
|
hideAsleep: () => read(ASLEEP_KEY) === "1",
|
|
93
98
|
saveHideAsleep: (v) => write(ASLEEP_KEY, v ? "1" : "0"),
|
|
99
|
+
/** #2554: whether All offices shows the lobby tile (the instances in no stored office) here (default: shown). */
|
|
100
|
+
lobbyTile: () => read(LOBBY_KEY) !== "0",
|
|
101
|
+
saveLobbyTile: (v) => write(LOBBY_KEY, v ? "1" : "0"),
|
|
94
102
|
/** The view kept for `key` (an office and its style): { k, cx, cy } (fleet-office-view.js savedOf), or null. */
|
|
95
103
|
view: (key) => {
|
|
96
104
|
try { const m = JSON.parse(read(VIEW_KEY) || "{}"); const a = m && Object.prototype.hasOwnProperty.call(m, key) ? m[key] : null;
|
package/dist/ui/fleet-office.js
CHANGED
|
@@ -44,6 +44,14 @@
|
|
|
44
44
|
// (webkit's prefix too); where a page cannot take an element fullscreen (an iPhone) or the browser refuses, "immersive":
|
|
45
45
|
// the container covers the window (CSS). Either way only the stage shows, with its floating controls (zoom, Fit, the
|
|
46
46
|
// overview, a compact office switcher, Exit); the stage's resize handling re-fits it. Nothing about it is kept.
|
|
47
|
+
// - All offices (#2554; offered with two or more stored offices): every stored office as a tile on one canvas, each
|
|
48
|
+
// drawn as its own view would be — its style (a pack loaded once and shared), its members, seats, poses, bubbles,
|
|
49
|
+
// walking and in-office traffic — scaled into its tile (fleet-office-overview.js: the tiles, the lobby tile of whoever
|
|
50
|
+
// is in no office, hit-testing, culling, the flights between tiles). The same canvas, controls and loop; tiles off
|
|
51
|
+
// screen are not drawn. A tile's label (a button, a readable size whatever the zoom) or a double-click on it opens
|
|
52
|
+
// that office; a person opens their chat. A tile too small to read (under TAG_MIN_K) shows name tags on hover only;
|
|
53
|
+
// bubbles are drawn after every tile (never cut at a tile's edge). Fit keeps the tiles clear of the controls
|
|
54
|
+
// (FIT_PAD). Edit offices and Edit layout wait for a single office.
|
|
47
55
|
import { html, useEffect, useLayoutEffect, useMemo, useRef, useState } from "/assets/app-html.js";
|
|
48
56
|
import { appStore } from "/assets/app-store.js";
|
|
49
57
|
import { t, lang, register } from "/assets/app-i18n.js";
|
|
@@ -59,6 +67,7 @@ import { createOfficeStore } from "./fleet-office-store.js";
|
|
|
59
67
|
import { OfficeEditor, officeForSave } from "./fleet-office-editor.js";
|
|
60
68
|
import { LayoutEditor, handlesOf } from "./fleet-office-layout.js";
|
|
61
69
|
import { applyOverrides } from "./fleet-office-pack.js";
|
|
70
|
+
import { ALL_ID, LOBBY_TILE, OV, createCrossFlights, cropToRooms, fromTile, inTile, labelAt, lobbyNodes, overviewOk, tileAt, tileBox, tileLayout, visibleTiles } from "./fleet-office-overview.js";
|
|
62
71
|
import { centreOn, gestureState, gestures, miniPoint, toScreen, toWorld, wheelFactor, zoomKey, zoomTo } from "./fleet-canvas-view.js";
|
|
63
72
|
import { OK_MAX, OK_MIN, clampPan, desksK, edgeMatte, edgeStrips, fitScene, layerScale, miniBox, miniFrame, openingView, pinchOffice, savedOf, stepK, viewFromSaved,
|
|
64
73
|
zoomLabel, zoomOffice } from "./fleet-office-view.js";
|
|
@@ -77,6 +86,8 @@ register("orgo", {
|
|
|
77
86
|
hideAsleep: "Hide paused and stopped", layoutIgnored: "Its saved layout does not fit this style: drawn as the style's own.",
|
|
78
87
|
fit: "Fit the office", overview: "Overview: click to move there", fullscreen: "Fullscreen", exitFullscreen: "Exit fullscreen",
|
|
79
88
|
fill: "Fill", fillTitle: "Fill the panel (the scene's overflow is cropped; drag to see it)", zoomLevel: "Zoom {0}",
|
|
89
|
+
allOffices: "All offices", labelAll: "All offices: each office is a tile, drawn in its own style. Drag to pan; the wheel, a pinch, + and − zoom, 0 fits, F is fullscreen. Double-click a tile to open that office.",
|
|
90
|
+
tileLabel: "{0} ({1})", openOffice: "Open the office {0}", lobbyTile: "Lobby tile (in no office)", lobbyEveryone: "Everyone is in an office", overviewNoEdit: "Pick one office to edit offices or a layout",
|
|
80
91
|
},
|
|
81
92
|
"zh-TW": {
|
|
82
93
|
label: "辦公室:每個 team 是一間房間,每個 instance 一張桌子;General 和未加入 team 的 instance 在大廳。拖曳平移;滾輪、雙指、+ 和 − 縮放,0 顯示整個場景,F 切換全螢幕。",
|
|
@@ -91,6 +102,8 @@ register("orgo", {
|
|
|
91
102
|
hideAsleep: "隱藏暫停與停止的成員", layoutIgnored: "它儲存的配置不適用這個風格:以風格本身的配置顯示。",
|
|
92
103
|
fit: "顯示整個辦公室", overview: "總覽:點一下移到那裡", fullscreen: "全螢幕", exitFullscreen: "結束全螢幕",
|
|
93
104
|
fill: "填滿", fillTitle: "填滿整個面板(超出的部分會被裁切,拖曳即可看到)", zoomLevel: "縮放 {0}",
|
|
105
|
+
allOffices: "所有辦公室", labelAll: "所有辦公室:每間辦公室是一格,以它自己的風格顯示。拖曳平移;滾輪、雙指、+ 和 − 縮放,0 顯示全部,F 切換全螢幕。雙擊一格即可開啟那間辦公室。",
|
|
106
|
+
tileLabel: "{0}({1})", openOffice: "開啟辦公室「{0}」", lobbyTile: "大廳格(不在任何辦公室)", lobbyEveryone: "每個成員都已在某間辦公室", overviewNoEdit: "請先選一間辦公室,再編輯辦公室或配置",
|
|
94
107
|
},
|
|
95
108
|
});
|
|
96
109
|
const to = (k, ...v) => t(`orgo.${k}`, ...v);
|
|
@@ -105,6 +118,10 @@ const reducedMotion = () => typeof matchMedia === "function" && matchMedia("(pre
|
|
|
105
118
|
const deviceRatio = () => Math.max(0.5, Math.min(4, Number(globalThis.devicePixelRatio) || 1));
|
|
106
119
|
/** The stage's size before it is measured (a test's page has none). */
|
|
107
120
|
const SIZE0 = { w: 960, h: 600 };
|
|
121
|
+
/** #2554: All offices' Fit keeps this many CSS px clear round the tiles (the zoom controls' column and a margin). */
|
|
122
|
+
const FIT_PAD = 60;
|
|
123
|
+
/** #2554: in All offices a tile's name tags show (always) from this many CSS px a unit up; below it on hover only. */
|
|
124
|
+
export const TAG_MIN_K = 0.75;
|
|
108
125
|
const PAN_KEYS = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] };
|
|
109
126
|
/** Typing: no single-key shortcut (the `f` of fullscreen) there. */
|
|
110
127
|
const typing = (el) => !!el && (el.tagName === "INPUT" || el.tagName === "SELECT" || el.tagName === "TEXTAREA" || el.isContentEditable === true);
|
|
@@ -155,6 +172,149 @@ function teamOptions(view) {
|
|
|
155
172
|
...view.teams.map((tm, i) => ({ value: `t${i}`, team: tm.name, label: tm.name }))];
|
|
156
173
|
}
|
|
157
174
|
|
|
175
|
+
// ── A scene: one office's layout, looks and skin, drawn by itself (the single view) or as a tile (All offices, #2554) ──
|
|
176
|
+
|
|
177
|
+
/** What each desk shows: the pose, the light, the coat; dimmed when the Find box matches someone else. */
|
|
178
|
+
function looksOf(L, work, q, mode, zones) {
|
|
179
|
+
return new Map(L.rooms.flatMap((r) => r.desks).map((d) => {
|
|
180
|
+
const n = d.node;
|
|
181
|
+
if (!n) return [d.key, { name: "", label: "", state: "missing", pose: "away", light: null, lamp: false, sign: null, coat: "", work: 0, dim: !!q, match: false, free: zones }];
|
|
182
|
+
const match = !!q && hits(n, q);
|
|
183
|
+
return [d.key, { name: n.name, label: nodeText(n).primary, state: n.state, pose: officePose(n.state), light: lightOf(n.state), lamp: lampOf(n.state),
|
|
184
|
+
sign: zones ? null : signOf(n.state), coat: coatOf(n.i, n.i ? identityOf(n.i) : null, mode), work: work.get(n.name) || 0, dim: !!q && !match, match, free: zones }];
|
|
185
|
+
}));
|
|
186
|
+
}
|
|
187
|
+
/** A stored office's own layout on a zones style (its overrides); `ignored`: they do not fit this style. */
|
|
188
|
+
function withOverrides(baseSkin, zones, office) {
|
|
189
|
+
const ov = zones && office ? office.overrides : null;
|
|
190
|
+
if (!ov || ov.pack !== baseSkin.id) return { skin: baseSkin, ignored: false };
|
|
191
|
+
const a = applyOverrides(baseSkin.pack, ov);
|
|
192
|
+
return a.ok ? { skin: { ...baseSkin, pack: a.pack }, ignored: false } : { skin: baseSkin, ignored: true };
|
|
193
|
+
}
|
|
194
|
+
/** A stored office's scene (or the lobby tile's): seats in its style's areas, its own seats, or one room wrapping at `deskW`. */
|
|
195
|
+
function officeScene(office, nodes, skin, zones, deskW, prevSeats) {
|
|
196
|
+
if (zones) return zonesLayout(skin.pack, nodes, prevSeats);
|
|
197
|
+
const lay = office.layout || layoutOk(skin.layout);
|
|
198
|
+
return lay ? customLayout({ ...office, layout: lay }, nodes) : autoLayout([{ key: office.id, name: office.name, lobby: !!office.lobby, nodes }], { width: deskW });
|
|
199
|
+
}
|
|
200
|
+
/** What drawPart needs of a scene; its layers are painted on first draw (and again at another resolution). */
|
|
201
|
+
function partOf(L, looks, skin, zones, walkers, flights) {
|
|
202
|
+
const all = L.rooms.flatMap((r) => r.desks);
|
|
203
|
+
const shown = new Set([...looks.values()].map((l) => l.state));
|
|
204
|
+
return { L, looks, skin, zones, walkers, flights, all, desks: all.filter((d) => d.node), layers: null,
|
|
205
|
+
active: zones ? activeFurniture(skin.pack, shown) : new Set(), lines: bubbleDefaults(), lang: lang() };
|
|
206
|
+
}
|
|
207
|
+
/** The back and front layers: pictures of the scene at `ls` layer px per logical unit (`tagsOff`: no name tags but the Find box's matches). */
|
|
208
|
+
function paintLayers(p, env, ls, tagsOff = false) {
|
|
209
|
+
const { L, skin, zones, looks, all } = p;
|
|
210
|
+
const lw = Math.max(1, Math.ceil(L.width * ls)), lh = Math.max(1, Math.ceil(L.height * ls));
|
|
211
|
+
const layer = (fn) => {
|
|
212
|
+
const c = document.createElement("canvas");
|
|
213
|
+
c.width = lw; c.height = lh;
|
|
214
|
+
const g = c.getContext && c.getContext("2d");
|
|
215
|
+
if (!g) return null;
|
|
216
|
+
g.setTransform(ls, 0, 0, ls, 0, 0);
|
|
217
|
+
g.imageSmoothingEnabled = false;
|
|
218
|
+
fn(g);
|
|
219
|
+
return c;
|
|
220
|
+
};
|
|
221
|
+
const back = layer((g) => {
|
|
222
|
+
g.fillStyle = env.pal.bg; g.fillRect(0, 0, L.width, L.height);
|
|
223
|
+
if (zones) {
|
|
224
|
+
skin.drawBackground(g, L, env);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
skin.drawFloor(g, L, env);
|
|
228
|
+
for (const r of L.rooms) skin.drawRoom(g, r, env);
|
|
229
|
+
for (const d of all) skin.drawDeskBack(g, d, looks.get(d.key), env);
|
|
230
|
+
});
|
|
231
|
+
const front = zones ? null : layer((g) => {
|
|
232
|
+
for (const d of all) { const lk = looks.get(d.key); skin.drawDeskFront(g, d, tagsOff && lk && !lk.match ? { ...lk, notag: true } : lk, env); }
|
|
233
|
+
});
|
|
234
|
+
return { ls, tagsOff, back, front };
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* One frame of a scene at `ms` (0: still), a logical unit `a` bitmap px, its origin at (ox, oy); `hot`: the desk key
|
|
238
|
+
* hovered (its name tag shows even in a crowd); `tagsOff` (#2554: a tile too small to read them): the name tags show
|
|
239
|
+
* only for the one hovered and the Find box's matches. Whether anything in it is moving (in the air, or someone walking).
|
|
240
|
+
*/
|
|
241
|
+
function drawPart(ctx, p, a, ox, oy, ms, still, hot, env, tagsOff = false, defer = null) {
|
|
242
|
+
const sk = p.skin;
|
|
243
|
+
const ls = layerScale(a, p.L.width, p.L.height);
|
|
244
|
+
if (!p.layers || p.layers.ls !== ls || p.layers.tagsOff !== tagsOff) p.layers = paintLayers(p, env, ls, tagsOff);
|
|
245
|
+
const T = () => ctx.setTransform(a, 0, 0, a, ox, oy);
|
|
246
|
+
const blit = (c) => { if (!c) return; ctx.setTransform(a / ls, 0, 0, a / ls, ox, oy); ctx.drawImage(c, 0, 0); T(); };
|
|
247
|
+
blit(p.layers.back);
|
|
248
|
+
T();
|
|
249
|
+
const speakers = [], tags = [];
|
|
250
|
+
const up = still ? [] : p.flights.frame(ms, p.L);
|
|
251
|
+
let walking = false;
|
|
252
|
+
if (p.zones) {
|
|
253
|
+
// Furniture under the people; the people (nearest last) with the furniture whose depth is "y" among them, by base
|
|
254
|
+
// y (a tie: the item first, the person in front); furniture over them; things in the air; the pack's foreground
|
|
255
|
+
// (door frames, pillars, table fronts); then what must stay readable: crowd counts and name tags (bubbles below).
|
|
256
|
+
const t = ms > 0 ? ms : now();
|
|
257
|
+
const people = p.desks.map((d) => {
|
|
258
|
+
const at = p.walkers.at(d.node.name, t) || { ...d.seat, walking: false };
|
|
259
|
+
if (at.walking) walking = true;
|
|
260
|
+
return { d, at, look: p.looks.get(d.key) };
|
|
261
|
+
}).sort((x, y) => x.at.y - y.at.y);
|
|
262
|
+
const furniture = sk.pack.furniture;
|
|
263
|
+
const band = [...furniture.filter((f) => f.depth === "y").map((f) => ({ y: f.y, k: 0, f })), ...people.map((q) => ({ y: q.at.y, k: 1, p: q }))]
|
|
264
|
+
.sort((x, y) => x.y - y.y || x.k - y.k);
|
|
265
|
+
for (const f of furniture) if (f.depth !== "y" && f.depth < CHAR_DEPTH) sk.drawFurniture(ctx, f, p.active.has(f.id), ms, env);
|
|
266
|
+
for (const { f, p: q } of band) {
|
|
267
|
+
if (f) { sk.drawFurniture(ctx, f, p.active.has(f.id), ms, env); continue; }
|
|
268
|
+
const box = { ...q.d, x: q.at.x - 44, y: q.at.y - 53, w: 88, h: 53, seat: { x: q.at.x, y: q.at.y } };
|
|
269
|
+
sk.drawCharacter(ctx, box, q.at.walking ? { ...q.look, pose: "walk", heading: q.at.heading } : q.look, ms, env);
|
|
270
|
+
if (!q.at.walking) speakers.push({ look: q.look, x: q.at.x + 10, y: q.at.y - 50, area: q.d.area });
|
|
271
|
+
}
|
|
272
|
+
for (const f of furniture) if (f.depth !== "y" && f.depth >= CHAR_DEPTH) sk.drawFurniture(ctx, f, p.active.has(f.id), ms, env);
|
|
273
|
+
for (const it of up) sk.drawProp(ctx, it, ms, env);
|
|
274
|
+
if (sk.drawForeground) sk.drawForeground(ctx, p.L, env);
|
|
275
|
+
// Who found no spot in an area: a count in its corner (never over its name).
|
|
276
|
+
for (const [ar, n] of p.L.crowd || []) if (p.L.areas[ar] && sk.drawCrowd) sk.drawCrowd(ctx, p.L.areas[ar], n, env);
|
|
277
|
+
// In an area over capacity the name tags show on hover or focus only (#2513: a crowd's tags covered each other).
|
|
278
|
+
const over = p.L.over || new Set();
|
|
279
|
+
for (const q of people) {
|
|
280
|
+
if ((tagsOff || over.has(q.d.area)) && q.d.key !== hot && !q.look.match) continue;
|
|
281
|
+
sk.drawTag(ctx, q.at.x, q.at.y, q.look, env);
|
|
282
|
+
tags.push(tagBox(ctx, q.at.x, q.at.y, q.look.label));
|
|
283
|
+
}
|
|
284
|
+
} else {
|
|
285
|
+
for (const d of p.desks) { const look = p.looks.get(d.key); sk.drawCharacter(ctx, d, look, ms, env); speakers.push({ look, x: d.x + 58, y: d.y + 12 }); }
|
|
286
|
+
blit(p.layers.front);
|
|
287
|
+
// Tags left out of the layer: the hovered desk's front again, with its tag.
|
|
288
|
+
const hd = tagsOff && hot ? p.desks.find((d) => d.key === hot) : null;
|
|
289
|
+
if (hd) sk.drawDeskFront(ctx, hd, p.looks.get(hd.key), env);
|
|
290
|
+
for (const it of up) sk.drawProp(ctx, it, ms, env);
|
|
291
|
+
}
|
|
292
|
+
// Speech bubbles: a short line for the state, now and then (typed out; whole at once when still), placed so no two
|
|
293
|
+
// overlap (placeBubbles; #2431).
|
|
294
|
+
const bubbles = [];
|
|
295
|
+
for (const sp of speakers) {
|
|
296
|
+
if (sp.look.dim || sp.look.pose === "away") continue;
|
|
297
|
+
const b = bubbleAt(sp.look.name, sp.look.state, ms, linesFor(sp.look.state, p.lang, sk.pack, p.lines));
|
|
298
|
+
if (b) bubbles.push({ key: sp.look.name, area: sp.area, state: sp.look.state, calling: CALLING.has(sp.look.state), rank: bubbleRank(sp.look.state), shown: b.shown,
|
|
299
|
+
box: bubbleBox(ctx, sp.x, sp.y, b.text), tail: { x: sp.x, y: sp.y }, sp, b });
|
|
300
|
+
}
|
|
301
|
+
// A crowded area speaks less (thinBubbles: calls, then work, a few at most); no bubble covers a name tag. `defer`
|
|
302
|
+
// (All offices): drawn after every tile, unclipped, so a bubble at a tile's edge is whole.
|
|
303
|
+
const placed = placeBubbles(thinBubbles(bubbles, p.zones ? p.L.over : null), 22, tags);
|
|
304
|
+
const say = () => { T(); for (const { sp, b, dy } of placed) sk.drawBubble(ctx, sp.x, sp.y, [...b.text].slice(0, b.shown).join(""), env, -dy); };
|
|
305
|
+
if (defer) defer.push(say); else say();
|
|
306
|
+
return walking || up.length > 0 || p.flights.size() > 0;
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* The person at (x, y) in a scene's units, as it draws them: on zones where they walk, in the box the skin draws them
|
|
310
|
+
* in (an uploaded character's strip, anchor and size: #2513 review), nearest first; on desks, the desk.
|
|
311
|
+
*/
|
|
312
|
+
function hitPart(p, x, y, t) {
|
|
313
|
+
if (!p.zones) return deskAt(p.L, x, y);
|
|
314
|
+
return personAt(p.all, x, y, (d) => p.walkers.at(d.node.name, t),
|
|
315
|
+
(d, at) => (p.skin.charBox && p.looks.get(d.key) ? p.skin.charBox(p.looks.get(d.key), at) : null));
|
|
316
|
+
}
|
|
317
|
+
|
|
158
318
|
function People({ nodes, pinned, onChange }) {
|
|
159
319
|
const [open, setOpen] = useState(false);
|
|
160
320
|
const [find, setFind] = useState("");
|
|
@@ -196,6 +356,7 @@ export function Office({ view, traffic, q = "", lease, onOpen }) {
|
|
|
196
356
|
const [filter, setFilter] = useState(() => store.filter());
|
|
197
357
|
const [style, setStyle] = useState(() => store.skin());
|
|
198
358
|
const [hideAsleep, setHideAsleep] = useState(() => store.hideAsleep());
|
|
359
|
+
const [lobbyOn, setLobbyOn] = useState(() => store.lobbyTile()); // #2554: All offices' lobby tile
|
|
199
360
|
const [packs, setPacks] = useState([]); // the skin packs the host keeps: [{ id, name, sha }]
|
|
200
361
|
const [packSkins, setPackSkins] = useState(() => new Map()); // id@sha → a loaded skin, or null (not a pack)
|
|
201
362
|
// Edit layout's session while it is open: { id, officeId, skin, pack (the style's identity), base (the pack as kept),
|
|
@@ -216,6 +377,10 @@ export function Office({ view, traffic, q = "", lease, onOpen }) {
|
|
|
216
377
|
const flights = useMemo(() => createFlights(), []);
|
|
217
378
|
const walkers = useMemo(() => createWalkers(), []);
|
|
218
379
|
const seatsRef = useRef(new Map()); // the zones layout's last seats: kept where they can be
|
|
380
|
+
// #2554: All offices' tiles keep their own: seats (by tile and style), walkers and flights (by tile); and the items
|
|
381
|
+
// flying between tiles.
|
|
382
|
+
const tileSeats = useRef(new Map()), tileWalkers = useRef(new Map()), tileFlights = useRef(new Map());
|
|
383
|
+
const cross = useMemo(() => createCrossFlights(), []);
|
|
219
384
|
const still = useMemo(reducedMotion, []);
|
|
220
385
|
|
|
221
386
|
// The host's offices: on opening, and again whenever the stream says they changed. Answers can arrive out of order
|
|
@@ -291,18 +456,26 @@ export function Office({ view, traffic, q = "", lease, onOpen }) {
|
|
|
291
456
|
// snaps to whole numbers of these (fleet-office-view.js), so a pixel-level view maps a logical unit onto whole pixels.
|
|
292
457
|
const ratio = backingScale(size.w, size.h, dpr);
|
|
293
458
|
|
|
294
|
-
|
|
459
|
+
// #2554: All offices — chosen, and at least two offices stored (else the whole fleet, as an office gone is).
|
|
460
|
+
const overview = choice === ALL_ID && overviewOk(stored);
|
|
461
|
+
const custom = choice !== AUTO_ID && !overview ? stored.find((o) => o.id === choice) || null : null;
|
|
295
462
|
// The style: a built-in one, or an uploaded pack once it is loaded (until then, and when it is not one, Desks).
|
|
296
463
|
const wanted = custom ? custom.skin : style;
|
|
297
|
-
const
|
|
464
|
+
const packOf = (id) => (STYLES.includes(id) ? null : packs.find((pk) => pk.id === id) || null);
|
|
465
|
+
const packInfo = packOf(wanted);
|
|
298
466
|
const packKey = packInfo ? `${packInfo.id}@${packInfo.sha}` : null;
|
|
467
|
+
// The packs to load: this office's style; in All offices every tile's (each loaded once, shared by the tiles using it).
|
|
468
|
+
const needed = [...new Map((overview ? [...stored.map((o) => o.skin), ...(lobbyOn ? [style] : [])] : [wanted]).map(packOf).filter(Boolean)
|
|
469
|
+
.map((pk) => [`${pk.id}@${pk.sha}`, pk])).entries()];
|
|
470
|
+
const neededKey = needed.map(([k]) => k).join(",");
|
|
299
471
|
useEffect(() => {
|
|
300
|
-
|
|
472
|
+
const want = needed.filter(([k]) => !packSkins.has(k));
|
|
473
|
+
if (!want.length) return undefined;
|
|
301
474
|
let live = true;
|
|
302
475
|
const get = lease ? (path, o) => lease.fetch(path, o) : undefined;
|
|
303
|
-
loadPackSkin(
|
|
476
|
+
for (const [k, pk] of want) loadPackSkin(pk.id, pk.sha, get ? { fetch: get } : {}).then((sk) => { if (live) setPackSkins((m) => new Map(m).set(k, sk)); });
|
|
304
477
|
return () => { live = false; };
|
|
305
|
-
}, [
|
|
478
|
+
}, [neededKey]);
|
|
306
479
|
const loading = !!packInfo && !packSkins.has(packKey);
|
|
307
480
|
const baseSkin = packInfo ? packSkins.get(packKey) || skinFor("pixel") : skinFor(STYLES.includes(wanted) ? wanted : custom ? wanted : "pixel");
|
|
308
481
|
const zones = !!(baseSkin.pack && baseSkin.pack.stateZones);
|
|
@@ -312,42 +485,66 @@ export function Office({ view, traffic, q = "", lease, onOpen }) {
|
|
|
312
485
|
const layoutStale = !!layout && (!custom || custom.id !== layout.officeId || custom.skin !== layout.skin || styleKey !== layout.pack || !zones || loading);
|
|
313
486
|
// A stored office's own layout on a zones style (its overrides; while Edit layout is open, the draft).
|
|
314
487
|
const placed = useMemo(() => {
|
|
315
|
-
if (!
|
|
316
|
-
|
|
317
|
-
const ov = custom && custom.overrides;
|
|
318
|
-
if (!ov || ov.pack !== baseSkin.id) return { skin: baseSkin, ignored: false };
|
|
319
|
-
const a = applyOverrides(baseSkin.pack, ov);
|
|
320
|
-
return a.ok ? { skin: { ...baseSkin, pack: a.pack }, ignored: false } : { skin: baseSkin, ignored: true };
|
|
488
|
+
if (zones && layout && !layoutStale) return { skin: { ...baseSkin, pack: layout.draft }, ignored: false };
|
|
489
|
+
return withOverrides(baseSkin, zones, custom);
|
|
321
490
|
}, [baseSkin, zones, custom, layout, layoutStale]);
|
|
322
491
|
const skin = placed.skin;
|
|
323
|
-
const
|
|
492
|
+
const keep = (n) => !(hideAsleep && ASLEEP.has(n.state));
|
|
493
|
+
const single = useMemo(() => {
|
|
494
|
+
if (overview) return { rooms: [], width: 1, height: 1 }; // All offices: the tiles below
|
|
324
495
|
const all = custom ? membersOf(custom, view) : [...new Map(officeRooms(view, filter).flatMap((r) => r.nodes).map((n) => [n.name, n])).values()];
|
|
325
|
-
const keep = (n) => !(hideAsleep && ASLEEP.has(n.state));
|
|
326
496
|
const nodes = all.filter(keep);
|
|
327
497
|
if (zones) { const z = zonesLayout(skin.pack, nodes, seatsRef.current); seatsRef.current = z.seats; return z; }
|
|
328
498
|
// The desks wrap at the stage's width at the scale they open on (desksK: a pixel level near one CSS px a unit).
|
|
329
499
|
const deskW = Math.max(240, Math.floor(width / desksK(ratio)));
|
|
330
500
|
if (!custom) return autoLayout(officeRooms(view, filter).map((r) => (hideAsleep ? { ...r, nodes: r.nodes.filter(keep) } : r)), { width: deskW });
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
501
|
+
return officeScene(custom, nodes, skin, false, deskW);
|
|
502
|
+
}, [overview, view, filter, custom, width, ratio, skin, hideAsleep]);
|
|
503
|
+
// ── #2554: All offices' tiles — each stored office (then the lobby: whoever is in no office) laid out as its own view
|
|
504
|
+
// lays it out, in its own style (desks wrap at a cell's width), with its own seats, walkers and flights ──
|
|
505
|
+
const lobbyAll = useMemo(() => (overview ? lobbyNodes(stored, view) : []), [overview, stored, view]);
|
|
506
|
+
const lobbyList = lobbyOn ? lobbyAll : [];
|
|
507
|
+
const tiles = useMemo(() => {
|
|
508
|
+
if (!overview) return [];
|
|
509
|
+
const mine = (m, k, make) => { if (!m.current.has(k)) m.current.set(k, make()); return m.current.get(k); };
|
|
510
|
+
const specs = stored.map((o) => ({ key: o.id, office: o, name: o.name, lobby: false, wanted: o.skin, nodes: membersOf(o, view) }));
|
|
511
|
+
if (lobbyList.length) specs.push({ key: LOBBY_TILE, office: { id: LOBBY_TILE, name: null, lobby: true, layout: null }, name: to("lobbyRoom"), lobby: true, wanted: style, nodes: lobbyList });
|
|
512
|
+
return specs.map((sp) => {
|
|
513
|
+
const info = packOf(sp.wanted), pk = info ? `${info.id}@${info.sha}` : null;
|
|
514
|
+
const base = info ? packSkins.get(pk) || skinFor("pixel") : skinFor(sp.wanted);
|
|
515
|
+
const z = !!(base.pack && base.pack.stateZones);
|
|
516
|
+
const sk = withOverrides(base, z, sp.lobby ? null : sp.office).skin;
|
|
517
|
+
const seatKey = `${sp.key}@${pk || base.id}`;
|
|
518
|
+
// A desks office is as large as its rooms (cropped: no margins, no corridors outside them).
|
|
519
|
+
const TL0 = officeScene(sp.office, sp.nodes.filter(keep), sk, z, OV.CELL_W, tileSeats.current.get(seatKey)), TL = z ? TL0 : cropToRooms(TL0);
|
|
520
|
+
if (z) tileSeats.current.set(seatKey, TL0.seats);
|
|
521
|
+
return { key: sp.key, name: sp.name, lobby: sp.lobby, count: sp.nodes.length, skin: sk, zones: z, L: TL, prefix: `${sp.key}\u0001`,
|
|
522
|
+
walkers: mine(tileWalkers, sp.key, createWalkers), flights: mine(tileFlights, sp.key, createFlights) };
|
|
523
|
+
});
|
|
524
|
+
}, [overview, stored, view, packs, packSkins, hideAsleep, lobbyOn, lobbyAll, style]);
|
|
525
|
+
const arranged = useMemo(() => tileLayout(tiles.map((tl) => ({ key: tl.key, w: tl.L.width, h: tl.L.height })), size), [tiles, size.w, size.h]);
|
|
526
|
+
const tileLooks = useMemo(() => { const work = workOn(traffic); return tiles.map((tl) => looksOf(tl.L, work, q, mode, tl.zones)); }, [tiles, traffic, q, mode, themeRev]);
|
|
527
|
+
// The scene the view pans and zooms: the office's, or the overview of every tile.
|
|
528
|
+
const L = useMemo(() => (overview ? { mode: "overview", rooms: [], width: arranged.width, height: arranged.height } : single), [overview, single, arranged]);
|
|
529
|
+
const looks = useMemo(() => looksOf(L, workOn(traffic), q, mode, zones), [L, traffic, q, mode, themeRev]);
|
|
344
530
|
useEffect(() => { flights.sync(traffic || [], now(), still); }, [traffic]);
|
|
345
531
|
// Zones: everyone's seat is where they walk to.
|
|
346
532
|
useEffect(() => {
|
|
347
|
-
if (!zones) return;
|
|
533
|
+
if (!zones || overview) return;
|
|
348
534
|
walkers.sync(new Map(desksOf(L).map((d) => [d.node.name, d.seat])), now(), L, still);
|
|
349
535
|
loop.current?.wake();
|
|
350
536
|
}, [L]);
|
|
537
|
+
// …and in each tile on a zones style; the traffic within a tile is its own, between tiles the overview's.
|
|
538
|
+
useEffect(() => {
|
|
539
|
+
for (const tl of tiles) if (tl.zones) tl.walkers.sync(new Map(desksOf(tl.L).map((d) => [d.node.name, d.seat])), now(), tl.L, still);
|
|
540
|
+
loop.current?.wake();
|
|
541
|
+
}, [tiles]);
|
|
542
|
+
useEffect(() => {
|
|
543
|
+
if (!overview) return;
|
|
544
|
+
for (const tl of tiles) tl.flights.sync(traffic || [], now(), still);
|
|
545
|
+
cross.sync(traffic || [], now(), still);
|
|
546
|
+
loop.current?.wake();
|
|
547
|
+
}, [traffic, tiles]);
|
|
351
548
|
|
|
352
549
|
// ── The view (#2540): { k, x, y } as the org chart canvas's (fleet-canvas-view.js) — logical units → the stage's CSS
|
|
353
550
|
// px. Set without a render (a ref): the canvas redraws, the overview, the tooltip and Edit layout's overlay follow ──
|
|
@@ -355,7 +552,9 @@ export function Office({ view, traffic, q = "", lease, onOpen }) {
|
|
|
355
552
|
// touched: a person moved the view (it is kept); fitted: the view is a fit (a resize fits it again, to the new size).
|
|
356
553
|
const touched = useRef(false), fitted = useRef(false), drag = useRef(gestureState());
|
|
357
554
|
const miniViewRef = useRef(null), miniRef = useRef(null), overlayEl = useRef(null), tipDeskRef = useRef(null), pctRef = useRef(null);
|
|
358
|
-
const
|
|
555
|
+
const labelEls = useRef(new Map()); // #2554: the tiles' labels (tile key → element)
|
|
556
|
+
// What a view is kept for: the office and its style (All offices: its own key).
|
|
557
|
+
const officeKey = overview ? `${ALL_ID}@tiles` : `${custom ? custom.id : AUTO_ID}@${baseSkin.id}`;
|
|
359
558
|
const keyRef = useRef(officeKey), saveTimer = useRef(0);
|
|
360
559
|
// The fit mode (Fit: contain; Fill: cover), kept per office and style like the view.
|
|
361
560
|
const [fitRev, setFitRev] = useState(0);
|
|
@@ -400,6 +599,21 @@ export function Office({ view, traffic, q = "", lease, onOpen }) {
|
|
|
400
599
|
const pc = pctRef.current;
|
|
401
600
|
if (pc) { const lb = zoomLabel(v.k); if (pc.textContent !== lb) pc.textContent = lb; pc.setAttribute("aria-label", to("zoomLevel", lb)); }
|
|
402
601
|
if (overlayEl.current) placeOverlay(overlayEl.current);
|
|
602
|
+
// All offices: each tile's label just above it, as wide as the room up to the next label in its row (or the stage's
|
|
603
|
+
// edge); hidden while its tile is off screen (nothing past the stage's edge for a focus to scroll it to: the
|
|
604
|
+
// controls stay where they are).
|
|
605
|
+
const shownTiles = overview ? new Set(visibleTiles(arranged, v, size).map((tt) => tt.key)) : null;
|
|
606
|
+
for (const [i, tt] of (overview ? arranged.tiles : []).entries()) {
|
|
607
|
+
const el = labelEls.current.get(tt.key);
|
|
608
|
+
if (!el || !el.style) continue;
|
|
609
|
+
const lp = toScreen(v, tt.x, tt.y), next = arranged.tiles[i + 1];
|
|
610
|
+
const room = next && next.label.y === tt.label.y ? (next.x - tt.x) * v.k - 6 : size.w - lp.x - 8;
|
|
611
|
+
el.style.setProperty("left", `${lp.x}px`); el.style.setProperty("top", `${lp.y}px`);
|
|
612
|
+
el.style.setProperty("max-width", `${Math.max(40, room)}px`);
|
|
613
|
+
el.style.setProperty("visibility", shownTiles.has(tt.key) ? "visible" : "hidden");
|
|
614
|
+
}
|
|
615
|
+
const st = stageRef.current;
|
|
616
|
+
if (st && (st.scrollLeft || st.scrollTop)) { st.scrollLeft = 0; st.scrollTop = 0; }
|
|
403
617
|
placeTip();
|
|
404
618
|
};
|
|
405
619
|
/** …and redraw. */
|
|
@@ -420,12 +634,18 @@ export function Office({ view, traffic, q = "", lease, onOpen }) {
|
|
|
420
634
|
if (saveTimer.current) clearTimeout(saveTimer.current);
|
|
421
635
|
saveTimer.current = setTimeout(flushSave, 400);
|
|
422
636
|
};
|
|
637
|
+
/**
|
|
638
|
+
* The scene fitted (`mode`: contain or cover). All offices' contain keeps FIT_PAD px clear all round, so no tile sits
|
|
639
|
+
* under the zoom controls at the side.
|
|
640
|
+
*/
|
|
641
|
+
const fitL = (mode) => fitScene(L.width, L.height, size, mode, overview && mode !== "cover" ? FIT_PAD : 0);
|
|
423
642
|
/** The view an office opens on: its kept one (a kept fit: fitted to this size), else its opening; and whether a fit. */
|
|
424
643
|
const openView = () => {
|
|
425
644
|
const kept = store.view(officeKey);
|
|
426
|
-
if (kept && kept.fit) return { v:
|
|
645
|
+
if (kept && kept.fit) return { v: fitL(fitMode), fit: true };
|
|
427
646
|
if (kept) return { v: viewFromSaved(kept, size), fit: false };
|
|
428
|
-
|
|
647
|
+
const fitted0 = zones || overview; // All offices opens whole, as a zones scene
|
|
648
|
+
return { v: overview ? fitL(fitMode) : openingView(L.width, L.height, size, ratio, fitted0, fitMode), fit: fitted0 };
|
|
429
649
|
};
|
|
430
650
|
// An office (or style) opened: its kept view, else its opening (zones fitted; desks from the top left). A resize fits
|
|
431
651
|
// a fitted view again (and opens an untouched one again); a view a person moved keeps the same point in the middle.
|
|
@@ -440,16 +660,16 @@ export function Office({ view, traffic, q = "", lease, onOpen }) {
|
|
|
440
660
|
opened.current = officeKey; keyRef.current = officeKey; touched.current = false;
|
|
441
661
|
reopen();
|
|
442
662
|
} else if (was && (was.w !== size.w || was.h !== size.h || was.ratio !== ratio)) {
|
|
443
|
-
if (fitted.current) vp.current =
|
|
663
|
+
if (fitted.current) vp.current = fitL(fitMode);
|
|
444
664
|
else if (!touched.current) reopen();
|
|
445
665
|
else { const v = vp.current, cx = (was.w / 2 - v.x) / v.k, cy = (was.h / 2 - v.y) / v.k; vp.current = { k: v.k, x: size.w / 2 - cx * v.k, y: size.h / 2 - cy * v.k }; }
|
|
446
|
-
} else if (fitted.current) vp.current =
|
|
666
|
+
} else if (fitted.current) vp.current = fitL(fitMode); // the scene's size changed
|
|
447
667
|
vp.current = clampPan(view0(), L.width, L.height, size);
|
|
448
668
|
raw.current = vp.current.k;
|
|
449
669
|
}, [officeKey, size.w, size.h, ratio, L.width, L.height]);
|
|
450
670
|
useEffect(() => () => flushSave(), []);
|
|
451
671
|
/** Fit (the button, `0`): the office's mode — `mode` when it was just switched. */
|
|
452
|
-
const fit = (mode = fitMode) => { const v =
|
|
672
|
+
const fit = (mode = fitMode) => { const v = fitL(mode); raw.current = v.k; setView(v, true, true); };
|
|
453
673
|
/** Fill on or off: kept for this office, and fitted at once. */
|
|
454
674
|
const toggleFill = () => {
|
|
455
675
|
const next = fitMode === "cover" ? "contain" : "cover";
|
|
@@ -538,8 +758,9 @@ export function Office({ view, traffic, q = "", lease, onOpen }) {
|
|
|
538
758
|
// or the zoom changes, never on a pan); each frame draws the matte, the layers, the people and the items in the air ──
|
|
539
759
|
const scene = useRef(null);
|
|
540
760
|
const loop = useRef(null);
|
|
541
|
-
// The matte around the scene: the pack's own, else its background's edge (sampled once), else the skin's (tokens)
|
|
542
|
-
|
|
761
|
+
// The matte around the scene: the pack's own, else its background's edge (sampled once), else the skin's (tokens);
|
|
762
|
+
// All offices: the dashboard's background (each tile is its own scene).
|
|
763
|
+
const matte = overview ? pal.bg : (skin.pack && skin.pack.matte) || sampledMatte(skin.backdrop) || (skin.matte ? skin.matte({ pal, mode }) : pal.bg);
|
|
543
764
|
// …and behind the canvas too (the stage), so no page grey shows while it is (re)sized.
|
|
544
765
|
useLayoutEffect(() => { const el = stageRef.current; if (el && el.style) el.style.setProperty("background-color", matte); }, [matte]);
|
|
545
766
|
useLayoutEffect(() => {
|
|
@@ -552,108 +773,54 @@ export function Office({ view, traffic, q = "", lease, onOpen }) {
|
|
|
552
773
|
cv.style.setProperty("width", `${BW / ratio}px`);
|
|
553
774
|
cv.style.setProperty("height", `${BH / ratio}px`);
|
|
554
775
|
const env = { pal, mode, labels: { lobby: to("lobbyRoom"), closed: to("closed"), areas: { desks: to("area_desks"), lounge: to("area_lounge"), alert: to("area_alert"), nap: to("area_nap") } } };
|
|
555
|
-
const
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
const
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
if (!g) return null;
|
|
564
|
-
g.setTransform(ls, 0, 0, ls, 0, 0);
|
|
565
|
-
g.imageSmoothingEnabled = false;
|
|
566
|
-
fn(g);
|
|
567
|
-
return c;
|
|
568
|
-
};
|
|
569
|
-
const back = layer((g) => {
|
|
570
|
-
g.fillStyle = env.pal.bg; g.fillRect(0, 0, L.width, L.height);
|
|
571
|
-
if (zones) {
|
|
572
|
-
skin.drawBackground(g, L, env);
|
|
573
|
-
return;
|
|
574
|
-
}
|
|
575
|
-
skin.drawFloor(g, L, env);
|
|
576
|
-
for (const r of L.rooms) skin.drawRoom(g, r, env);
|
|
577
|
-
for (const d of all) skin.drawDeskBack(g, d, looks.get(d.key), env);
|
|
578
|
-
});
|
|
579
|
-
const front = zones ? null : layer((g) => { for (const d of all) skin.drawDeskFront(g, d, looks.get(d.key), env); });
|
|
580
|
-
return { ls, back, front };
|
|
581
|
-
};
|
|
582
|
-
const shown = new Set([...looks.values()].map((l) => l.state));
|
|
583
|
-
scene.current = { ctx, ratio, BW, BH, paint, layers: null, matte, env, desks: all.filter((d) => d.node), looks, L, skin, zones, dirty: true,
|
|
584
|
-
active: zones ? activeFurniture(skin.pack, shown) : new Set(), lines: bubbleDefaults(), lang: lang() };
|
|
776
|
+
const base = { ctx, ratio, BW, BH, matte, env, dirty: true };
|
|
777
|
+
if (overview) {
|
|
778
|
+
// Each tile a scene of its own, where the overview puts it (arranged.tiles, in the same order).
|
|
779
|
+
const parts = tiles.map((tl, i) => ({ ...partOf(tl.L, tileLooks[i], tl.skin, tl.zones, tl.walkers, tl.flights), key: tl.key, prefix: tl.prefix, tile: arranged.tiles[i] }));
|
|
780
|
+
scene.current = { ...base, overview: true, parts, lay: arranged };
|
|
781
|
+
} else {
|
|
782
|
+
scene.current = { ...base, overview: false, part: partOf(L, looks, skin, zones, walkers, flights) };
|
|
783
|
+
}
|
|
585
784
|
if (still) draw(0); else loop.current?.wake();
|
|
586
|
-
}, [L, looks, skin, pal, size.w, size.h, ratio, matte]);
|
|
785
|
+
}, [L, looks, skin, pal, size.w, size.h, ratio, matte, overview, tiles, tileLooks, arranged]);
|
|
587
786
|
|
|
588
787
|
/** One frame at `ms` (0: still); whether anything is moving (in the air, or someone walking). */
|
|
589
788
|
function draw(ms) {
|
|
590
789
|
const s = scene.current;
|
|
591
790
|
if (!s) return false;
|
|
592
|
-
const { ctx, BW, BH, env
|
|
791
|
+
const { ctx, BW, BH, env } = s;
|
|
593
792
|
// The view in bitmap px: a logical unit is `a` px (a whole number at a pixel level), the origin on a whole px.
|
|
594
793
|
const v = view0(), a = v.k * s.ratio, ox = Math.round(v.x * s.ratio), oy = Math.round(v.y * s.ratio);
|
|
595
|
-
const ls = layerScale(a, s.L.width, s.L.height);
|
|
596
|
-
if (!s.layers || s.layers.ls !== ls) s.layers = s.paint(ls);
|
|
597
|
-
const T = () => ctx.setTransform(a, 0, 0, a, ox, oy);
|
|
598
|
-
const blit = (c) => { if (!c) return; ctx.setTransform(a / ls, 0, 0, a / ls, ox, oy); ctx.drawImage(c, 0, 0); T(); };
|
|
599
794
|
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
600
795
|
ctx.clearRect(0, 0, BW, BH);
|
|
601
796
|
ctx.fillStyle = s.matte; ctx.fillRect(0, 0, BW, BH);
|
|
602
797
|
ctx.imageSmoothingEnabled = false;
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
const
|
|
607
|
-
let
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
.sort((a, b) => a.y - b.y || a.k - b.k);
|
|
621
|
-
for (const f of furniture) if (f.depth !== "y" && f.depth < CHAR_DEPTH) sk.drawFurniture(ctx, f, s.active.has(f.id), ms, env);
|
|
622
|
-
for (const { f, p } of band) {
|
|
623
|
-
if (f) { sk.drawFurniture(ctx, f, s.active.has(f.id), ms, env); continue; }
|
|
624
|
-
const box = { ...p.d, x: p.at.x - 44, y: p.at.y - 53, w: 88, h: 53, seat: { x: p.at.x, y: p.at.y } };
|
|
625
|
-
sk.drawCharacter(ctx, box, p.at.walking ? { ...p.look, pose: "walk", heading: p.at.heading } : p.look, ms, env);
|
|
626
|
-
if (!p.at.walking) speakers.push({ look: p.look, x: p.at.x + 10, y: p.at.y - 50, area: p.d.area });
|
|
627
|
-
}
|
|
628
|
-
for (const f of furniture) if (f.depth !== "y" && f.depth >= CHAR_DEPTH) sk.drawFurniture(ctx, f, s.active.has(f.id), ms, env);
|
|
629
|
-
for (const it of up) sk.drawProp(ctx, it, ms, env);
|
|
630
|
-
if (sk.drawForeground) sk.drawForeground(ctx, s.L, env);
|
|
631
|
-
// Who found no spot in an area: a count in its corner (never over its name).
|
|
632
|
-
for (const [a, n] of s.L.crowd || []) if (s.L.areas[a] && sk.drawCrowd) sk.drawCrowd(ctx, s.L.areas[a], n, env);
|
|
633
|
-
// In an area over capacity the name tags show on hover or focus only (#2513: a crowd's tags covered each other).
|
|
634
|
-
const over = s.L.over || new Set(), hot = tipNow.current;
|
|
635
|
-
for (const p of people) {
|
|
636
|
-
if (over.has(p.d.area) && p.d.key !== hot && !p.look.match) continue;
|
|
637
|
-
sk.drawTag(ctx, p.at.x, p.at.y, p.look, env);
|
|
638
|
-
tags.push(tagBox(ctx, p.at.x, p.at.y, p.look.label));
|
|
639
|
-
}
|
|
640
|
-
} else {
|
|
641
|
-
for (const d of s.desks) { const look = s.looks.get(d.key); sk.drawCharacter(ctx, d, look, ms, env); speakers.push({ look, x: d.x + 58, y: d.y + 12 }); }
|
|
642
|
-
blit(s.layers.front);
|
|
643
|
-
for (const it of up) sk.drawProp(ctx, it, ms, env);
|
|
798
|
+
if (!s.overview) return drawPart(ctx, s.part, a, ox, oy, ms, still, tipNow.current, env);
|
|
799
|
+
// All offices: each tile on screen drawn as its own view, scaled into it and clipped to it; one off screen is not
|
|
800
|
+
// drawn (its flights still land). Then a frame round each tile, and the items flying between tiles.
|
|
801
|
+
const hot = tipNow.current, on = new Set(visibleTiles(s.lay, v, sizeRef.current).map((tt) => tt.key)), said = [];
|
|
802
|
+
let moving = false;
|
|
803
|
+
for (const p of s.parts) {
|
|
804
|
+
const tt = p.tile;
|
|
805
|
+
if (!on.has(tt.key)) { if (!still) p.flights.frame(ms, p.L); continue; }
|
|
806
|
+
const tx = Math.round((v.x + tt.x * v.k) * s.ratio), ty = Math.round((v.y + tt.y * v.k) * s.ratio), tw = tt.w * v.k * s.ratio, th = tt.h * v.k * s.ratio;
|
|
807
|
+
ctx.save();
|
|
808
|
+
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
809
|
+
ctx.beginPath(); ctx.rect(tx, ty, tw, th); ctx.clip();
|
|
810
|
+
if (drawPart(ctx, p, a * tt.s, tx, ty, ms, still, hot && hot.startsWith(p.prefix) ? hot.slice(p.prefix.length) : null, env, v.k * tt.s < TAG_MIN_K, said)) moving = true;
|
|
811
|
+
ctx.restore();
|
|
812
|
+
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
813
|
+
ctx.strokeStyle = env.pal.border; ctx.lineWidth = 1;
|
|
814
|
+
ctx.strokeRect(tx - 0.5, ty - 0.5, tw + 1, th + 1);
|
|
644
815
|
}
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
const
|
|
651
|
-
if (b) bubbles.push({ key: sp.look.name, area: sp.area, state: sp.look.state, calling: CALLING.has(sp.look.state), rank: bubbleRank(sp.look.state), shown: b.shown,
|
|
652
|
-
box: bubbleBox(ctx, sp.x, sp.y, b.text), tail: { x: sp.x, y: sp.y }, sp, b });
|
|
816
|
+
for (const say of said) say();
|
|
817
|
+
const up = still ? [] : cross.frame(ms, s.parts);
|
|
818
|
+
if (up.length) {
|
|
819
|
+
ctx.setTransform(a, 0, 0, a, ox, oy);
|
|
820
|
+
const prop = skinFor("pixel").drawProp;
|
|
821
|
+
for (const it of up) prop(ctx, it, ms, env);
|
|
653
822
|
}
|
|
654
|
-
|
|
655
|
-
for (const { sp, b, dy } of placeBubbles(thinBubbles(bubbles, s.zones ? s.L.over : null), 22, tags)) sk.drawBubble(ctx, sp.x, sp.y, [...b.text].slice(0, b.shown).join(""), env, -dy);
|
|
656
|
-
return walking || up.length > 0 || flights.size() > 0;
|
|
823
|
+
return moving || up.length > 0 || cross.size() > 0;
|
|
657
824
|
}
|
|
658
825
|
// The loop: drawing only when the frame (FPS) changed or something is in the air; stopped while the tab is hidden,
|
|
659
826
|
// gone with the view. Office review: while something walks or flies, one requestAnimationFrame per display frame (the
|
|
@@ -694,22 +861,38 @@ export function Office({ view, traffic, q = "", lease, onOpen }) {
|
|
|
694
861
|
tipNow.current = tip;
|
|
695
862
|
useEffect(() => { if (!scene.current) return; scene.current.dirty = true; if (still) draw(0); else loop.current?.wake(); }, [tip]);
|
|
696
863
|
|
|
864
|
+
// Everyone drawn: { key, node, box (the desk, in the view's units), dot (where the overview marks them), office (All
|
|
865
|
+
// offices: the tile's name) }. In All offices a key is the tile's prefix and the desk's key: one person in two
|
|
866
|
+
// offices is two people here.
|
|
867
|
+
const people = overview
|
|
868
|
+
? tiles.flatMap((tl, i) => { const tt = arranged.tiles[i];
|
|
869
|
+
return tt ? desksOf(tl.L).map((d) => ({ key: tl.prefix + d.key, node: d.node, box: tileBox(tt, d), office: tl.name,
|
|
870
|
+
dot: fromTile(tt, tl.zones ? d.seat : { x: d.x + d.w / 2, y: d.y + d.h / 2 }) })) : []; })
|
|
871
|
+
: desksOf(L).map((d) => ({ key: d.key, node: d.node, box: d, office: null, dot: zones ? d.seat : { x: d.x + d.w / 2, y: d.y + d.h / 2 } }));
|
|
697
872
|
// Preact owns the markup, not the view: put it back on the overview, the overlay and the tooltip after every render.
|
|
698
|
-
const tipDesk = tip ?
|
|
699
|
-
tipDeskRef.current = tipDesk;
|
|
873
|
+
const tipDesk = tip ? people.find((d) => d.key === tip) || null : null;
|
|
874
|
+
tipDeskRef.current = tipDesk ? tipDesk.box : null;
|
|
700
875
|
useLayoutEffect(mark);
|
|
701
876
|
// ── Pointing (#2540: through the view): hover (or a first tap) shows a desk's card, a click (or a second tap) opens
|
|
702
877
|
// its chat; a drag on the scene pans it (never on Edit layout's handles: they move), the wheel and a pinch zoom ──
|
|
703
|
-
|
|
878
|
+
/** The view's point under a pointer event (logical units), or null. */
|
|
879
|
+
const pointOf = (e) => {
|
|
704
880
|
const r = canvasRef.current ? canvasRef.current.getBoundingClientRect() : null;
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
881
|
+
return r ? toWorld(view0(), e.clientX - r.left, e.clientY - r.top) : null;
|
|
882
|
+
};
|
|
883
|
+
/** The person under a pointer event as drawn ({ key, node }), or null; All offices: in the tile there, in its own units. */
|
|
884
|
+
const deskOf = (e) => {
|
|
885
|
+
const w = pointOf(e), s = scene.current;
|
|
886
|
+
if (!w) return null;
|
|
887
|
+
// The zones scene draws people where they walk: they are hit there, not at the seat they are heading for (hitPart).
|
|
888
|
+
if (!s || !s.overview) {
|
|
889
|
+
if (overview) return null;
|
|
890
|
+
return hitPart(s && s.part ? s.part : { L, zones, walkers, skin, looks, all: L.rooms.flatMap((rm) => rm.desks) }, w.x, w.y, now());
|
|
891
|
+
}
|
|
892
|
+
const tt = tileAt(s.lay, w.x, w.y), p = tt ? s.parts.find((x) => x.tile.key === tt.key) : null;
|
|
893
|
+
if (!p) return null;
|
|
894
|
+
const at = inTile(tt, w.x, w.y), d = hitPart(p, at.x, at.y, now());
|
|
895
|
+
return d ? { key: p.prefix + d.key, node: d.node } : null;
|
|
713
896
|
};
|
|
714
897
|
const g = gestures(drag.current, { el: () => stageRef.current, pan: (dx, dy) => { const v = view0(); setView({ ...v, x: v.x + dx, y: v.y + dy }); },
|
|
715
898
|
pinch: (from, to) => { raw.current = Math.min(OK_MAX, Math.max(OK_MIN, raw.current * (to.dist / from.dist))); setView(pinchOffice(view0(), from, to, raw.current)); } });
|
|
@@ -722,7 +905,7 @@ export function Office({ view, traffic, q = "", lease, onOpen }) {
|
|
|
722
905
|
if (drag.current.pts.size === 2) raw.current = view0().k; // a pinch starts from the zoom shown
|
|
723
906
|
};
|
|
724
907
|
const onWheel = (e) => {
|
|
725
|
-
if (!onScene(e.target) && !(typeof e.target?.closest === "function" && e.target.closest(".ol-handle"))) return;
|
|
908
|
+
if (!onScene(e.target) && !(typeof e.target?.closest === "function" && e.target.closest(".ol-handle, .oo-tile-label"))) return;
|
|
726
909
|
e.preventDefault();
|
|
727
910
|
const r = stageRef.current.getBoundingClientRect(), v = view0();
|
|
728
911
|
raw.current = Math.min(OK_MAX, Math.max(OK_MIN, raw.current * wheelFactor(e)));
|
|
@@ -743,9 +926,9 @@ export function Office({ view, traffic, q = "", lease, onOpen }) {
|
|
|
743
926
|
if (!el || typeof el.getAttribute !== "function") return;
|
|
744
927
|
const hk = el.getAttribute("data-handle");
|
|
745
928
|
if (hk && layout) { const hd = handlesOf(layout.draft).find((x) => x.key === hk); if (hd) show({ x: hd.x - 12, y: hd.y - 12, w: 24, h: 24 }); return; }
|
|
746
|
-
const
|
|
747
|
-
const d =
|
|
748
|
-
if (d) show(d);
|
|
929
|
+
const key = el.classList && el.classList.contains("oo-desk") ? el.getAttribute("data-key") : null;
|
|
930
|
+
const d = key ? people.find((x) => x.key === key) : null;
|
|
931
|
+
if (d) show(d.box);
|
|
749
932
|
};
|
|
750
933
|
const onMove = (e) => { if (e.pointerType === "touch" || g.busy()) return; const d = deskOf(e); const key = d ? d.key : null; if (key !== tip) setTip(key); };
|
|
751
934
|
const onClick = (e) => {
|
|
@@ -754,14 +937,21 @@ export function Office({ view, traffic, q = "", lease, onOpen }) {
|
|
|
754
937
|
if (lastType.current === "touch" && tip !== d.key) { setTip(d.key); return; } // a first tap shows; a second opens
|
|
755
938
|
open(d.node.name);
|
|
756
939
|
};
|
|
940
|
+
// All offices: a double-click on a tile (off its people) opens that office; the lobby is no office.
|
|
941
|
+
const onDblClick = (e) => {
|
|
942
|
+
const s = scene.current, w = pointOf(e);
|
|
943
|
+
if (!overview || !s || !s.overview || !w || deskOf(e)) return;
|
|
944
|
+
const tt = tileAt(s.lay, w.x, w.y) || labelAt(s.lay, w.x, w.y), tl = tt ? tiles.find((x) => x.key === tt.key) : null;
|
|
945
|
+
if (tl && !tl.lobby) pick(tl.key);
|
|
946
|
+
};
|
|
757
947
|
const onMini = (e) => {
|
|
758
948
|
const w = miniPoint(miniRef.current ? miniRef.current.getBoundingClientRect() : null, { x: 0, y: 0, w: L.width, h: L.height }, e.clientX, e.clientY);
|
|
759
949
|
if (w) setView(centreOn(view0(), size, w.x, w.y));
|
|
760
950
|
};
|
|
761
951
|
|
|
762
|
-
const auto = !custom;
|
|
952
|
+
const auto = !custom && !overview;
|
|
763
953
|
// Edit layout: an admin, a stored office, a zones style (built in, or a pack loaded here); not while the offices'
|
|
764
|
-
// editor is open, nor a second session.
|
|
954
|
+
// editor is open, nor a second session (nor in All offices: custom is null there).
|
|
765
955
|
const canLayout = canEdit && storedRev !== null && !!custom && zones && !loading && baseSkin.id === custom.skin && !editing && !layout;
|
|
766
956
|
const openLayout = () => {
|
|
767
957
|
setLayout({ id: ++layoutSeq.current, officeId: custom.id, skin: custom.skin, pack: styleKey, base: baseSkin.pack, start: skin.pack, draft: skin.pack, rev: storedRev, list: stored });
|
|
@@ -795,14 +985,17 @@ export function Office({ view, traffic, q = "", lease, onOpen }) {
|
|
|
795
985
|
// While Edit layout is open the office stays (its picker is disabled; this is the guard behind it).
|
|
796
986
|
const pick = (id) => { if (layoutRef.current) return; store.saveChoice(id); setChoice(id); setTip(null); };
|
|
797
987
|
const everyone = useMemo(() => { const m = new Map(); for (const n of [...view.top, ...view.teams.flatMap((tm) => tm.nodes), ...view.rest.nodes]) if (!m.has(n.name)) m.set(n.name, n); return [...m.values()]; }, [view]);
|
|
798
|
-
const desks = desksOf(L);
|
|
799
988
|
const tipText = tipDesk ? nodeText(tipDesk.node) : null;
|
|
800
|
-
// The overview: the scene's rooms (or areas) and a dot for each person (a call in its colour),
|
|
801
|
-
|
|
989
|
+
// The overview: the scene's rooms (or areas; All offices: the tiles) and a dot for each person (a call in its colour),
|
|
990
|
+
// the view's frame on it.
|
|
991
|
+
const miniRects = overview ? arranged.tiles.map((tt) => ({ key: tt.key, x: tt.x, y: tt.y, w: tt.w, h: tt.h }))
|
|
992
|
+
: zones ? Object.values(L.areas || {}).map((a) => ({ key: a.name, ...a.rect })) : L.rooms.map((r) => ({ key: r.key, x: r.x, y: r.y, w: r.w, h: r.h }));
|
|
802
993
|
const dotR = Math.max(L.width, L.height) / 90;
|
|
803
|
-
|
|
994
|
+
// The switcher: the whole fleet, All offices (two or more stored), each stored office.
|
|
995
|
+
const officeSelect = (cls) => html`<select class=${`tb-select ${cls}`} value=${layout ? layout.officeId : overview ? ALL_ID : custom ? custom.id : AUTO_ID} disabled=${!!layout}
|
|
804
996
|
aria-label=${to("office")} onChange=${(e) => pick(e.target.value)}>
|
|
805
997
|
<option value=${AUTO_ID}>${to("allOffice")}</option>
|
|
998
|
+
${overviewOk(stored) ? html`<option value=${ALL_ID}>${to("allOffices")}</option>` : null}
|
|
806
999
|
${stored.map((o) => html`<option key=${o.id} value=${o.id}>${o.name}</option>`)}
|
|
807
1000
|
</select>`;
|
|
808
1001
|
return html`<div class=${`oo${fs ? ` fs ${fs}` : ""}`} ref=${ooRef}>
|
|
@@ -819,32 +1012,43 @@ export function Office({ view, traffic, q = "", lease, onOpen }) {
|
|
|
819
1012
|
</select></label>
|
|
820
1013
|
<${People} nodes=${everyone} pinned=${f.pinned} onChange=${(pinned) => setF({ ...f, pinned })} />` : null}
|
|
821
1014
|
<label class="check oo-hide-asleep"><input type="checkbox" checked=${hideAsleep} onChange=${() => { const v = !hideAsleep; store.saveHideAsleep(v); setHideAsleep(v); setTip(null); }} /><span>${to("hideAsleep")}</span></label>
|
|
1015
|
+
${overview ? html`<label class="check oo-lobby-tile" title=${lobbyAll.length ? undefined : to("lobbyEveryone")}><input type="checkbox" checked=${lobbyOn && lobbyAll.length > 0}
|
|
1016
|
+
disabled=${!lobbyAll.length} onChange=${() => { const v = !lobbyOn; store.saveLobbyTile(v); setLobbyOn(v); setTip(null); }} /><span>${to("lobbyTile")}</span></label>
|
|
1017
|
+
${lobbyAll.length ? null : html`<span class="muted oo-note oo-lobby-none">${to("lobbyEveryone")}</span>`}` : null}
|
|
822
1018
|
${custom && baseSkin.id !== custom.skin && !loading ? html`<span class="muted oo-note">${to("skinUnknown", styleName(custom.skin))}</span>` : null}
|
|
823
1019
|
${placed.ignored ? html`<span class="muted oo-note">${to("layoutIgnored")}</span>` : null}
|
|
824
1020
|
${L.unseated ? html`<span class="muted oo-note">${to("unseated", L.unseated)}</span>` : null}
|
|
825
|
-
${canEdit && storedRev !== null ? html`<button type="button" class="btn btn-sm oo-edit" onClick=${() => setEditing(true)} disabled=${!!layout
|
|
1021
|
+
${canEdit && storedRev !== null ? html`<button type="button" class="btn btn-sm oo-edit" onClick=${() => { if (!overview) setEditing(true); }} disabled=${!!layout || overview}
|
|
1022
|
+
title=${overview ? to("overviewNoEdit") : undefined}>${t("orge.edit")}</button>` : null}
|
|
826
1023
|
${canLayout ? html`<button type="button" class="btn btn-sm oo-layout" onClick=${openLayout}>${t("orgl.edit")}</button>` : null}
|
|
827
1024
|
</div>
|
|
828
|
-
${editing ? html`<${OfficeEditor} offices=${stored} rev=${storedRev} view=${view} store=${store} packs=${packs}
|
|
1025
|
+
${editing && !overview ? html`<${OfficeEditor} offices=${stored} rev=${storedRev} view=${view} store=${store} packs=${packs}
|
|
829
1026
|
ask=${ask} current=${() => shown.current} onSaved=${adopt} onClose=${() => setEditing(false)} />` : null}
|
|
830
|
-
${
|
|
1027
|
+
${overview || people.length || L.rooms.length ? null : html`<p class="note oo-empty">${to("nobody")} ${auto && (f.team || f.pinned.length) ? html`<button type="button" class="btn btn-sm" onClick=${() => setF({ team: "", pinned: [] })}>${to("everyone")}</button>` : null}</p>`}
|
|
831
1028
|
<div class=${`oo-stage${layout ? " editing" : ""}`} ref=${stageRef} onPointerDown=${onDown} onPointerMove=${g.move} onPointerUp=${g.up} onPointerCancel=${g.up}
|
|
832
1029
|
onWheel=${onWheel} onKeyDown=${onKeyDown} onFocusIn=${onFocusIn} onClickCapture=${g.clickCapture}>
|
|
833
|
-
<canvas ref=${canvasRef} class=${`oo-canvas${tipDesk && tipDesk.node.i ? " hot" : ""}`} role="application" tabindex="0" aria-label=${to("label")}
|
|
834
|
-
onPointerMove=${onMove} onPointerLeave=${() => setTip(null)} onClick=${onClick}></canvas>
|
|
1030
|
+
<canvas ref=${canvasRef} class=${`oo-canvas${tipDesk && tipDesk.node.i ? " hot" : ""}`} role="application" tabindex="0" aria-label=${to(overview ? "labelAll" : "label")}
|
|
1031
|
+
onPointerMove=${onMove} onPointerLeave=${() => setTip(null)} onClick=${onClick} ondblclick=${onDblClick}></canvas>
|
|
1032
|
+
${overview ? tiles.map((tl) => {
|
|
1033
|
+
const text = to("tileLabel", tl.name, tl.count), at = (el) => { if (el) labelEls.current.set(tl.key, el); else labelEls.current.delete(tl.key); };
|
|
1034
|
+
return tl.lobby ? html`<span key=${tl.key} ref=${at} class="oo-tile-label lobby">${text}</span>`
|
|
1035
|
+
: html`<button key=${tl.key} ref=${at} type="button" class="oo-tile-label" data-office=${tl.key} title=${to("openOffice", tl.name)} aria-label=${to("openOffice", tl.name)}
|
|
1036
|
+
onClick=${() => pick(tl.key)}>${text}</button>`;
|
|
1037
|
+
}) : null}
|
|
835
1038
|
${layout ? html`<${LayoutEditor} key=${layout.id} base=${layout.base} start=${layout.start} place=${placeOverlay}
|
|
836
1039
|
stale=${layoutStale ? t("orgl.stale") : ""} onChange=${onDraft} onSave=${saveLayout} onClose=${closeLayout} />` : null}
|
|
837
1040
|
${tipText ? html`<div ref=${tipRef} class="oo-tip" role="tooltip">
|
|
838
1041
|
<strong>${tipText.primary}</strong>${tipText.secondary ? html` <span class="muted mono">${tipText.secondary}</span>` : null}
|
|
839
1042
|
<div>${tipText.sub}</div><div class="muted">${caption(officePose(tipDesk.node.state), tipDesk.node.state)}</div>
|
|
1043
|
+
${tipDesk.office ? html`<div class="muted oo-tip-office">${tipDesk.office}</div>` : null}
|
|
840
1044
|
${tipDesk.node.i && lastType.current === "touch" ? html`<div class="muted">${to("tapAgain")}</div>` : null}
|
|
841
1045
|
</div>` : null}
|
|
842
|
-
<ul class="oo-desks" aria-label=${to("desks")}>${
|
|
843
|
-
const nt = nodeText(d.node), cap = caption(officePose(d.node.state), d.node.state);
|
|
1046
|
+
<ul class="oo-desks" aria-label=${to("desks")}>${people.map((d) => {
|
|
1047
|
+
const nt = nodeText(d.node), cap = caption(officePose(d.node.state), d.node.state), where = d.office ? ` · ${d.office}` : "";
|
|
844
1048
|
return html`<li key=${d.key}>${d.node.i
|
|
845
|
-
? html`<button type="button" class="oo-desk" data-name=${d.node.name} aria-label=${`${nt.full} · ${cap}. ${t("orgc.openChat")}`}
|
|
1049
|
+
? html`<button type="button" class="oo-desk" data-name=${d.node.name} data-key=${d.key} aria-label=${`${nt.full}${where} · ${cap}. ${t("orgc.openChat")}`}
|
|
846
1050
|
onFocus=${() => setTip(d.key)} onBlur=${() => setTip(null)} onClick=${() => open(d.node.name)}>${nt.primary}</button>`
|
|
847
|
-
: html`<span class="oo-desk" data-name=${d.node.name}>${nt.full}</span>`}</li>`;
|
|
1051
|
+
: html`<span class="oo-desk" data-name=${d.node.name} data-key=${d.key}>${nt.full}</span>`}</li>`;
|
|
848
1052
|
})}</ul>
|
|
849
1053
|
<div class="oc-zoom oo-zoom" role="group" aria-label=${t("orgc.view")}>
|
|
850
1054
|
<output ref=${pctRef} class="oo-zoom-pct"></output>
|
|
@@ -861,8 +1065,7 @@ export function Office({ view, traffic, q = "", lease, onOpen }) {
|
|
|
861
1065
|
<rect class="oo-mini-matte" x=${-L.width} y=${-L.height} width=${L.width * 3} height=${L.height * 3} fill=${matte} />
|
|
862
1066
|
<rect class="oo-mini-scene" x="0" y="0" width=${L.width} height=${L.height} />
|
|
863
1067
|
${miniRects.map((r) => html`<rect key=${r.key} class="oo-mini-room" x=${r.x} y=${r.y} width=${r.w} height=${r.h} />`)}
|
|
864
|
-
${
|
|
865
|
-
return html`<circle key=${d.key} class=${`oo-mini-dot st-${d.node.state}${q && hits(d.node, q) ? " match" : ""}`} cx=${at.x} cy=${at.y} r=${dotR} />`; })}
|
|
1068
|
+
${people.map((d) => html`<circle key=${d.key} class=${`oo-mini-dot st-${d.node.state}${q && hits(d.node, q) ? " match" : ""}`} cx=${d.dot.x} cy=${d.dot.y} r=${dotR} />`)}
|
|
866
1069
|
<rect ref=${miniViewRef} class="oc-mini-view oo-mini-view" />
|
|
867
1070
|
</svg>
|
|
868
1071
|
</div>
|
package/dist/ui/panel-chat.js
CHANGED
|
@@ -44,6 +44,11 @@ const writeKey = (k, v) => { try { if (v) localStorage.setItem(k, v); else local
|
|
|
44
44
|
export const chatCardPrefs = createStore({ open: readKey(CHAT_CARD_KEY, "open"), rowHidden: readKey(CHAT_CARD_ROW_KEY, "hidden") });
|
|
45
45
|
export const setChatCardOpen = (open) => { writeKey(CHAT_CARD_KEY, open ? "open" : null); chatCardPrefs.set((s) => ({ ...s, open })); };
|
|
46
46
|
export const setChatCardRowHidden = (rowHidden) => { writeKey(CHAT_CARD_ROW_KEY, rowHidden ? "hidden" : null); chatCardPrefs.set((s) => ({ ...s, rowHidden })); };
|
|
47
|
+
// Full width (the header's ⋯ menu): this browser's choice, like code wrap. The thread and the composer take the panel's
|
|
48
|
+
// width; a person's bubble keeps its cap (app.css). A phone's column is the screen already: no item there.
|
|
49
|
+
const CHAT_WIDE_KEY = "agend_chat_full_width";
|
|
50
|
+
export const chatWide = createStore({ on: readKey(CHAT_WIDE_KEY, "on") });
|
|
51
|
+
export const setChatWide = (on) => { writeKey(CHAT_WIDE_KEY, on ? "on" : null); chatWide.set({ on }); };
|
|
47
52
|
function ChatCard({ name, card }) {
|
|
48
53
|
const prefs = useStore(chatCardPrefs);
|
|
49
54
|
if (!card || !prefs.open) return null;
|
|
@@ -260,6 +265,7 @@ function ChatView({ name, inst, lease, navKey, exec, awaiting }) {
|
|
|
260
265
|
const publishCanvasPlayback = useCallback(value => setCanvasPlayback(previous =>
|
|
261
266
|
!previous && !value.playing || previous?.name === value.name && previous.playing === value.playing ? previous : value), []);
|
|
262
267
|
const [wrap, setWrap] = useState(codeWrap);
|
|
268
|
+
const wide = useStore(chatWide).on;
|
|
263
269
|
const view = useRef(null), split = useRef(null), thread = useRef(null), bg = useRef(null), effectSurface = useRef(null);
|
|
264
270
|
useLayoutEffect(() => { applyTheme(view.current, theme, mode); }, [themeKey, mode]);
|
|
265
271
|
useLayoutEffect(() => {
|
|
@@ -321,6 +327,7 @@ function ChatView({ name, inst, lease, navKey, exec, awaiting }) {
|
|
|
321
327
|
...themeMenuItems(name),
|
|
322
328
|
// #2117: its ambient — why it is not showing here (with the way to change that), and a person's Clear.
|
|
323
329
|
...ambientMenuItems(name, { publicLink: document.body.dataset.publicLink === "1", clear: clearAmbient }),
|
|
330
|
+
narrowScreen() ? null : { key: "fullWidth", checkbox: true, checked: wide, label: t("chat.fullWidth"), onSelect: () => setChatWide(!wide) },
|
|
324
331
|
cwEnabled && canvasWorkerMenuAvailable(window) ? { key: "effects", label: t("cwControls.title"), onSelect: () => setDialog("effects") } : null,
|
|
325
332
|
{ key: "delete", label: t("chat.delete"), icon: "trash", danger: true, onSelect: () => setDialog("delete") },
|
|
326
333
|
];
|
|
@@ -333,7 +340,7 @@ function ChatView({ name, inst, lease, navKey, exec, awaiting }) {
|
|
|
333
340
|
const sub = html`<span class="status"><span class=${`dot ${cls}`} aria-hidden="true"></span>${statusLabel(inst, exec, awaiting)}</span>
|
|
334
341
|
${inst.model ? html`<button type="button" class="hd-chip" title=${t("chat.chipModel", modelText(inst))} aria-label=${t("chat.chipModel", modelText(inst))} onClick=${() => pick("model")}>${modelText(inst)}</button>` : null}
|
|
335
342
|
${inst.effort ? html`<button type="button" class="hd-chip" title=${t("chat.chipEffort", inst.effort)} aria-label=${t("chat.chipEffort", inst.effort)} onClick=${() => pick("effort")}>${inst.effort}</button>` : null}`;
|
|
336
|
-
return html`<div class=${`panel p-chat${wrap ? " wrap-code" : ""}`} ref=${view}>
|
|
343
|
+
return html`<div class=${`panel p-chat${wrap ? " wrap-code" : ""}`} data-width=${wide ? "full" : undefined} ref=${view}>
|
|
337
344
|
<${PanelHeader} title=${headerName(namesOf(inst || { name }, names.primary))} sub=${sub} nav=${guest ? null : html`<${InstanceSwitch} name=${name} current="chat" />`} notice=${html`<${ChatEffects} name=${name} lease=${lease} element=${effectSurface} store=${store} open=${dialog === "effects"} onPlayback=${publishCanvasPlayback} onClose=${() => setDialog(null)} active=${fxHere} />`}>${fxHere ? html`<${ChatEffectPosters} name=${name} lease=${lease} element=${effectSurface} />` : null}<${CardMenu} card=${card} /><${HeaderTools} navKey=${navKey} />${guest ? null : html`<${Menu} items=${items} label=${t("app.more")} />`}</${PanelHeader}>
|
|
338
345
|
<${ChatCard} key=${name} name=${name} card=${card} />
|
|
339
346
|
<${AgentWidget} slot="chat_header" widget=${widgetFor(name, "chat_header")} binds=${instanceBinds(inst, exec, awaiting)} />
|
package/dist/ui/shared/app.css
CHANGED
|
@@ -573,6 +573,9 @@ html:has(.shell), html:has(.shell) body { overflow: hidden; }
|
|
|
573
573
|
the message's overflow-wrap: anywhere that is one character wide (the #1672 status line's time, one glyph per line). */
|
|
574
574
|
.msg .time { font-family: var(--font-mono); white-space: nowrap; flex: none; }
|
|
575
575
|
.msg.user { align-self: flex-end; max-width: min(80%, 620px); }
|
|
576
|
+
/* Full width (the header's ⋯ menu, this browser's choice): the thread and the composer take the panel, less their own
|
|
577
|
+
padding, for wide tables and code; a person's bubble keeps its cap (above). A phone's column is the screen already. */
|
|
578
|
+
@media (min-width: 900px) { .p-chat[data-width="full"] .thread, .p-chat[data-width="full"] .dock-col { max-width: none; } }
|
|
576
579
|
.msg.user .meta { justify-content: flex-end; }
|
|
577
580
|
/* #1773: the bubble hugs its text. The message is as wide as its widest child — usually the meta row (sender, a time
|
|
578
581
|
such as 「昨天 下午03:58」, ticks) — and a block body would stretch to it; fit-content keeps a short message short, on
|
|
@@ -1762,6 +1765,8 @@ strong.org-name { font-size: var(--fs-sm); color: var(--text); }
|
|
|
1762
1765
|
.oo-people-list label { display: flex; align-items: center; gap: 8px; padding: 3px 2px; font-size: var(--fs-xs); color: var(--text); cursor: pointer; overflow-wrap: anywhere; }
|
|
1763
1766
|
.oo-note { font-size: var(--fs-xs); }
|
|
1764
1767
|
.oo-stage { position: relative; flex: 1 1 auto; min-height: 340px; overflow: hidden; background: var(--bg); touch-action: none; user-select: none; -webkit-user-select: none; }
|
|
1768
|
+
/* #2554: clip, not a scroll container — nothing past the edge (a tile's label) can scroll the stage and its controls. */
|
|
1769
|
+
.oo-stage { overflow: clip; }
|
|
1765
1770
|
.oo-canvas { position: absolute; left: 0; top: 0; display: block; image-rendering: pixelated; cursor: grab; touch-action: none; }
|
|
1766
1771
|
.oo-canvas:active { cursor: grabbing; }
|
|
1767
1772
|
.oo-canvas:focus-visible { outline: 2px solid var(--focus); outline-offset: -2px; }
|
|
@@ -1785,6 +1790,16 @@ strong.org-name { font-size: var(--fs-sm); color: var(--text); }
|
|
|
1785
1790
|
.oo-mini-dot.match { fill: var(--focus); }
|
|
1786
1791
|
.oo-tip { position: absolute; left: 0; top: 0; z-index: 5; max-width: 240px; pointer-events: none; padding: 6px 10px; font-size: var(--fs-xs); line-height: 1.4;
|
|
1787
1792
|
color: var(--text); background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-md); box-shadow: var(--shadow-2); overflow-wrap: anywhere; }
|
|
1793
|
+
/* #2554: All offices — each tile's label (office name and head count) just above the tile, placed by the page (left, top,
|
|
1794
|
+
max-width through the CSSOM); a button that opens the office, the lobby's a plain chip. */
|
|
1795
|
+
.oo-tile-label { position: absolute; left: 0; top: 0; z-index: 3; transform: translateY(calc(-100% - 4px)); max-width: 240px; overflow: hidden; text-overflow: ellipsis;
|
|
1796
|
+
white-space: nowrap; padding: 2px 8px; font: inherit; font-size: var(--fs-xs); font-weight: var(--fw-semibold); line-height: 18px; color: var(--text);
|
|
1797
|
+
background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-sm); }
|
|
1798
|
+
button.oo-tile-label { cursor: pointer; }
|
|
1799
|
+
button.oo-tile-label:hover { border-color: var(--accent); }
|
|
1800
|
+
button.oo-tile-label:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
|
|
1801
|
+
.oo-tile-label.lobby { color: var(--text-2); }
|
|
1802
|
+
.oo-lobby-tile { font-size: var(--fs-xs); color: var(--text-2); }
|
|
1788
1803
|
/* The desks as a list: for a screen reader, and shown (over the stage's corner) while the keyboard is in it. */
|
|
1789
1804
|
.oo-desks { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; list-style: none; }
|
|
1790
1805
|
.oo-desks:focus-within { position: absolute; z-index: 7; left: var(--s-2); bottom: var(--s-2); width: auto; height: auto; max-height: 40%; margin: 0; padding: var(--s-1); clip: auto; overflow-y: auto;
|