@songsid/agend 2.3.1 → 2.3.2-beta.1
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/authority/authority-state.d.ts +4 -0
- package/dist/authority/authority-state.js +16 -10
- package/dist/authority/authority-state.js.map +1 -1
- package/dist/authority/session-authorizations.d.ts +7 -1
- package/dist/authority/session-authorizations.js +6 -4
- package/dist/authority/session-authorizations.js.map +1 -1
- package/dist/authority/web-authority.d.ts +19 -6
- package/dist/authority/web-authority.js +22 -8
- package/dist/authority/web-authority.js.map +1 -1
- package/dist/channel/adapters/discord.d.ts +2 -0
- package/dist/channel/adapters/discord.js +33 -3
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/channel/mcp-tools.js +5 -5
- package/dist/channel/mcp-tools.js.map +1 -1
- package/dist/channel/types.d.ts +11 -0
- package/dist/config-validator.js +40 -3
- package/dist/config-validator.js.map +1 -1
- package/dist/daemon.d.ts +34 -3
- package/dist/daemon.js +227 -46
- package/dist/daemon.js.map +1 -1
- package/dist/delivery-outbox.d.ts +12 -1
- package/dist/delivery-outbox.js +67 -15
- package/dist/delivery-outbox.js.map +1 -1
- package/dist/doctor.d.ts +3 -0
- package/dist/doctor.js +9 -0
- package/dist/doctor.js.map +1 -1
- package/dist/event-log.d.ts +5 -0
- package/dist/event-log.js +13 -0
- package/dist/event-log.js.map +1 -1
- package/dist/fleet-manager.d.ts +140 -0
- package/dist/fleet-manager.js +414 -29
- package/dist/fleet-manager.js.map +1 -1
- package/dist/fleet-process-handlers.d.ts +1 -0
- package/dist/fleet-process-handlers.js +9 -1
- package/dist/fleet-process-handlers.js.map +1 -1
- package/dist/general-knowledge/skills/web-appearance/SKILL.canvas-worker-off.md +4 -4
- package/dist/general-knowledge/skills/web-appearance/SKILL.md +7 -7
- package/dist/general-knowledge/skills/web-dashboard/SKILL.canvas-worker-off.md +106 -55
- package/dist/general-knowledge/skills/web-dashboard/SKILL.md +107 -56
- package/dist/guest-seen-people.d.ts +38 -0
- package/dist/guest-seen-people.js +78 -0
- package/dist/guest-seen-people.js.map +1 -0
- package/dist/instance-config-impact.js +4 -1
- package/dist/instance-config-impact.js.map +1 -1
- package/dist/instance-lifecycle.d.ts +1 -0
- package/dist/instance-lifecycle.js +16 -1
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/locale.js +38 -10
- package/dist/locale.js.map +1 -1
- package/dist/mac-auto-restore.d.ts +117 -0
- package/dist/mac-auto-restore.js +294 -0
- package/dist/mac-auto-restore.js.map +1 -0
- package/dist/outbound-schemas.d.ts +2 -0
- package/dist/outbound-schemas.js +2 -0
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/public-web-gateway.js +9 -1
- package/dist/public-web-gateway.js.map +1 -1
- package/dist/settings-api.d.ts +26 -0
- package/dist/settings-api.js +107 -1
- package/dist/settings-api.js.map +1 -1
- package/dist/settings-change.js +37 -3
- package/dist/settings-change.js.map +1 -1
- package/dist/settings-effect.js +5 -2
- package/dist/settings-effect.js.map +1 -1
- package/dist/start-attempt.d.ts +9 -5
- package/dist/start-attempt.js +27 -8
- package/dist/start-attempt.js.map +1 -1
- package/dist/start-failure-chat.d.ts +4 -0
- package/dist/start-failure-chat.js +23 -6
- package/dist/start-failure-chat.js.map +1 -1
- package/dist/start-failure-notice.d.ts +4 -0
- package/dist/start-failure-notice.js +8 -0
- package/dist/start-failure-notice.js.map +1 -1
- package/dist/start-failure.d.ts +25 -0
- package/dist/start-failure.js +75 -2
- package/dist/start-failure.js.map +1 -1
- package/dist/turn-reply-guard.d.ts +25 -0
- package/dist/turn-reply-guard.js +111 -12
- package/dist/turn-reply-guard.js.map +1 -1
- package/dist/types.d.ts +18 -1
- package/dist/ui/backend-signin.js +5 -2
- package/dist/ui/chat-ambient.js +1 -1
- package/dist/ui/chat-effects.js +4 -4
- package/dist/ui/chat-render.js +5 -2
- package/dist/ui/chat-strings.js +2 -2
- package/dist/ui/chat-thread.js +2 -2
- package/dist/ui/first-run.js +2 -2
- package/dist/ui/fleet-canvas-view.js +114 -0
- package/dist/ui/fleet-canvas.js +15 -77
- package/dist/ui/fleet-office-editor.js +80 -5
- package/dist/ui/fleet-office-layout.js +197 -0
- package/dist/ui/fleet-office-model.js +6 -3
- package/dist/ui/fleet-office-pack.d.ts +39 -0
- package/dist/ui/fleet-office-pack.js +240 -29
- package/dist/ui/fleet-office-skin.js +130 -21
- package/dist/ui/fleet-office-store.js +75 -4
- package/dist/ui/fleet-office-view.js +154 -0
- package/dist/ui/fleet-office-zones.js +95 -33
- package/dist/ui/fleet-office.js +530 -94
- package/dist/ui/fleet-usage.js +7 -6
- package/dist/ui/panel-chat.js +1 -1
- package/dist/ui/panel-details.js +2 -2
- package/dist/ui/panel-fleet.js +11 -77
- package/dist/ui/panel-log.js +2 -2
- package/dist/ui/panel-settings.js +578 -163
- package/dist/ui/search-dialog.js +26 -7
- package/dist/ui/settings-appearance.js +82 -29
- package/dist/ui/settings-apply.js +42 -7
- package/dist/ui/settings-dialogs.js +68 -22
- package/dist/ui/settings-draft.js +51 -0
- package/dist/ui/settings-guests.js +244 -78
- package/dist/ui/settings-learn.js +2 -2
- package/dist/ui/settings-model.js +2 -0
- package/dist/ui/settings-registry.js +142 -0
- package/dist/ui/settings-strings.js +102 -44
- package/dist/ui/settings-web-authority.js +34 -23
- package/dist/ui/shared/app-i18n.js +14 -0
- package/dist/ui/shared/app-nav.js +10 -5
- package/dist/ui/shared/app-prefs.js +80 -2
- package/dist/ui/shared/app-route.js +39 -5
- package/dist/ui/shared/app-shell.js +59 -4
- package/dist/ui/shared/app-stream.js +2 -1
- package/dist/ui/shared/app.css +242 -17
- package/dist/ui/shared/app.js +3 -0
- package/dist/ui/shared/effect-stock-trust.js +1 -1
- package/dist/ui/shared/identity-color.js +4 -4
- package/dist/ui/shared/signin.js +2 -2
- package/dist/ui/shared/ui-dialog.js +6 -1
- package/dist/ui/shared/ui-icons.js +2 -0
- package/dist/ui/shared/web-authorize.js +13 -12
- package/dist/web-api.d.ts +8 -2
- package/dist/web-api.js +160 -5
- package/dist/web-api.js.map +1 -1
- package/dist/web-auth.js +7 -1
- package/dist/web-auth.js.map +1 -1
- package/dist/web-experimental.d.ts +1 -1
- package/dist/web-experimental.js +1 -1
- package/dist/web-experimental.js.map +1 -1
- package/dist/web-mod-agent.js +2 -2
- package/dist/web-mod-agent.js.map +1 -1
- package/dist/web-office-skins.d.ts +108 -0
- package/dist/web-office-skins.js +298 -0
- package/dist/web-office-skins.js.map +1 -0
- package/dist/web-office-store.d.ts +8 -2
- package/dist/web-office-store.js +37 -7
- package/dist/web-office-store.js.map +1 -1
- package/dist/web-settings-patch.d.ts +6 -0
- package/dist/web-settings-patch.js +61 -4
- package/dist/web-settings-patch.js.map +1 -1
- package/dist/web-shell-routes.d.ts +4 -2
- package/dist/web-shell-routes.js +17 -7
- package/dist/web-shell-routes.js.map +1 -1
- package/dist/working-recovery.d.ts +104 -0
- package/dist/working-recovery.js +263 -0
- package/dist/working-recovery.js.map +1 -0
- package/dist/working-state-journal.d.ts +3 -1
- package/dist/working-state-journal.js +15 -2
- package/dist/working-state-journal.js.map +1 -1
- package/package.json +1 -1
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
// "studio" (the zones layout: work desks, a lounge, an alert corner and a nap corner; people walk to the area for their
|
|
5
5
|
// state). Every drawing call goes through the OfficeSkin interface below, so a skin pack (pictures from an artist,
|
|
6
6
|
// format v1 in fleet-office-pack.js) can replace any part; a part a pack does not supply falls back to the built-in
|
|
7
|
-
// drawing.
|
|
7
|
+
// drawing. An admin uploads a pack (#2513, the host keeps it: src/web-office-skins.ts); loadPackSkin() reads it back —
|
|
8
|
+
// its manifest checked here again (checkSkinPack), its pictures by their content ids — and spriteSkin() draws it.
|
|
8
9
|
//
|
|
9
10
|
// ── The interface ──
|
|
10
11
|
//
|
|
@@ -22,16 +23,25 @@
|
|
|
22
23
|
// @property {(ctx, desk, look, env) => void} drawDeskFront desks: in front — the desk's front, its sign, folders, name tag
|
|
23
24
|
// @property {(ctx, L, env) => void} [drawBackground] zones: the whole 1280×720 background, areas and their names
|
|
24
25
|
// @property {(ctx, item, active, ms, env) => void} [drawFurniture] zones: one furniture item (active: activeWhen holds)
|
|
26
|
+
// @property {(ctx, L, env) => void} [drawForeground] zones: a pack's foreground over the whole 1280×720 scene, above
|
|
27
|
+
// the people, furniture and items in the air, below the crowd counts, name tags and bubbles
|
|
25
28
|
// @property {(ctx, x, y, look, env) => void} [drawTag] zones: a name tag under feet at (x, y)
|
|
29
|
+
// @property {(ctx, area, n, env) => void} [drawCrowd] zones: an area's "+n" (who found no spot in it)
|
|
26
30
|
// @property {(ctx, desk, look, ms, env) => void} drawCharacter the occupant in its pose at time `ms` (0: still); in
|
|
27
|
-
// zones `desk` is the character's box at its current position (its feet at desk.seat)
|
|
31
|
+
// zones `desk` is the character's box at its current position (its feet at desk.seat); walking, `look.heading` is
|
|
32
|
+
// the current step's { dx, dy }
|
|
33
|
+
// @property {(look, at) => {x, y, w, h}} [charBox] zones: the box drawCharacter covers with the feet at `at`
|
|
34
|
+
// ({ x, y, walking, heading }): what hover and click hit (fleet-office-zones.js personAt)
|
|
28
35
|
// @property {(ctx, prop, ms, env) => void} drawProp a traffic item in the air: { kind, hue, x, y, t }
|
|
29
36
|
// @property {(ctx, x, y, text, env, lift?) => void} drawBubble a speech bubble whose tail points at (x, y), its box `lift` px higher
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
37
|
+
// @property {(env) => string} matte #2540: the colour around the scene (the Office fills the panel; the scene is 16:9)
|
|
38
|
+
// @property {object|null} [backdrop] a pack's loaded background picture (the page samples its edges for the matte)
|
|
39
|
+
// The desks layout's back and front layers change only when the fleet (or the zoom) does: the renderer keeps them as
|
|
40
|
+
// pictures and draws the characters, props and bubbles each frame. In zones the background is kept; furniture, people (walking
|
|
41
|
+
// along the pack's waypoints), tags, props and bubbles are drawn each frame, furniture by depth around the people (depth "y":
|
|
42
|
+
// among them by base y), then the foreground, then the crowd counts, tags and bubbles.
|
|
33
43
|
import { frameAt, nameHash } from "./fleet-office-model.js";
|
|
34
|
-
import { CHAR_DEPTH, LOGICAL } from "./fleet-office-pack.js";
|
|
44
|
+
import { CHAR_DEPTH, LOGICAL, checkSkinPack, own, packImages, poseStrip, stripBox } from "./fleet-office-pack.js";
|
|
35
45
|
|
|
36
46
|
export { nameHash };
|
|
37
47
|
|
|
@@ -271,21 +281,58 @@ function drawBubble(ctx, x, y, text, env, lift = 0) {
|
|
|
271
281
|
ctx.restore();
|
|
272
282
|
}
|
|
273
283
|
|
|
284
|
+
/** A zones name tag's widest text (logical units): longer names are cut with an ellipsis. */
|
|
285
|
+
export const TAG_TEXT_W = 84;
|
|
286
|
+
const TAG_FONT = "600 10px system-ui, sans-serif";
|
|
287
|
+
/** The box a name tag under feet at (x, y) takes (what drawTag draws; bubbles keep clear of it, #2513). */
|
|
288
|
+
export function tagBox(ctx, x, y, label) {
|
|
289
|
+
ctx.save();
|
|
290
|
+
ctx.font = TAG_FONT;
|
|
291
|
+
const t = fitText(ctx, label, TAG_TEXT_W), w = Math.ceil((ctx.measureText ? ctx.measureText(t).width : t.length * 6) + 10);
|
|
292
|
+
ctx.restore();
|
|
293
|
+
return { x: Math.round(x - w / 2), y: y + 3, w, h: 14, text: t };
|
|
294
|
+
}
|
|
274
295
|
/** Zones: a name tag under someone's feet at (x, y). */
|
|
275
296
|
function drawTag(ctx, x, y, look, env) {
|
|
276
297
|
const { pal } = env;
|
|
277
298
|
ctx.save();
|
|
278
299
|
if (look.dim) ctx.globalAlpha = 0.35;
|
|
279
|
-
ctx.font =
|
|
280
|
-
const t = fitText(ctx, look.label,
|
|
300
|
+
ctx.font = TAG_FONT;
|
|
301
|
+
const t = fitText(ctx, look.label, TAG_TEXT_W), w = Math.ceil((ctx.measureText ? ctx.measureText(t).width : t.length * 6) + 10);
|
|
281
302
|
ctx.fillStyle = pal.surface; ctx.fillRect(Math.round(x - w / 2), y + 3, w, 14);
|
|
282
303
|
ctx.strokeStyle = look.match ? pal.focus : pal.border; ctx.lineWidth = look.match ? 2 : 1; ctx.strokeRect(Math.round(x - w / 2) + 0.5, y + 3.5, w - 1, 13);
|
|
283
304
|
ctx.fillStyle = pal.text; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText(t, x, y + 10.5);
|
|
284
305
|
ctx.restore();
|
|
285
306
|
}
|
|
286
307
|
|
|
308
|
+
/**
|
|
309
|
+
* Zones: how many more want an area than it has room for — a chip in its bottom-right corner, inside its rect, so it
|
|
310
|
+
* never covers the area's name (#2513: "+42" over the nap corner's title).
|
|
311
|
+
*/
|
|
312
|
+
export function crowdChip(ctx, area, n) {
|
|
313
|
+
ctx.save();
|
|
314
|
+
ctx.font = "700 12px system-ui, sans-serif";
|
|
315
|
+
const text = `+${n}`, w = Math.ceil((ctx.measureText ? ctx.measureText(text).width : text.length * 7) + 12), h = 18, r = area.rect;
|
|
316
|
+
ctx.restore();
|
|
317
|
+
return { x: Math.round(r.x + r.w - w - 6), y: Math.round(r.y + r.h - h - 6), w, h, text };
|
|
318
|
+
}
|
|
319
|
+
function drawCrowd(ctx, area, n, env) {
|
|
320
|
+
if (!n) return;
|
|
321
|
+
const { pal } = env, b = crowdChip(ctx, area, n);
|
|
322
|
+
ctx.save();
|
|
323
|
+
ctx.fillStyle = pal.surface; ctx.fillRect(b.x, b.y, b.w, b.h);
|
|
324
|
+
ctx.strokeStyle = pal.border; ctx.lineWidth = 1; ctx.strokeRect(b.x + 0.5, b.y + 0.5, b.w - 1, b.h - 1);
|
|
325
|
+
ctx.fillStyle = pal.text; ctx.font = "700 12px system-ui, sans-serif"; ctx.textAlign = "center"; ctx.textBaseline = "middle";
|
|
326
|
+
ctx.fillText(b.text, b.x + b.w / 2, b.y + b.h / 2 + 0.5);
|
|
327
|
+
ctx.restore();
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** The built-in character in zones covers this box around its feet (as zonesLayout's desk box). */
|
|
331
|
+
const charBox = (look, at) => ({ x: at.x - 24, y: at.y - 52, w: 48, h: 66 });
|
|
332
|
+
/** #2540: Desks' matte (around the scene): the page's background token, which the desks' floor is too (Studio has its own). */
|
|
333
|
+
const matte = (env) => env.pal.bg;
|
|
287
334
|
/** @type {OfficeSkin} */
|
|
288
|
-
export const pixelSkin = { id: "pixel", drawFloor, drawRoom, drawDeskBack, drawCharacter, drawDeskFront, drawProp, drawBubble, drawTag };
|
|
335
|
+
export const pixelSkin = { id: "pixel", drawFloor, drawRoom, drawDeskBack, drawCharacter, drawDeskFront, drawProp, drawBubble, drawTag, drawCrowd, charBox, matte };
|
|
289
336
|
|
|
290
337
|
// ── The built-in studio: the zones layout, drawn procedurally ──
|
|
291
338
|
|
|
@@ -346,8 +393,6 @@ function drawBackground(ctx, L, env) {
|
|
|
346
393
|
ctx.strokeStyle = pal.border; ctx.lineWidth = 2; ctx.strokeRect(r.x + 1, r.y + 1, r.w - 2, r.h - 2);
|
|
347
394
|
ctx.fillStyle = pal.text; ctx.font = "600 16px system-ui, sans-serif"; ctx.textBaseline = "top"; ctx.textAlign = "left";
|
|
348
395
|
ctx.fillText(fitText(ctx, env.labels.areas[a.name] || a.name, r.w - 60), r.x + 12, r.y + 10);
|
|
349
|
-
const crowd = L.crowd && L.crowd.get(a.name);
|
|
350
|
-
if (crowd) { ctx.fillStyle = pal.text2; ctx.textAlign = "right"; ctx.fillText(`+${crowd}`, r.x + r.w - 12, r.y + 10); }
|
|
351
396
|
ctx.restore();
|
|
352
397
|
}
|
|
353
398
|
}
|
|
@@ -396,9 +441,11 @@ function drawFurniture(ctx, f, active, ms, env) {
|
|
|
396
441
|
}
|
|
397
442
|
}
|
|
398
443
|
/** @type {OfficeSkin} */
|
|
399
|
-
|
|
444
|
+
/** #2540: the studio's matte is its hall floor (the scene's edge), so the panel around the scene reads as more of it. */
|
|
445
|
+
const studioMatte = (env) => (ZONE_FLOOR[env.mode] || ZONE_FLOOR.dark).hall;
|
|
446
|
+
export const studioSkin = { ...pixelSkin, id: "studio", pack: STUDIO_PACK, drawBackground, drawFurniture, matte: studioMatte };
|
|
400
447
|
|
|
401
|
-
// ── Skin packs
|
|
448
|
+
// ── Skin packs ──
|
|
402
449
|
|
|
403
450
|
/**
|
|
404
451
|
* An OfficeSkin from a checked pack (fleet-office-pack.js checkSkinPack) and its loaded pictures (`images`: src → a
|
|
@@ -406,20 +453,29 @@ export const studioSkin = { ...pixelSkin, id: "studio", pack: STUDIO_PACK, drawB
|
|
|
406
453
|
*/
|
|
407
454
|
export function spriteSkin(pack, images, base = pixelSkin) {
|
|
408
455
|
const img = (src) => (src && images.get(src)) || null;
|
|
409
|
-
|
|
456
|
+
// A strip's frame at its displayed size (dw × dh, unsmoothed), its anchor on (x, y); mirrored about the anchor.
|
|
457
|
+
const strip = (ctx, st, ms, x, y, frame = null, mirror = false) => {
|
|
410
458
|
const im = img(st && st.src);
|
|
411
459
|
if (!im) return false;
|
|
412
|
-
const f = frame ?? frameAt(ms, st.frames, st.fps),
|
|
460
|
+
const f = frame ?? frameAt(ms, st.frames, st.fps), b = stripBox(st, x, y, mirror);
|
|
413
461
|
ctx.save();
|
|
414
462
|
ctx.imageSmoothingEnabled = false;
|
|
415
|
-
ctx.drawImage(im, f * st.w, 0, st.w, st.h,
|
|
463
|
+
if (mirror) { ctx.translate(b.x + b.w, b.y); ctx.scale(-1, 1); ctx.drawImage(im, f * st.w, 0, st.w, st.h, 0, 0, b.w, b.h); }
|
|
464
|
+
else ctx.drawImage(im, f * st.w, 0, st.w, st.h, b.x, b.y, b.w, b.h);
|
|
416
465
|
ctx.restore();
|
|
417
466
|
return true;
|
|
418
467
|
};
|
|
468
|
+
const setOf = (look) => own(pack.characters, own(pack.byInstance, look.name)) || own(pack.characters, "default");
|
|
469
|
+
/** The strip a look shows (its pose; walking, by its heading), when its picture is loaded. */
|
|
470
|
+
const shown = (look, walking = look.pose === "walk") => {
|
|
471
|
+
const ps = poseStrip(setOf(look), walking ? "walk" : look.pose, look.heading);
|
|
472
|
+
return ps && img(ps.strip.src) ? ps : null;
|
|
473
|
+
};
|
|
419
474
|
return {
|
|
420
475
|
...base,
|
|
421
476
|
id: pack.id,
|
|
422
477
|
pack,
|
|
478
|
+
backdrop: img(pack.background),
|
|
423
479
|
drawRoom(ctx, room, env) {
|
|
424
480
|
const floor = img(pack.room.floor), wall = img(pack.room.wall);
|
|
425
481
|
if (!floor) { base.drawRoom(ctx, room, env); return; }
|
|
@@ -448,6 +504,11 @@ export function spriteSkin(pack, images, base = pixelSkin) {
|
|
|
448
504
|
if (!im) { drawBackground(ctx, L, env); return; }
|
|
449
505
|
ctx.save(); ctx.imageSmoothingEnabled = false; ctx.drawImage(im, 0, 0, LOGICAL.w, LOGICAL.h); ctx.restore();
|
|
450
506
|
},
|
|
507
|
+
drawForeground(ctx, L, env) {
|
|
508
|
+
const im = img(pack.foreground);
|
|
509
|
+
if (!im) return;
|
|
510
|
+
ctx.save(); ctx.imageSmoothingEnabled = false; ctx.drawImage(im, 0, 0, LOGICAL.w, LOGICAL.h); ctx.restore();
|
|
511
|
+
},
|
|
451
512
|
drawFurniture(ctx, f, active, ms, env) {
|
|
452
513
|
if (f.shape) { drawFurniture(ctx, f, active, ms, env); return; }
|
|
453
514
|
// Active: its strip (or activeSprite) plays; otherwise its first frame holds.
|
|
@@ -456,19 +517,67 @@ export function spriteSkin(pack, images, base = pixelSkin) {
|
|
|
456
517
|
},
|
|
457
518
|
drawCharacter(ctx, d, look, ms, env) {
|
|
458
519
|
if (look.pose === "away") return;
|
|
459
|
-
const
|
|
520
|
+
const ps = shown(look);
|
|
460
521
|
ctx.save();
|
|
461
522
|
if (look.dim) ctx.globalAlpha = 0.35;
|
|
462
|
-
const done = !!
|
|
523
|
+
const done = !!ps && strip(ctx, ps.strip, ms, d.seat.x, d.seat.y, null, ps.mirror);
|
|
463
524
|
ctx.restore();
|
|
464
525
|
if (!done) base.drawCharacter(ctx, d, look, ms, env);
|
|
465
526
|
},
|
|
466
|
-
|
|
527
|
+
charBox(look, at) {
|
|
528
|
+
const ps = shown({ ...look, heading: at.heading }, !!at.walking);
|
|
529
|
+
return ps ? stripBox(ps.strip, at.x, at.y, ps.mirror) : base.charBox(look, at);
|
|
530
|
+
},
|
|
531
|
+
drawProp(ctx, it, ms, env) { if (!strip(ctx, own(pack.props, it.hue), ms, it.x, it.y)) base.drawProp(ctx, it, ms, env); },
|
|
467
532
|
};
|
|
468
533
|
}
|
|
469
534
|
|
|
470
|
-
/** The skins this page
|
|
535
|
+
/** The skins built into this page; an uploaded pack is loaded by loadPackSkin. */
|
|
471
536
|
export const SKINS = new Map([["pixel", pixelSkin], ["studio", studioSkin]]);
|
|
472
|
-
export const
|
|
537
|
+
export const BUILT_IN_SKINS = ["pixel", "studio"];
|
|
538
|
+
/** A skin by id: a built-in one, else a pack loaded here (`packs`: id → skin), else the default (pixel). */
|
|
539
|
+
export const skinFor = (id, packs = null) => SKINS.get(id) || (packs && packs.get(id)) || pixelSkin;
|
|
540
|
+
|
|
541
|
+
const loaded = new Map(); // `${id}@${sha}` → Promise<OfficeSkin | null>
|
|
542
|
+
const MAX_LOADED = 16;
|
|
543
|
+
const defaultImage = (url) => new Promise((resolve) => {
|
|
544
|
+
if (typeof Image !== "function") { resolve(null); return; }
|
|
545
|
+
const im = new Image();
|
|
546
|
+
im.onload = () => resolve(im);
|
|
547
|
+
im.onerror = () => resolve(null);
|
|
548
|
+
im.src = url;
|
|
549
|
+
});
|
|
550
|
+
/**
|
|
551
|
+
* An uploaded pack (GET /ui/office-skins/<id>?v=<sha>) as an OfficeSkin: its manifest checked (null when it is not a
|
|
552
|
+
* pack: the page then draws a built-in style and says so), its pictures loaded from their content ids (one that does
|
|
553
|
+
* not load is drawn by the built-in skin). Kept per id and sha, so a pack is fetched once.
|
|
554
|
+
*/
|
|
555
|
+
export function loadPackSkin(id, sha, { fetch: get = globalThis.fetch, image = defaultImage } = {}) {
|
|
556
|
+
const key = `${id}@${sha}`;
|
|
557
|
+
if (loaded.has(key)) return loaded.get(key);
|
|
558
|
+
const base = `/ui/office-skins/${encodeURIComponent(id)}`;
|
|
559
|
+
const job = (async () => {
|
|
560
|
+
try {
|
|
561
|
+
const r = await get(`${base}?v=${encodeURIComponent(sha)}`, { method: "GET", headers: { "Content-Type": "application/json" } });
|
|
562
|
+
if (!r || !r.ok) return null;
|
|
563
|
+
const d = await r.json();
|
|
564
|
+
const c = checkSkinPack(d && d.manifest);
|
|
565
|
+
if (!c.ok || c.pack.id !== id) return null;
|
|
566
|
+
const files = d.files && typeof d.files === "object" ? d.files : {};
|
|
567
|
+
const images = new Map();
|
|
568
|
+
await Promise.all(packImages(c.pack).map(async (src) => {
|
|
569
|
+
const fid = own(files, src);
|
|
570
|
+
if (typeof fid !== "string" || !/^[0-9a-f]{32}$/.test(fid)) return;
|
|
571
|
+
const im = await image(`${base}/img/${fid}.png`);
|
|
572
|
+
if (im) images.set(src, im);
|
|
573
|
+
}));
|
|
574
|
+
return spriteSkin(c.pack, images);
|
|
575
|
+
} catch { return null; }
|
|
576
|
+
})();
|
|
577
|
+
loaded.set(key, job);
|
|
578
|
+
if (loaded.size > MAX_LOADED) loaded.delete(loaded.keys().next().value);
|
|
579
|
+
job.then((skin) => { if (!skin) loaded.delete(key); }); // a failed read is tried again next time
|
|
580
|
+
return job;
|
|
581
|
+
}
|
|
473
582
|
/** The people's depth in zones (fleet-office-pack.js). */
|
|
474
583
|
export { CHAR_DEPTH };
|
|
@@ -2,12 +2,30 @@
|
|
|
2
2
|
// - the offices people made: the host's (GET / PUT /ui/offices, src/web-office-store.ts — one list for every viewer);
|
|
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
|
-
// office is shown, the auto office's member filter, and its style (skin)
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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.
|
|
7
|
+
// - the skin packs an admin uploaded (#2513: GET /ui/office-skins, POST to upload one, DELETE to remove one;
|
|
8
|
+
// src/web-office-skins.ts). The office editor and the layout editor call saveOffices() with the whole list and the rev
|
|
9
|
+
// they read (a change in between answers 409).
|
|
8
10
|
import { AUTO_ID, filterOk, officesOk } from "./fleet-office-model.js";
|
|
11
|
+
import { fitModeOk, savedOk } from "./fleet-office-view.js";
|
|
9
12
|
|
|
10
|
-
const CHOICE_KEY = "agend_org_office", FILTER_KEY = "agend_org_office_filter", SKIN_KEY = "agend_org_office_skin";
|
|
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
|
+
/** #2540: { "<office>@<style>": [k, cx, cy] (and a 1 after them: a fit) } — the last VIEWS_MAX views, the most recent last. */
|
|
15
|
+
const VIEW_KEY = "agend_org_office_view", VIEWS_MAX = 32;
|
|
16
|
+
/** #2540: { "<office>@<style>": "cover" } — the offices whose fit fills the panel (the rest: "contain"), the last VIEWS_MAX. */
|
|
17
|
+
const FIT_KEY = "agend_org_office_fit";
|
|
18
|
+
const PACK_ID = /^[A-Za-z0-9_-]{1,40}$/;
|
|
19
|
+
/** The host's list of skin packs, checked: [{ id, name, sha }] (anything odd dropped). */
|
|
20
|
+
export function skinListOk(d) {
|
|
21
|
+
const out = [], ids = new Set();
|
|
22
|
+
for (const p of Array.isArray(d && d.packs) ? d.packs.slice(0, 64) : []) {
|
|
23
|
+
if (!p || typeof p.id !== "string" || !PACK_ID.test(p.id) || ids.has(p.id) || typeof p.sha !== "string" || !/^[0-9a-f]{32}$/.test(p.sha)) continue;
|
|
24
|
+
ids.add(p.id);
|
|
25
|
+
out.push({ id: p.id, name: typeof p.name === "string" && p.name.trim() ? p.name.trim().slice(0, 60) : p.id, sha: p.sha });
|
|
26
|
+
}
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
11
29
|
|
|
12
30
|
/** `fetch`: the caller's (a lease's, so an answer after the person moved on is dropped); `storage`: default localStorage. */
|
|
13
31
|
export function createOfficeStore({ fetch: get = globalThis.fetch, storage } = {}) {
|
|
@@ -37,6 +55,31 @@ export function createOfficeStore({ fetch: get = globalThis.fetch, storage } = {
|
|
|
37
55
|
return r.ok ? { ok: true, rev: d.rev, offices: officesOk(d.offices) } : { ok: false, status: r.status, error: d.error || "offices_failed" };
|
|
38
56
|
} catch { return { ok: false, status: 0, error: "offices_failed" }; }
|
|
39
57
|
},
|
|
58
|
+
/** The skin packs the host keeps: { rev, packs: [{ id, name, sha }] }; null when they could not be read. */
|
|
59
|
+
async skins() {
|
|
60
|
+
try {
|
|
61
|
+
const r = await get("/ui/office-skins", { method: "GET", headers });
|
|
62
|
+
if (!r || !r.ok) return null;
|
|
63
|
+
const d = await r.json();
|
|
64
|
+
return { rev: typeof (d && d.rev) === "string" ? d.rev : "", packs: skinListOk(d) };
|
|
65
|
+
} catch { return null; }
|
|
66
|
+
},
|
|
67
|
+
/** Upload a pack (an admin session): { manifest, files: { name: base64 }, name? } → { ok, pack } or { ok: false, status, error, detail }. */
|
|
68
|
+
async uploadSkin(body) {
|
|
69
|
+
try {
|
|
70
|
+
const r = await get("/ui/office-skins", { method: "POST", headers, body: JSON.stringify(body) });
|
|
71
|
+
const d = await r.json().catch(() => ({}));
|
|
72
|
+
return r.ok ? { ok: true, pack: d.pack, rev: d.rev } : { ok: false, status: r.status, error: d.error || "skin_failed", detail: d.detail || "" };
|
|
73
|
+
} catch { return { ok: false, status: 0, error: "skin_failed", detail: "" }; }
|
|
74
|
+
},
|
|
75
|
+
/** Remove a pack (an admin session). */
|
|
76
|
+
async deleteSkin(id) {
|
|
77
|
+
try {
|
|
78
|
+
const r = await get(`/ui/office-skins/${encodeURIComponent(id)}`, { method: "DELETE", headers });
|
|
79
|
+
const d = await r.json().catch(() => ({}));
|
|
80
|
+
return r.ok ? { ok: true } : { ok: false, status: r.status, error: d.error || "skin_failed" };
|
|
81
|
+
} catch { return { ok: false, status: 0, error: "skin_failed" }; }
|
|
82
|
+
},
|
|
40
83
|
/** The office shown here: an id (one the host no longer has falls back to the auto office where it is used). */
|
|
41
84
|
choice: () => { const v = read(CHOICE_KEY); return typeof v === "string" && v && v.length <= 40 ? v : AUTO_ID; },
|
|
42
85
|
saveChoice: (id) => write(CHOICE_KEY, String(id)),
|
|
@@ -45,5 +88,33 @@ export function createOfficeStore({ fetch: get = globalThis.fetch, storage } = {
|
|
|
45
88
|
/** The whole fleet's style here: a skin id ("pixel": desks, "studio": zones); an unknown one is the default where used. */
|
|
46
89
|
skin: () => { const v = read(SKIN_KEY); return typeof v === "string" && /^[A-Za-z0-9_-]{1,40}$/.test(v) ? v : "pixel"; },
|
|
47
90
|
saveSkin: (id) => write(SKIN_KEY, String(id)),
|
|
91
|
+
/** Whether paused and stopped people are left out of the Office here (default: shown). */
|
|
92
|
+
hideAsleep: () => read(ASLEEP_KEY) === "1",
|
|
93
|
+
saveHideAsleep: (v) => write(ASLEEP_KEY, v ? "1" : "0"),
|
|
94
|
+
/** The view kept for `key` (an office and its style): { k, cx, cy } (fleet-office-view.js savedOf), or null. */
|
|
95
|
+
view: (key) => {
|
|
96
|
+
try { const m = JSON.parse(read(VIEW_KEY) || "{}"); const a = m && Object.prototype.hasOwnProperty.call(m, key) ? m[key] : null;
|
|
97
|
+
return Array.isArray(a) ? savedOk({ k: a[0], cx: a[1], cy: a[2], fit: a[3] === 1 }) : null; } catch { return null; }
|
|
98
|
+
},
|
|
99
|
+
saveView: (key, v) => {
|
|
100
|
+
const ok = savedOk(v);
|
|
101
|
+
if (!ok || typeof key !== "string" || key.length > 100) return;
|
|
102
|
+
let m = {};
|
|
103
|
+
try { const o = JSON.parse(read(VIEW_KEY) || "{}"); if (o && typeof o === "object" && !Array.isArray(o)) m = o; } catch { m = {}; }
|
|
104
|
+
const r = (n) => Math.round(n * 1000) / 1000;
|
|
105
|
+
const next = Object.entries(m).filter(([k2, a]) => k2 !== key && Array.isArray(a)).slice(-(VIEWS_MAX - 1));
|
|
106
|
+
write(VIEW_KEY, JSON.stringify(Object.fromEntries([...next, [key, [r(ok.k), r(ok.cx), r(ok.cy), ...(ok.fit ? [1] : [])]]])));
|
|
107
|
+
},
|
|
108
|
+
/** The fit mode kept for `key`: "contain" (the default) or "cover" (Fill). */
|
|
109
|
+
fitMode: (key) => {
|
|
110
|
+
try { const m = JSON.parse(read(FIT_KEY) || "{}"); return fitModeOk(m && Object.prototype.hasOwnProperty.call(m, key) ? m[key] : null); } catch { return "contain"; }
|
|
111
|
+
},
|
|
112
|
+
saveFitMode: (key, mode) => {
|
|
113
|
+
if (typeof key !== "string" || key.length > 100) return;
|
|
114
|
+
let m = {};
|
|
115
|
+
try { const o = JSON.parse(read(FIT_KEY) || "{}"); if (o && typeof o === "object" && !Array.isArray(o)) m = o; } catch { m = {}; }
|
|
116
|
+
const next = Object.entries(m).filter(([k2, v]) => k2 !== key && v === "cover").slice(-(VIEWS_MAX - 1));
|
|
117
|
+
write(FIT_KEY, JSON.stringify(Object.fromEntries(fitModeOk(mode) === "cover" ? [...next, [key, "cover"]] : next)));
|
|
118
|
+
},
|
|
48
119
|
};
|
|
49
120
|
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// #2540: the Office view's canvas controls — the pure part (fleet-office.js applies it). The office fills the Fleet
|
|
2
|
+
// panel; its scene (the zones' 1280×720 logical canvas, or the desks layout) is panned and zoomed like the org chart's
|
|
3
|
+
// canvas (fleet-canvas-view.js: the same view { k, x, y }, gestures, wheel and keys), with these of its own:
|
|
4
|
+
//
|
|
5
|
+
// - Pixel levels for the steps. + and − step to the next pixel level (one logical unit a whole number of device px:
|
|
6
|
+
// k·dpr = 1, 2, 3, …; below one device px a unit by 1.25); the canvas always draws unsmoothed (nearest neighbour).
|
|
7
|
+
// The wheel and a pinch zoom continuously, and Fit takes whatever scale fills the panel.
|
|
8
|
+
// - Fit: two modes, kept per office (fleet-office-store.js). "contain" (the default): the largest scale at which the
|
|
9
|
+
// whole scene shows — non-integer, so the scene fills the panel's width or height. "cover" (Fill): the smallest
|
|
10
|
+
// scale that fills both; what overflows is cropped and stays a pan away. Either is centred.
|
|
11
|
+
// - The view never loses the scene (clampPan): along an axis where the scene is larger than the panel it covers the
|
|
12
|
+
// panel (no gap at an edge); where it is smaller it stays wholly inside.
|
|
13
|
+
//
|
|
14
|
+
// Around the scene is a matte: the pack's `matte` (fleet-office-pack.js), else the colour of the background's edge
|
|
15
|
+
// pixels (sampled once per style: edgeMatte), else the skin's own (Studio's hall floor; Desks': the dashboard's
|
|
16
|
+
// background). It paints the whole canvas outside the scene (and the stage behind it), never the page's grey.
|
|
17
|
+
import { clampK, pinchTo, zoomTo } from "./fleet-canvas-view.js";
|
|
18
|
+
import { CANVAS_MAX } from "./fleet-office-model.js";
|
|
19
|
+
|
|
20
|
+
/** The zoom's range, in CSS px per logical unit. */
|
|
21
|
+
export const OK_MIN = 0.1, OK_MAX = 8;
|
|
22
|
+
const EPS = 1e-6;
|
|
23
|
+
const clampO = (k) => clampK(k, OK_MIN, OK_MAX);
|
|
24
|
+
/** The highest pixel level within the range at this ratio (at least 1). */
|
|
25
|
+
const topLevel = (dpr) => Math.max(1, Math.floor(OK_MAX * dpr + EPS));
|
|
26
|
+
|
|
27
|
+
/** `k` on its pixel level: k·dpr rounded to a whole number when it is 1 or more; as it is (in range) below that. */
|
|
28
|
+
export function snapK(k, dpr = 1) {
|
|
29
|
+
const c = clampO(k), n = c * dpr;
|
|
30
|
+
if (n < 1 - EPS) return c;
|
|
31
|
+
return Math.min(topLevel(dpr), Math.max(1, Math.round(n))) / dpr;
|
|
32
|
+
}
|
|
33
|
+
/** Whether `k` is a pixel level (a whole number of device px per logical unit). */
|
|
34
|
+
export const isPixelK = (k, dpr = 1) => k * dpr >= 1 - EPS && Math.abs(k * dpr - Math.round(k * dpr)) < 1e-6;
|
|
35
|
+
/** One step in (`dir` > 0) or out: the next pixel level; below one device pixel a unit, by 1.25. */
|
|
36
|
+
export function stepK(k, dir, dpr = 1) {
|
|
37
|
+
const n = k * dpr;
|
|
38
|
+
if (dir > 0) {
|
|
39
|
+
if (n >= 1 - EPS) return Math.min(topLevel(dpr), Math.floor(n + EPS) + 1) / dpr;
|
|
40
|
+
return n * 1.25 >= 1 - EPS ? 1 / dpr : clampO(k * 1.25);
|
|
41
|
+
}
|
|
42
|
+
if (n > 1 + EPS) return Math.max(1, Math.ceil(n - EPS) - 1) / dpr;
|
|
43
|
+
return clampO(k * 0.8);
|
|
44
|
+
}
|
|
45
|
+
/** The view at scale `k` (in range, continuous: the wheel's) about the viewport point (cx, cy). */
|
|
46
|
+
export const zoomOffice = (v, k, cx, cy) => zoomTo(v, clampO(k), cx, cy);
|
|
47
|
+
/** A pinch step: `raw` the zoom the pinch has reached (continuous); the world point under the old midpoint follows. */
|
|
48
|
+
export const pinchOffice = (v, from, to, raw) => pinchTo(v, from, to, clampO(raw));
|
|
49
|
+
|
|
50
|
+
/** The fit modes: "contain" (the whole scene shows) and "cover" (the panel is filled, the overflow cropped). */
|
|
51
|
+
export const FIT_MODES = ["contain", "cover"];
|
|
52
|
+
export const fitModeOk = (m) => (m === "cover" ? "cover" : "contain");
|
|
53
|
+
/** The scale at which a `w`×`h` scene fits a `size` viewport: contain (the whole scene) or cover (the whole panel). */
|
|
54
|
+
export function fitK(w, h, size, mode = "contain", margin = 0) {
|
|
55
|
+
const sx = (size.w - 2 * margin) / Math.max(1, w), sy = (size.h - 2 * margin) / Math.max(1, h);
|
|
56
|
+
return clampO(fitModeOk(mode) === "cover" ? Math.max(sx, sy) : Math.min(sx, sy));
|
|
57
|
+
}
|
|
58
|
+
/** The scene at fitK, centred. */
|
|
59
|
+
export function fitScene(w, h, size, mode = "contain", margin = 0) {
|
|
60
|
+
const k = fitK(w, h, size, mode, margin);
|
|
61
|
+
return { k, x: (size.w - w * k) / 2, y: (size.h - h * k) / 2 };
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* The desks layout's scale when it opens: the pixel level nearest one CSS px a unit (1 on an ordinary or a 2× screen,
|
|
65
|
+
* 4/3 at 150 %, 0.8 at 125 %); the layout wraps at the panel's width over it, so it opens as wide as the panel.
|
|
66
|
+
*/
|
|
67
|
+
export const desksK = (dpr = 1) => Math.max(1, Math.round(dpr)) / dpr;
|
|
68
|
+
/** The view a scene opens on with nothing kept: zones fitted (the office's mode), desks from the top left at desksK. */
|
|
69
|
+
export const openingView = (w, h, size, dpr, zones, mode = "contain") => (zones ? fitScene(w, h, size, mode) : { k: desksK(dpr), x: 0, y: 0 });
|
|
70
|
+
/** One axis of clampPan: offset `o` of a scene `s` long in a viewport `n` long. */
|
|
71
|
+
const clampAxis = (o, s, n) => (s >= n ? Math.min(0, Math.max(n - s, o)) : Math.min(n - s, Math.max(0, o)));
|
|
72
|
+
/**
|
|
73
|
+
* The view kept on the scene: along an axis where the scene is at least the viewport it covers the viewport (no gap at
|
|
74
|
+
* either edge); where it is smaller it stays wholly inside. Nothing can be scrolled off-screen.
|
|
75
|
+
*/
|
|
76
|
+
export function clampPan(v, w, h, size) {
|
|
77
|
+
return { k: v.k, x: clampAxis(v.x, w * v.k, size.w), y: clampAxis(v.y, h * v.k, size.h) };
|
|
78
|
+
}
|
|
79
|
+
/** The zoom as a person reads it: "150%". */
|
|
80
|
+
export const zoomLabel = (k) => `${Math.round(k * 100)}%`;
|
|
81
|
+
/**
|
|
82
|
+
* The overview's box in a `size` stage: the scene's aspect, about a seventh of the stage's width (120–280 px, at most
|
|
83
|
+
* a third of its height), `margin` px in from the bottom right corner — always inside the stage.
|
|
84
|
+
*/
|
|
85
|
+
export function miniBox(size, w, h, margin = 12) {
|
|
86
|
+
const ar = Math.max(1, w) / Math.max(1, h), roomW = Math.max(0, size.w - 2 * margin), roomH = Math.max(0, size.h - 2 * margin);
|
|
87
|
+
let bw = Math.min(280, Math.max(120, Math.round(size.w / 7)), roomW), bh = bw / ar;
|
|
88
|
+
const maxH = Math.min(roomH, Math.max(60, size.h / 3));
|
|
89
|
+
if (bh > maxH) { bh = maxH; bw = bh * ar; }
|
|
90
|
+
bw = Math.floor(bw); bh = Math.floor(bh);
|
|
91
|
+
return { x: size.w - margin - bw, y: size.h - margin - bh, w: bw, h: bh };
|
|
92
|
+
}
|
|
93
|
+
/** The view's frame on the overview (logical units): the part of the scene in view (never outside it). */
|
|
94
|
+
export function miniFrame(v, size, w, h) {
|
|
95
|
+
const x0 = Math.max(0, -v.x / v.k), y0 = Math.max(0, -v.y / v.k);
|
|
96
|
+
const x1 = Math.min(w, (size.w - v.x) / v.k), y1 = Math.min(h, (size.h - v.y) / v.k);
|
|
97
|
+
return { x: x0, y: y0, width: Math.max(0, x1 - x0), height: Math.max(0, y1 - y0) };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── Kept per device, per office: the zoom and the logical point in the middle (the viewport's size may differ) ──
|
|
101
|
+
|
|
102
|
+
/** What is kept of a view: { k, cx, cy }, and `fit: true` when it is a fit (it is fitted again, to the size then). */
|
|
103
|
+
export const savedOf = (v, size, fit = false) => ({ k: v.k, cx: (size.w / 2 - v.x) / v.k, cy: (size.h / 2 - v.y) / v.k, ...(fit ? { fit: true } : {}) });
|
|
104
|
+
/** A kept view, checked: { k, cx, cy } with finite numbers (k in range), else null. */
|
|
105
|
+
export function savedOk(x) {
|
|
106
|
+
if (!x || typeof x !== "object") return null;
|
|
107
|
+
const { k, cx, cy } = x;
|
|
108
|
+
if (![k, cx, cy].every((n) => typeof n === "number" && Number.isFinite(n)) || k <= 0) return null;
|
|
109
|
+
if (Math.abs(cx) > 1e6 || Math.abs(cy) > 1e6) return null;
|
|
110
|
+
return { k: clampO(k), cx, cy, ...(x.fit === true ? { fit: true } : {}) };
|
|
111
|
+
}
|
|
112
|
+
/** The view a kept one gives in a `size` viewport: the same zoom, the same logical point in the middle. */
|
|
113
|
+
export function viewFromSaved(s, size) {
|
|
114
|
+
const k = clampO(s.k);
|
|
115
|
+
return { k, x: size.w / 2 - s.cx * k, y: size.h / 2 - s.cy * k };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ── Drawing ──
|
|
119
|
+
|
|
120
|
+
/** The cached layers' budget (device px): beyond it they are drawn coarser and scaled up by a whole number. */
|
|
121
|
+
export const LAYER_MAX = { side: CANVAS_MAX.side, area: 8 * 1024 * 1024 };
|
|
122
|
+
/**
|
|
123
|
+
* The layers' resolution (layer px per logical unit) for a view at `a` device px a unit and a `w`×`h` scene: `a`
|
|
124
|
+
* itself (the layer is blitted 1:1), or — over the budget — `a` divided by the smallest whole number that fits it,
|
|
125
|
+
* so the blit is still an integer scale-up (nearest neighbour: crisp).
|
|
126
|
+
*/
|
|
127
|
+
export function layerScale(a, w, h, max = LAYER_MAX) {
|
|
128
|
+
const cap = Math.min(max.side / Math.max(1, w), max.side / Math.max(1, h), Math.sqrt(max.area / Math.max(1, w * h)));
|
|
129
|
+
if (a <= cap) return a;
|
|
130
|
+
return a / Math.ceil(a / cap - EPS);
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* The matte from a background's edge pixels: `strips` are RGBA pixel arrays (the top and bottom rows, the left and
|
|
134
|
+
* right columns); the mean of their opaque pixels as #rrggbb, or null when none is opaque.
|
|
135
|
+
*/
|
|
136
|
+
export function edgeMatte(strips) {
|
|
137
|
+
let r = 0, g = 0, b = 0, n = 0;
|
|
138
|
+
for (const s of strips) {
|
|
139
|
+
if (!s || typeof s.length !== "number") continue;
|
|
140
|
+
for (let i = 0; i + 3 < s.length; i += 4) { if (s[i + 3] < 128) continue; r += s[i]; g += s[i + 1]; b += s[i + 2]; n++; }
|
|
141
|
+
}
|
|
142
|
+
if (!n) return null;
|
|
143
|
+
const hx = (v) => Math.round(v / n).toString(16).padStart(2, "0");
|
|
144
|
+
return `#${hx(r)}${hx(g)}${hx(b)}`;
|
|
145
|
+
}
|
|
146
|
+
/** The edge strips of a canvas (its 2d context's pixels), for edgeMatte; null when they cannot be read. */
|
|
147
|
+
export function edgeStrips(c) {
|
|
148
|
+
try {
|
|
149
|
+
const g = c && typeof c.getContext === "function" ? c.getContext("2d") : null;
|
|
150
|
+
if (!g || typeof g.getImageData !== "function" || !(c.width > 0) || !(c.height > 0)) return null;
|
|
151
|
+
const w = c.width, h = c.height;
|
|
152
|
+
return [g.getImageData(0, 0, w, 1).data, g.getImageData(0, h - 1, w, 1).data, g.getImageData(0, 0, 1, h).data, g.getImageData(w - 1, 0, 1, h).data];
|
|
153
|
+
} catch { return null; } // a tainted or lost canvas: the skin's matte
|
|
154
|
+
}
|