@songsid/agend 2.3.1 → 2.3.2-beta.10
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/THIRD_PARTY_NOTICES.md +40 -0
- package/dist/agent-cli.js +21 -2
- package/dist/agent-cli.js.map +1 -1
- package/dist/agent-endpoint.js +5 -0
- package/dist/agent-endpoint.js.map +1 -1
- package/dist/antigravity-usage-scan.d.ts +58 -0
- package/dist/antigravity-usage-scan.js +257 -0
- package/dist/antigravity-usage-scan.js.map +1 -0
- package/dist/antigravity-usage-worker.d.ts +1 -0
- package/dist/antigravity-usage-worker.js +10 -0
- package/dist/antigravity-usage-worker.js.map +1 -0
- package/dist/auth-api.js +4 -0
- package/dist/auth-api.js.map +1 -1
- 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/backend/kiro-identity.d.ts +64 -0
- package/dist/backend/kiro-identity.js +101 -0
- package/dist/backend/kiro-identity.js.map +1 -1
- package/dist/backend/kiro-retired-model.d.ts +62 -0
- package/dist/backend/kiro-retired-model.js +160 -0
- package/dist/backend/kiro-retired-model.js.map +1 -0
- package/dist/backend/kiro.d.ts +71 -2
- package/dist/backend/kiro.js +352 -11
- package/dist/backend/kiro.js.map +1 -1
- package/dist/backend/types.d.ts +59 -0
- package/dist/backend/types.js.map +1 -1
- package/dist/backend-signin-guide.d.ts +1 -0
- package/dist/backend-signin-guide.js +9 -0
- package/dist/backend-signin-guide.js.map +1 -0
- package/dist/backend-signin-pending.d.ts +10 -0
- package/dist/backend-signin-pending.js +44 -0
- package/dist/backend-signin-pending.js.map +1 -0
- package/dist/browser-backend-signins.d.ts +56 -0
- package/dist/browser-backend-signins.js +189 -0
- package/dist/browser-backend-signins.js.map +1 -0
- package/dist/cache-ledger.d.ts +38 -2
- package/dist/cache-ledger.js +133 -8
- package/dist/cache-ledger.js.map +1 -1
- package/dist/cache-prices.d.ts +2 -1
- package/dist/cache-prices.js +5 -2
- package/dist/cache-prices.js.map +1 -1
- package/dist/cache-service.d.ts +68 -0
- package/dist/cache-service.js +355 -13
- package/dist/cache-service.js.map +1 -1
- package/dist/channel/adapters/discord.d.ts +13 -0
- package/dist/channel/adapters/discord.js +103 -16
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/channel/mcp-tools.js +34 -7
- package/dist/channel/mcp-tools.js.map +1 -1
- package/dist/channel/tool-router.js +2 -1
- package/dist/channel/tool-router.js.map +1 -1
- package/dist/channel/types.d.ts +11 -0
- package/dist/classic-channel-manager.d.ts +11 -0
- package/dist/classic-channel-manager.js +39 -1
- package/dist/classic-channel-manager.js.map +1 -1
- package/dist/cli.js +21 -2
- package/dist/cli.js.map +1 -1
- package/dist/config-validator.d.ts +7 -1
- package/dist/config-validator.js +92 -12
- package/dist/config-validator.js.map +1 -1
- package/dist/config.d.ts +19 -0
- package/dist/config.js +31 -1
- package/dist/config.js.map +1 -1
- package/dist/cpu-quota.d.ts +23 -0
- package/dist/cpu-quota.js +86 -0
- package/dist/cpu-quota.js.map +1 -0
- package/dist/daemon.d.ts +57 -3
- package/dist/daemon.js +330 -50
- package/dist/daemon.js.map +1 -1
- package/dist/dashboard-permissions.d.ts +20 -6
- package/dist/dashboard-permissions.js +58 -9
- package/dist/dashboard-permissions.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 +11 -1
- 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 +212 -0
- package/dist/fleet-manager.js +914 -111
- 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/forged-envelope-scan.d.ts +9 -0
- package/dist/forged-envelope-scan.js +30 -5
- package/dist/forged-envelope-scan.js.map +1 -1
- package/dist/general-knowledge/skills/credential-profiles/SKILL.md +5 -2
- package/dist/general-knowledge/skills/kiro-engine-migration/SKILL.md +14 -1
- package/dist/general-knowledge/skills/web-appearance/SKILL.canvas-worker-off.md +28 -5
- package/dist/general-knowledge/skills/web-appearance/SKILL.md +31 -8
- package/dist/general-knowledge/skills/web-dashboard/SKILL.canvas-worker-off.md +138 -59
- package/dist/general-knowledge/skills/web-dashboard/SKILL.md +145 -61
- 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 +14 -6
- package/dist/instance-config-impact.js.map +1 -1
- package/dist/instance-lifecycle.d.ts +20 -0
- package/dist/instance-lifecycle.js +71 -13
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/kiro-db-reader.d.ts +5 -0
- package/dist/kiro-db-reader.js +18 -0
- package/dist/kiro-db-reader.js.map +1 -1
- package/dist/kiro-engine-status.d.ts +2 -0
- package/dist/kiro-engine-status.js +9 -0
- package/dist/kiro-engine-status.js.map +1 -1
- package/dist/kiro-migrate.d.ts +49 -0
- package/dist/kiro-migrate.js +105 -0
- package/dist/kiro-migrate.js.map +1 -0
- package/dist/kiro-transcript-lane.d.ts +2 -0
- package/dist/kiro-transcript-lane.js.map +1 -1
- package/dist/kiro-transcript-worker.js +1 -0
- package/dist/kiro-transcript-worker.js.map +1 -1
- package/dist/kiro-tui-forensic.d.ts +7 -0
- package/dist/kiro-tui-forensic.js +110 -0
- package/dist/kiro-tui-forensic.js.map +1 -0
- package/dist/kiro-usage-run.d.ts +7 -0
- package/dist/kiro-usage-run.js +39 -0
- package/dist/kiro-usage-run.js.map +1 -0
- package/dist/kiro-usage-scan.d.ts +49 -0
- package/dist/kiro-usage-scan.js +223 -0
- package/dist/kiro-usage-scan.js.map +1 -0
- package/dist/kiro-usage-worker.d.ts +1 -0
- package/dist/kiro-usage-worker.js +10 -0
- package/dist/kiro-usage-worker.js.map +1 -0
- package/dist/kiro-usage.d.ts +58 -0
- package/dist/kiro-usage.js +310 -0
- package/dist/kiro-usage.js.map +1 -0
- package/dist/locale.js +120 -12
- package/dist/locale.js.map +1 -1
- package/dist/log-service.js +1 -1
- package/dist/log-service.js.map +1 -1
- package/dist/login-url.d.ts +4 -0
- package/dist/login-url.js +22 -0
- package/dist/login-url.js.map +1 -0
- 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/needs-you-hub.d.ts +2 -1
- package/dist/needs-you-hub.js +1 -1
- package/dist/needs-you-hub.js.map +1 -1
- package/dist/needs-you.d.ts +8 -2
- package/dist/needs-you.js +6 -0
- package/dist/needs-you.js.map +1 -1
- package/dist/notice-delivery.d.ts +16 -0
- package/dist/notice-delivery.js +35 -0
- package/dist/notice-delivery.js.map +1 -0
- package/dist/office-share-page.d.ts +16 -0
- package/dist/office-share-page.js +85 -0
- package/dist/office-share-page.js.map +1 -0
- package/dist/office-share-service.d.ts +91 -0
- package/dist/office-share-service.js +176 -0
- package/dist/office-share-service.js.map +1 -0
- package/dist/office-share.d.ts +258 -0
- package/dist/office-share.js +549 -0
- package/dist/office-share.js.map +1 -0
- package/dist/opencode-usage-scan.d.ts +28 -0
- package/dist/opencode-usage-scan.js +92 -0
- package/dist/opencode-usage-scan.js.map +1 -0
- package/dist/opencode-usage-worker.d.ts +1 -0
- package/dist/opencode-usage-worker.js +10 -0
- package/dist/opencode-usage-worker.js.map +1 -0
- package/dist/outbound-handlers.js +22 -2
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/outbound-schemas.d.ts +28 -2
- package/dist/outbound-schemas.js +29 -2
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/public-web-gateway.js +16 -2
- package/dist/public-web-gateway.js.map +1 -1
- package/dist/quickstart-api.js +13 -2
- package/dist/quickstart-api.js.map +1 -1
- package/dist/quickstart.js +2 -1
- package/dist/quickstart.js.map +1 -1
- package/dist/remote-card-answer.d.ts +83 -0
- package/dist/remote-card-answer.js +208 -0
- package/dist/remote-card-answer.js.map +1 -0
- package/dist/reply-triggers.d.ts +2 -0
- package/dist/reply-triggers.js +3 -0
- package/dist/reply-triggers.js.map +1 -0
- package/dist/settings-api.d.ts +28 -0
- package/dist/settings-api.js +148 -17
- package/dist/settings-api.js.map +1 -1
- package/dist/settings-change.js +54 -17
- package/dist/settings-change.js.map +1 -1
- package/dist/settings-effect.js +12 -3
- package/dist/settings-effect.js.map +1 -1
- package/dist/settings-instance-patch.js +4 -1
- package/dist/settings-instance-patch.js.map +1 -1
- package/dist/setup-form.d.ts +1 -1
- package/dist/setup-form.js +55 -6
- package/dist/setup-form.js.map +1 -1
- package/dist/setup-host.d.ts +9 -0
- package/dist/setup-host.js +127 -6
- package/dist/setup-host.js.map +1 -1
- package/dist/setup-signin-assets.d.ts +2 -0
- package/dist/setup-signin-assets.js +6 -0
- package/dist/setup-signin-assets.js.map +1 -0
- package/dist/setup-wizard.js +2 -0
- package/dist/setup-wizard.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/structured-signin-transport.d.ts +8 -0
- package/dist/structured-signin-transport.js +79 -0
- package/dist/structured-signin-transport.js.map +1 -0
- package/dist/structured-signin.d.ts +131 -0
- package/dist/structured-signin.js +388 -0
- package/dist/structured-signin.js.map +1 -0
- package/dist/tool-permissions.js +12 -0
- package/dist/tool-permissions.js.map +1 -1
- package/dist/topic-commands.js +6 -2
- package/dist/topic-commands.js.map +1 -1
- package/dist/transcript-sources.d.ts +26 -2
- package/dist/transcript-sources.js +103 -40
- package/dist/transcript-sources.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 +46 -9
- package/dist/ui/backend-signin.js +68 -12
- 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 +4 -4
- 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 +94 -14
- package/dist/ui/fleet-office-layout.js +235 -0
- package/dist/ui/fleet-office-model.js +12 -6
- package/dist/ui/fleet-office-overview.js +167 -0
- package/dist/ui/fleet-office-pack.d.ts +44 -0
- package/dist/ui/fleet-office-pack.js +352 -37
- package/dist/ui/fleet-office-skin.js +220 -36
- package/dist/ui/fleet-office-store.js +112 -7
- package/dist/ui/fleet-office-view.js +155 -0
- package/dist/ui/fleet-office-zones.js +264 -42
- package/dist/ui/fleet-office.js +957 -160
- package/dist/ui/fleet-usage.js +26 -21
- package/dist/ui/office-share-dialog.js +145 -0
- package/dist/ui/office-share-page.js +164 -0
- package/dist/ui/panel-chat.js +9 -2
- package/dist/ui/panel-details.js +2 -2
- package/dist/ui/panel-fleet.js +11 -77
- package/dist/ui/panel-log.js +4 -4
- package/dist/ui/panel-needs.js +4 -4
- package/dist/ui/panel-settings.js +608 -167
- 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 +76 -23
- package/dist/ui/settings-draft.js +51 -0
- package/dist/ui/settings-guests.js +255 -79
- package/dist/ui/settings-learn.js +2 -2
- package/dist/ui/settings-model.js +16 -6
- package/dist/ui/settings-registry.js +146 -0
- package/dist/ui/settings-strings.js +116 -44
- package/dist/ui/settings-web-authority.js +34 -23
- package/dist/ui/shared/app-i18n.js +18 -2
- 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 +73 -10
- package/dist/ui/shared/app-stream.js +3 -1
- package/dist/ui/shared/app.css +307 -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/office/cozy.png +0 -0
- package/dist/ui/shared/side-panel.js +95 -0
- 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/ui/structured-signin.js +115 -0
- package/dist/usage-report.d.ts +20 -0
- package/dist/usage-report.js +54 -17
- package/dist/usage-report.js.map +1 -1
- package/dist/usage-rollup.d.ts +9 -1
- package/dist/usage-rollup.js +20 -4
- package/dist/usage-rollup.js.map +1 -1
- package/dist/web-api.d.ts +26 -2
- package/dist/web-api.js +361 -5
- package/dist/web-api.js.map +1 -1
- package/dist/web-auth.js +8 -2
- package/dist/web-auth.js.map +1 -1
- package/dist/web-backend-login.d.ts +1 -7
- package/dist/web-backend-login.js +2 -24
- package/dist/web-backend-login.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-acts.d.ts +173 -0
- package/dist/web-office-acts.js +285 -0
- package/dist/web-office-acts.js.map +1 -0
- package/dist/web-office-skins.d.ts +109 -0
- package/dist/web-office-skins.js +299 -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 +77 -14
- package/dist/web-office-store.js.map +1 -1
- package/dist/web-settings-patch.d.ts +8 -1
- package/dist/web-settings-patch.js +65 -5
- 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 +298 -0
- package/dist/working-recovery.js.map +1 -0
- package/dist/working-state-journal.d.ts +5 -1
- package/dist/working-state-journal.js +31 -3
- package/dist/working-state-journal.js.map +1 -1
- package/package.json +3 -2
- package/templates/windows-shortcuts.ps1 +155 -140
package/dist/ui/fleet-office.js
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
// Fleet → Org chart → Office: the fleet as a small pixel office, the third view beside Canvas and List
|
|
2
2
|
// (fleet-canvas.js). The same data as theirs: the structure from /ui/org (buildOrgView), the live state from the app
|
|
3
|
-
// store, the traffic from fleet-traffic.js's store — nothing new is read but the offices people made (/ui/offices).
|
|
3
|
+
// store, the traffic from fleet-traffic.js's store — nothing new is read but the offices people made (/ui/offices) and what agents do in them (/ui/office-acts, #2578).
|
|
4
4
|
//
|
|
5
|
-
// - Two layouts, by skin. Desks (the built-in "pixel" skin
|
|
6
|
-
// name tag; General and the instances in no team share the lobby; a grid that
|
|
7
|
-
// (fleet-office-model.js autoLayout). Zones (a skin whose pack maps states to areas — the
|
|
8
|
-
//
|
|
9
|
-
// pack's waypoints when its state changes (fleet-office-zones.js).
|
|
5
|
+
// - Two layouts, by skin. Desks (the built-in "pixel" skin; an unknown style, and a saved office with none): each team
|
|
6
|
+
// is a room, each member a desk with a name tag; General and the instances in no team share the lobby; a grid that
|
|
7
|
+
// wraps at the stage's width (fleet-office-model.js autoLayout). Zones (a skin whose pack maps states to areas — the
|
|
8
|
+
// built-in "studio", and "cozy" drawn from one picture, the default, #2631): a 1280×720 scene; each instance takes a
|
|
9
|
+
// seat in the area for its state and walks there along the pack's waypoints when its state changes (fleet-office-zones.js).
|
|
10
|
+
// - #2540: the canvas fills the Fleet panel (no frame: the whole width of the Fleet area, down to the window's foot);
|
|
11
|
+
// the scene is panned and zoomed as the org chart's canvas is (fleet-canvas-view.js: drag, wheel, pinch, + / − / Fit
|
|
12
|
+
// and the `+ - 0` keys, an overview), Fit filling the panel (contain; Fill: cover), + and − in pixel levels, the zoom
|
|
13
|
+
// shown as a percentage, around it a matte (fleet-office-view.js). The view (zoom and centre) and the fit mode are
|
|
14
|
+
// kept per device per office and style. Everything on the scene — hit-testing, tooltips, Edit layout's handles —
|
|
15
|
+
// goes through the same view transform.
|
|
10
16
|
// - Speech bubbles: now and then a short line for its state (typed out; at once with reduced motion) — the page's own
|
|
11
17
|
// lines or the skin's, never anything an agent said. Furniture can react: an item with `activeWhen` animates while
|
|
12
18
|
// anyone shown is in that state.
|
|
@@ -20,43 +26,88 @@
|
|
|
20
26
|
// - Who is shown: an office switcher (the whole fleet, or an office from the host), and for the whole fleet a team (or
|
|
21
27
|
// the lobby) and pinned people — kept per browser (fleet-office-store.js).
|
|
22
28
|
// - One requestAnimationFrame loop, drawing only when a frame changes; it stops while the tab is hidden and when the view
|
|
23
|
-
// goes. Reduced motion: still poses, nothing in the air. Colours are the dashboard's
|
|
24
|
-
//
|
|
29
|
+
// goes. Reduced motion: still poses, nothing in the air (zoom and pan are never animated). Colours are the dashboard's
|
|
30
|
+
// tokens (dark and light).
|
|
31
|
+
// - Drawing is the skin's (fleet-office-skin.js); a skin pack an admin uploaded (fleet-office-pack.js, #2513) replaces
|
|
32
|
+
// any part — an office names it as its style; one this page cannot load is drawn as Desks, and says so.
|
|
33
|
+
// - Crowds (#2513): an area with more people than seats packs the rest into a tighter grid, then counts who is left
|
|
34
|
+
// ("+N", in its corner, never over its name); there idle and asleep people say nothing, at most a few speak (calls
|
|
35
|
+
// first, then work), no bubble covers a name tag, and the tags show on hover or focus only. "Hide paused and stopped"
|
|
36
|
+
// (per browser) leaves the sleepers out.
|
|
37
|
+
// - Share… (#2636; an admin, on the dashboard reached directly or over the public link): a link anyone can open to watch an office live, read-only,
|
|
38
|
+
// until it ends (office-share-dialog.js; the page it opens is office-share-page.js).
|
|
39
|
+
// - Edit layout (an admin, a stored office on a zones skin): fleet-office-layout.js, saved as the office's overrides.
|
|
40
|
+
// An editing session (#2513 review) is bound to the office it opened on, that office's style (the pack's id and sha)
|
|
41
|
+
// and the list and rev read then: a re-read of the offices meanwhile (the stream) changes the view, never the session,
|
|
42
|
+
// so Save sends that rev and a change made meanwhile is a 409 (the session then takes the list read again, and Save
|
|
43
|
+
// again is the person's decision). While it is open the office picker and Edit offices wait; if the office goes or its
|
|
44
|
+
// style changes underneath it, the editor says so and only Cancel is left.
|
|
45
|
+
// - Fullscreen (#2540): a button by the zoom controls, or `f`; Esc leaves. The Fullscreen API on the view's container
|
|
46
|
+
// (webkit's prefix too); where a page cannot take an element fullscreen (an iPhone) or the browser refuses, "immersive":
|
|
47
|
+
// the container covers the window (CSS). Either way only the stage shows, with its floating controls (zoom, Fit, the
|
|
48
|
+
// overview, a compact office switcher, Exit); the stage's resize handling re-fits it. Nothing about it is kept.
|
|
49
|
+
// - All offices (#2554; offered with two or more stored offices): every stored office as a tile on one canvas, each
|
|
50
|
+
// drawn as its own view would be — its style (a pack loaded once and shared), its members, seats, poses, bubbles,
|
|
51
|
+
// walking and in-office traffic — scaled into its tile (fleet-office-overview.js: the tiles, the lobby tile of whoever
|
|
52
|
+
// is in no office, hit-testing, culling, the flights between tiles). The same canvas, controls and loop; tiles off
|
|
53
|
+
// screen are not drawn. A tile's label (a button, a readable size whatever the zoom) or a double-click on it opens
|
|
54
|
+
// that office; a person opens their chat. A tile too small to read (under TAG_MIN_K) shows name tags on hover only;
|
|
55
|
+
// bubbles are drawn after every tile (never cut at a tile's edge). Fit keeps the tiles clear of the controls
|
|
56
|
+
// (FIT_PAD). Edit offices and Edit layout wait for a single office.
|
|
25
57
|
import { html, useEffect, useLayoutEffect, useMemo, useRef, useState } from "/assets/app-html.js";
|
|
26
58
|
import { appStore } from "/assets/app-store.js";
|
|
27
59
|
import { t, lang, register } from "/assets/app-i18n.js";
|
|
28
60
|
import { identityOf } from "/assets/identity-color.js";
|
|
61
|
+
import { Icon } from "/assets/ui-icons.js";
|
|
29
62
|
import { nodeText } from "./fleet-org.js";
|
|
30
63
|
import { hits, needle } from "./fleet-canvas-layout.js";
|
|
31
64
|
import { AUTO_ID, LOBBY, autoLayout, backingScale, coatOf, createFlights, customLayout, deskAt, desksOf, filterOk, lampOf, layoutOk, lightOf, membersOf,
|
|
32
65
|
officePose, officeRooms, signOf, workOn } from "./fleet-office-model.js";
|
|
33
|
-
import { CHAR_DEPTH, DEFAULT_PAL, bubbleBox, modeOf, skinFor } from "./fleet-office-skin.js";
|
|
34
|
-
import { CALLING, activeFurniture, bubbleAt, createWalkers, linesFor, personAt, placeBubbles, zonesLayout } from "./fleet-office-zones.js";
|
|
35
|
-
import { createOfficeStore } from "./fleet-office-store.js";
|
|
36
|
-
import {
|
|
66
|
+
import { BUILT_IN_SKINS, CHAR_DEPTH, DEFAULT_PAL, bubbleBox, loadCozySkin, loadPackSkin, modeOf, skinFor, tagBox } from "./fleet-office-skin.js";
|
|
67
|
+
import { CALLING, activeFurniture, bubbleAt, bubbleRank, NIGHT, WANDER, createMail, createWalkers, isNight, linesFor, npcKey, personAt, placeBubbles, thinBubbles, walkTargets, zonesLayout } from "./fleet-office-zones.js";
|
|
68
|
+
import { actsOk, createOfficeStore } from "./fleet-office-store.js";
|
|
69
|
+
import { POLL_MS } from "./fleet-traffic.js";
|
|
70
|
+
import { OfficeEditor, officeForSave } from "./fleet-office-editor.js";
|
|
71
|
+
import { LayoutEditor, handlesOf } from "./fleet-office-layout.js";
|
|
72
|
+
import { ShareButton } from "./office-share-dialog.js";
|
|
73
|
+
import { applyOverrides, fitNight } from "./fleet-office-pack.js";
|
|
74
|
+
import { ALL_ID, LOBBY_TILE, OV, createCrossFlights, cropToRooms, fromTile, inTile, labelAt, lobbyNodes, overviewOk, tileAt, tileBox, tileLayout, visibleTiles } from "./fleet-office-overview.js";
|
|
75
|
+
import { centreOn, gestureState, gestures, miniPoint, toScreen, toWorld, wheelFactor, zoomKey, zoomTo } from "./fleet-canvas-view.js";
|
|
76
|
+
import { OK_MAX, OK_MIN, clampPan, desksK, edgeMatte, edgeStrips, fitScene, layerScale, miniBox, miniFrame, openingView, pinchOffice, savedOf, stepK, viewFromSaved,
|
|
77
|
+
zoomLabel, zoomOffice } from "./fleet-office-view.js";
|
|
37
78
|
|
|
38
79
|
register("orgo", {
|
|
39
80
|
en: {
|
|
40
|
-
label: "Office: each team is a room and each instance a desk; General and the instances in no team are in the lobby.",
|
|
81
|
+
label: "Office: each team is a room and each instance a desk; General and the instances in no team are in the lobby. Drag to pan; the wheel, a pinch, + and − zoom, 0 fits, F is fullscreen.",
|
|
41
82
|
office: "Office", allOffice: "Whole fleet", show: "Show", allTeams: "All teams", lobby: "Lobby (General, no team)", lobbyRoom: "Lobby",
|
|
42
83
|
people: "People", peopleN: "People ({0})", findPerson: "Find a person", everyone: "Show everyone", nobody: "No one here matches this filter.",
|
|
43
84
|
unseated: "{0} without a seat", skinUnknown: "Its style “{0}” is not available here: drawn as Desks.", closed: "Closed", desks: "Desks", tapAgain: "Tap again to open the chat",
|
|
44
85
|
capWorking: "Typing away", capIdle: "Tea break", capPaused: "Asleep", capNeeds: "Calling for you", capAway: "Desk empty", capStarting: "Just arriving", capQueued: "Waiting to start",
|
|
45
|
-
styleLabel: "Style", style_pixel: "Desks", style_studio: "Studio", area_desks: "Work", area_lounge: "Lounge", area_alert: "Help desk", area_nap: "Nap corner",
|
|
86
|
+
styleLabel: "Style", style_pixel: "Desks", style_studio: "Studio", style_cozy: "Cozy", cozyNotice: "Add your skin", area_desks: "Work", area_lounge: "Lounge", area_alert: "Help desk", area_nap: "Nap corner",
|
|
46
87
|
b_working: "On it.|Typing…|Almost there|In the zone|One more test", b_idle: "Tea time|Ready when you are|Stretching|Anything for me?",
|
|
47
88
|
b_paused: "Zzz…|Back later", b_stopped: "Off duty|Zzz…", b_needs: "Need you!|A question for you|Over here!",
|
|
48
89
|
b_crashed: "Ouch, crashed|Help!", b_stuck: "Hmm, stuck?|Still thinking…",
|
|
90
|
+
hideAsleep: "Hide paused and stopped", nightOn: "Bedtime 0–6: the idle sleep", layoutIgnored: "Its saved layout does not fit this style: drawn as the style's own.",
|
|
91
|
+
fit: "Fit the office", overview: "Overview: click to move there", fullscreen: "Fullscreen", exitFullscreen: "Exit fullscreen",
|
|
92
|
+
fill: "Fill", fillTitle: "Fill the panel (the scene's overflow is cropped; drag to see it)", zoomLevel: "Zoom {0}",
|
|
93
|
+
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.",
|
|
94
|
+
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",
|
|
49
95
|
},
|
|
50
96
|
"zh-TW": {
|
|
51
|
-
label: "辦公室:每個 team 是一間房間,每個 instance 一張桌子;General 和未加入 team 的 instance
|
|
97
|
+
label: "辦公室:每個 team 是一間房間,每個 instance 一張桌子;General 和未加入 team 的 instance 在大廳。拖曳平移;滾輪、雙指、+ 和 − 縮放,0 顯示整個場景,F 切換全螢幕。",
|
|
52
98
|
office: "辦公室", allOffice: "整個 fleet", show: "顯示", allTeams: "所有 team", lobby: "大廳(General、未加入 team)", lobbyRoom: "大廳",
|
|
53
99
|
people: "成員", peopleN: "成員({0})", findPerson: "尋找成員", everyone: "顯示全部", nobody: "沒有符合這個篩選的成員。",
|
|
54
100
|
unseated: "{0} 位沒有座位", skinUnknown: "這裡沒有它的風格「{0}」:以辦公桌顯示。", closed: "休息中", desks: "桌子", tapAgain: "再點一下開啟聊天",
|
|
55
101
|
capWorking: "埋頭打字中", capIdle: "喝茶休息", capPaused: "睡著了", capNeeds: "在呼叫你", capAway: "空位", capStarting: "剛到座位", capQueued: "等待啟動",
|
|
56
|
-
styleLabel: "風格", style_pixel: "辦公桌", style_studio: "工作室", area_desks: "工作區", area_lounge: "休息區", area_alert: "求救區", area_nap: "午睡角",
|
|
102
|
+
styleLabel: "風格", style_pixel: "辦公桌", style_studio: "工作室", style_cozy: "溫馨", cozyNotice: "換上你的外觀包", area_desks: "工作區", area_lounge: "休息區", area_alert: "求救區", area_nap: "午睡角",
|
|
57
103
|
b_working: "處理中|打字中…|快好了|專心中|再測一次", b_idle: "喝茶中|隨時可以開工|伸個懶腰|有事找我嗎?",
|
|
58
104
|
b_paused: "Zzz…|晚點回來", b_stopped: "下班了|Zzz…", b_needs: "需要你!|有個問題|在這裡!",
|
|
59
105
|
b_crashed: "糟糕,當掉了|救命!", b_stuck: "嗯,卡住了?|還在想…",
|
|
106
|
+
hideAsleep: "隱藏暫停與停止的成員", nightOn: "0–6 點就寢:閒著的人去睡", layoutIgnored: "它儲存的配置不適用這個風格:以風格本身的配置顯示。",
|
|
107
|
+
fit: "顯示整個辦公室", overview: "總覽:點一下移到那裡", fullscreen: "全螢幕", exitFullscreen: "結束全螢幕",
|
|
108
|
+
fill: "填滿", fillTitle: "填滿整個面板(超出的部分會被裁切,拖曳即可看到)", zoomLevel: "縮放 {0}",
|
|
109
|
+
allOffices: "所有辦公室", labelAll: "所有辦公室:每間辦公室是一格,以它自己的風格顯示。拖曳平移;滾輪、雙指、+ 和 − 縮放,0 顯示全部,F 切換全螢幕。雙擊一格即可開啟那間辦公室。",
|
|
110
|
+
tileLabel: "{0}({1})", openOffice: "開啟辦公室「{0}」", lobbyTile: "大廳格(不在任何辦公室)", lobbyEveryone: "每個成員都已在某間辦公室", overviewNoEdit: "請先選一間辦公室,再編輯辦公室或配置",
|
|
60
111
|
},
|
|
61
112
|
});
|
|
62
113
|
const to = (k, ...v) => t(`orgo.${k}`, ...v);
|
|
@@ -67,10 +118,40 @@ const CAPTION_OF_STATE = { starting: "capStarting", queued: "capQueued" };
|
|
|
67
118
|
export const caption = (pose, state) => to(CAPTION_OF_STATE[state] || CAPTION[pose] || "capIdle");
|
|
68
119
|
|
|
69
120
|
const reducedMotion = () => typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
121
|
+
/** The device's ratio (CSS px → device px), kept sane. */
|
|
122
|
+
const deviceRatio = () => Math.max(0.5, Math.min(4, Number(globalThis.devicePixelRatio) || 1));
|
|
123
|
+
/** The stage's size before it is measured (a test's page has none). */
|
|
124
|
+
const SIZE0 = { w: 960, h: 600 };
|
|
125
|
+
/** #2554: All offices' Fit keeps this many CSS px clear round the tiles (the zoom controls' column and a margin). */
|
|
126
|
+
const FIT_PAD = 60;
|
|
127
|
+
/** #2554: in All offices a tile's name tags show (always) from this many CSS px a unit up; below it on hover only. */
|
|
128
|
+
export const TAG_MIN_K = 0.75;
|
|
129
|
+
const PAN_KEYS = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] };
|
|
130
|
+
/** Typing: no single-key shortcut (the `f` of fullscreen) there. */
|
|
131
|
+
const typing = (el) => !!el && (el.tagName === "INPUT" || el.tagName === "SELECT" || el.tagName === "TEXTAREA" || el.isContentEditable === true);
|
|
132
|
+
/** The element the page has fullscreen (standard, else webkit's), or null. */
|
|
133
|
+
const fullscreenEl = () => { const d = globalThis.document; return (d && (d.fullscreenElement || d.webkitFullscreenElement)) || null; };
|
|
134
|
+
/** A pack's background, sampled once for its matte (fleet-office-view.js edgeMatte): picture → #rrggbb or null. */
|
|
135
|
+
const MATTES = new WeakMap();
|
|
136
|
+
function sampledMatte(img) {
|
|
137
|
+
if (!img || typeof img !== "object") return null;
|
|
138
|
+
if (MATTES.has(img)) return MATTES.get(img);
|
|
139
|
+
let m = null;
|
|
140
|
+
try {
|
|
141
|
+
const c = document.createElement("canvas");
|
|
142
|
+
c.width = 64; c.height = 36;
|
|
143
|
+
const g = c.getContext && c.getContext("2d");
|
|
144
|
+
if (g) { g.imageSmoothingEnabled = false; g.drawImage(img, 0, 0, 64, 36); const st = edgeStrips(c); m = st ? edgeMatte(st) : null; }
|
|
145
|
+
} catch { m = null; }
|
|
146
|
+
MATTES.set(img, m);
|
|
147
|
+
return m;
|
|
148
|
+
}
|
|
70
149
|
const now = () => (typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now());
|
|
71
150
|
/** How often the picture can change when nothing is in the air (the poses loop at 4 fps; a bubble types faster). */
|
|
72
151
|
const FPS = 12;
|
|
73
|
-
const STYLES =
|
|
152
|
+
const STYLES = BUILT_IN_SKINS;
|
|
153
|
+
/** Paused and stopped: left out when "Hide paused and stopped" is on. */
|
|
154
|
+
const ASLEEP = new Set(["paused", "stopped"]);
|
|
74
155
|
const BUBBLE_STATES = ["working", "idle", "paused", "stopped", "needs", "crashed", "stuck"];
|
|
75
156
|
/** The page's own bubble lines, by state, in its language (short, never message content). */
|
|
76
157
|
export const bubbleDefaults = () => Object.fromEntries(BUBBLE_STATES.map((s) => [s, to(`b_${s}`).split("|")]));
|
|
@@ -95,6 +176,234 @@ function teamOptions(view) {
|
|
|
95
176
|
...view.teams.map((tm, i) => ({ value: `t${i}`, team: tm.name, label: tm.name }))];
|
|
96
177
|
}
|
|
97
178
|
|
|
179
|
+
// ── A scene: one office's layout, looks and skin, drawn by itself (the single view) or as a tile (All offices, #2554) ──
|
|
180
|
+
|
|
181
|
+
/** What each desk shows: the pose, the light, the coat; dimmed when the Find box matches someone else. */
|
|
182
|
+
function looksOf(L, work, q, mode, zones) {
|
|
183
|
+
return new Map(L.rooms.flatMap((r) => r.desks).map((d) => {
|
|
184
|
+
const n = d.node;
|
|
185
|
+
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 }];
|
|
186
|
+
const match = !!q && hits(n, q);
|
|
187
|
+
// At night (#2568) the idle are drawn asleep (their state stays idle: the tooltip and the list say so).
|
|
188
|
+
return [d.key, { name: n.name, label: nodeText(n).primary, state: n.state, pose: officePose(d.night ? "paused" : n.state), night: !!d.night, light: lightOf(n.state), lamp: lampOf(n.state),
|
|
189
|
+
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 }];
|
|
190
|
+
}));
|
|
191
|
+
}
|
|
192
|
+
/** A stored office's own layout on a zones style (its overrides); `ignored`: they do not fit this style. */
|
|
193
|
+
function withOverrides(baseSkin, zones, office) {
|
|
194
|
+
const ov = zones && office ? office.overrides : null;
|
|
195
|
+
if (!ov || ov.pack !== baseSkin.id) return { skin: baseSkin, ignored: false };
|
|
196
|
+
const a = applyOverrides(baseSkin.pack, fitNight(baseSkin.pack, ov)); // #2665: a night place the pack lost: left out
|
|
197
|
+
return a.ok ? { skin: { ...baseSkin, pack: a.pack }, ignored: false } : { skin: baseSkin, ignored: true };
|
|
198
|
+
}
|
|
199
|
+
/** 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`. */
|
|
200
|
+
function officeScene(office, nodes, skin, zones, deskW, prevSeats, night = false) {
|
|
201
|
+
if (zones) return zonesLayout(skin.pack, nodes, prevSeats, { night });
|
|
202
|
+
const lay = office.layout || layoutOk(skin.layout);
|
|
203
|
+
return lay ? customLayout({ ...office, layout: lay }, nodes) : autoLayout([{ key: office.id, name: office.name, lobby: !!office.lobby, nodes }], { width: deskW });
|
|
204
|
+
}
|
|
205
|
+
/** What drawPart needs of a scene; its layers are painted on first draw (and again at another resolution). */
|
|
206
|
+
function partOf(L, looks, skin, zones, walkers, flights) {
|
|
207
|
+
const all = L.rooms.flatMap((r) => r.desks);
|
|
208
|
+
const shown = new Set([...looks.values()].map((l) => l.state)), byName = new Map([...looks.values()].filter((l) => l.name).map((l) => [l.name, l.state]));
|
|
209
|
+
return { L, looks, skin, zones, walkers, flights, all, desks: all.filter((d) => d.node), layers: null,
|
|
210
|
+
active: zones ? activeFurniture(skin.pack, shown, byName) : new Set(), lines: bubbleDefaults(), lang: lang() };
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* At bedtime (#2568): each item with a `night` drawn as it — its strip playing, at its night place: the office's
|
|
214
|
+
* (Edit layout, #2665: applyOverrides puts it on `night`), else the pack's night x/y, else its (moved) day place.
|
|
215
|
+
*/
|
|
216
|
+
export const nightFurniture = (list) => list.map((f) => (f.night ? { ...f, sprite: f.night.sprite, activeSprite: null, x: f.night.x ?? f.x, y: f.night.y ?? f.y, asleep: true } : f));
|
|
217
|
+
/** #2596: each NPC with a `walk` where its walker is now (`at(key)`): moved there — its tag and depth with it — and, while
|
|
218
|
+
* it walks, `walking` and its `heading` (its walk strip: walkStrip); asleep, or with no walker (not strolling), where it is. */
|
|
219
|
+
export const strollFurniture = (list, at) => list.map((f) => {
|
|
220
|
+
const w = f.walk && !f.asleep ? at(npcKey(f)) : null;
|
|
221
|
+
return w ? { ...f, x: w.x, y: w.y, walking: !!w.walking, heading: w.heading || null } : f;
|
|
222
|
+
});
|
|
223
|
+
/** Where a seated person's bubble points: over the head of the picture drawn — a pack's figure can be taller than the
|
|
224
|
+
* built-in one (#2558: a 134 px figure had its face under the bubble); never lower than the built-in spot. */
|
|
225
|
+
export function speakerAt(sk, look, at) {
|
|
226
|
+
const top = sk.charBox ? sk.charBox(look, at).y + 2 : Infinity;
|
|
227
|
+
return { x: at.x + 10, y: Math.min(at.y - 50, top) };
|
|
228
|
+
}
|
|
229
|
+
/** The pose an area's seats are for: the first state (asleep, working, calling, idle) the pack sends there. */
|
|
230
|
+
const GHOST_POSE = [["paused", "paused"], ["stopped", "paused"], ["working", "working"], ["needs", "needs"], ["crashed", "needs"], ["stuck", "needs"], ["idle", "idle"]];
|
|
231
|
+
/** How faint Edit layout's figures are: seen through, yet readable over a busy picture (a dimmed person is 0.35). */
|
|
232
|
+
const GHOST_ALPHA = 0.6;
|
|
233
|
+
/** Edit layout's faint figures: one on each seat no one is on, { x, y, look } (the pack's default set; drawn at GHOST_ALPHA). */
|
|
234
|
+
export function ghostsOf(pack, people = []) {
|
|
235
|
+
const taken = (s) => people.some((q) => Math.abs(q.at.x - s.x) < 2 && Math.abs(q.at.y - s.y) < 2);
|
|
236
|
+
const out = [];
|
|
237
|
+
for (const [name, a] of Object.entries(pack.areas)) {
|
|
238
|
+
const hit = GHOST_POSE.find(([st]) => pack.stateZones && pack.stateZones[st] === name);
|
|
239
|
+
const pose = hit ? hit[1] : "idle";
|
|
240
|
+
for (const s of a.seats) if (!taken(s)) out.push({ x: s.x, y: s.y, look: { key: `ghost:${name}`, name: "", label: "", state: pose, pose, coat: "#8a8f99", free: true } });
|
|
241
|
+
}
|
|
242
|
+
return out;
|
|
243
|
+
}
|
|
244
|
+
/** #2665: Edit layout's faint night places by day — each item with a `night` as it is drawn at bedtime (nightFurniture). */
|
|
245
|
+
export const nightGhostsOf = (pack) => nightFurniture(pack.furniture.filter((f) => f.night));
|
|
246
|
+
/** All offices: the tile `name` is drawn in ({ tile, skin }), or null. */
|
|
247
|
+
const senderTile = (parts, name) => (name ? parts.find((p) => desksOf(p.L).some((d) => d.node.name === name)) || null : null);
|
|
248
|
+
/**
|
|
249
|
+
* Whether an agent's act (office_set, #2578) holds now: not past its end; its person not called (a call wins) nor
|
|
250
|
+
* asleep; and no new work begun since — `seen` (kept by the page) remembers each act whose person was seen not working,
|
|
251
|
+
* so working again after that (or after acting while idle) is new work, and the act gives way. The turn it acted in
|
|
252
|
+
* (working at the time) keeps it.
|
|
253
|
+
*/
|
|
254
|
+
export function actLive(a, state, at = Date.now(), seen = null) {
|
|
255
|
+
if (!a || at >= a.until || CALLING.has(state) || ASLEEP.has(state)) return false;
|
|
256
|
+
if (!seen) return true;
|
|
257
|
+
const k = `${a.instance}@${a.since ?? a.until}`;
|
|
258
|
+
if (state !== "working") { seen.add(k); return true; }
|
|
259
|
+
return !seen.has(k) && (!a.state || a.state === "working");
|
|
260
|
+
}
|
|
261
|
+
/** How far beside the person an agent going to them stands. */
|
|
262
|
+
const ACT_BESIDE = 48;
|
|
263
|
+
/** Where an agent's act sends it at `t`: its point, beside the person it goes to (where they are now), or null. */
|
|
264
|
+
export function actTarget(a, L, walkers, t) {
|
|
265
|
+
if (a.x !== undefined) return { x: a.x, y: a.y };
|
|
266
|
+
if (!a.person) return null;
|
|
267
|
+
const d = desksOf(L).find((x) => x.node.name === a.person), there = (walkers && walkers.at(a.person, t)) || (d && d.seat);
|
|
268
|
+
return there ? { x: Math.round(Math.min(L.width, there.x + ACT_BESIDE)), y: Math.round(there.y) } : null;
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* A look in its agent's own pose (office_set, #2578) while its act holds, else as it is. In bed for the night (#2568,
|
|
272
|
+
* `look.night`) it is asleep, as for mail (#2569): bedtime wins over an act.
|
|
273
|
+
*/
|
|
274
|
+
function actPosed(p, look) {
|
|
275
|
+
const act = look && p.acts && p.acts.get(look.name);
|
|
276
|
+
return act && act.pose && actLive(act, look.night ? "paused" : look.state, Date.now(), p.actSeen) ? { ...look, pose: officePose(act.pose) } : look;
|
|
277
|
+
}
|
|
278
|
+
/** 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). */
|
|
279
|
+
function paintLayers(p, env, ls, tagsOff = false) {
|
|
280
|
+
const { L, skin, zones, looks, all } = p;
|
|
281
|
+
const lw = Math.max(1, Math.ceil(L.width * ls)), lh = Math.max(1, Math.ceil(L.height * ls));
|
|
282
|
+
const layer = (fn) => {
|
|
283
|
+
const c = document.createElement("canvas");
|
|
284
|
+
c.width = lw; c.height = lh;
|
|
285
|
+
const g = c.getContext && c.getContext("2d");
|
|
286
|
+
if (!g) return null;
|
|
287
|
+
g.setTransform(ls, 0, 0, ls, 0, 0);
|
|
288
|
+
g.imageSmoothingEnabled = false;
|
|
289
|
+
fn(g);
|
|
290
|
+
return c;
|
|
291
|
+
};
|
|
292
|
+
const back = layer((g) => {
|
|
293
|
+
g.fillStyle = env.pal.bg; g.fillRect(0, 0, L.width, L.height);
|
|
294
|
+
if (zones) {
|
|
295
|
+
skin.drawBackground(g, L, env);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
skin.drawFloor(g, L, env);
|
|
299
|
+
for (const r of L.rooms) skin.drawRoom(g, r, env);
|
|
300
|
+
for (const d of all) skin.drawDeskBack(g, d, looks.get(d.key), env);
|
|
301
|
+
});
|
|
302
|
+
const front = zones ? null : layer((g) => {
|
|
303
|
+
for (const d of all) { const lk = looks.get(d.key); skin.drawDeskFront(g, d, tagsOff && lk && !lk.match ? { ...lk, notag: true } : lk, env); }
|
|
304
|
+
});
|
|
305
|
+
return { ls, tagsOff, back, front };
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* One frame of a scene at `ms` (0: still), a logical unit `a` bitmap px, its origin at (ox, oy); `hot`: the desk key
|
|
309
|
+
* hovered (its name tag shows even in a crowd); `tagsOff` (#2554: a tile too small to read them): the name tags show
|
|
310
|
+
* only for the one hovered and the Find box's matches. Whether anything in it is moving (in the air, or someone walking).
|
|
311
|
+
*/
|
|
312
|
+
function drawPart(ctx, p, a, ox, oy, ms, still, hot, env, tagsOff = false, defer = null) {
|
|
313
|
+
const sk = p.skin;
|
|
314
|
+
const ls = layerScale(a, p.L.width, p.L.height);
|
|
315
|
+
if (!p.layers || p.layers.ls !== ls || p.layers.tagsOff !== tagsOff) p.layers = paintLayers(p, env, ls, tagsOff);
|
|
316
|
+
const T = () => ctx.setTransform(a, 0, 0, a, ox, oy);
|
|
317
|
+
const blit = (c) => { if (!c) return; ctx.setTransform(a / ls, 0, 0, a / ls, ox, oy); ctx.drawImage(c, 0, 0); T(); };
|
|
318
|
+
blit(p.layers.back);
|
|
319
|
+
T();
|
|
320
|
+
const speakers = [], tags = [];
|
|
321
|
+
const up = still ? [] : p.flights.frame(ms, p.L);
|
|
322
|
+
// Mail from another office (#2564): flying in at the door, or on the floor until its person picks it up.
|
|
323
|
+
const post = still || !p.mail || !p.zones ? [] : p.mail.frame(ms > 0 ? ms : now(), p.L, p.walkers);
|
|
324
|
+
let walking = false;
|
|
325
|
+
if (p.zones) {
|
|
326
|
+
// Furniture under the people; the people (nearest last) with the furniture whose depth is "y" among them, by base
|
|
327
|
+
// y (a tie: the item first, the person in front); furniture over them; things in the air; the pack's foreground
|
|
328
|
+
// (door frames, pillars, table fronts); then what must stay readable: crowd counts and name tags (bubbles below).
|
|
329
|
+
const t = ms > 0 ? ms : now();
|
|
330
|
+
const people = p.desks.map((d) => {
|
|
331
|
+
const at = p.walkers.at(d.node.name, t) || { ...d.seat, walking: false };
|
|
332
|
+
if (at.walking) walking = true;
|
|
333
|
+
return { d, at, look: p.looks.get(d.key) };
|
|
334
|
+
}).sort((x, y) => x.at.y - y.at.y);
|
|
335
|
+
const furniture = strollFurniture(p.night ? nightFurniture(sk.pack.furniture) : sk.pack.furniture, (k) => p.walkers.at(k, t));
|
|
336
|
+
if (furniture.some((f) => f.walking)) walking = true;
|
|
337
|
+
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 })),
|
|
338
|
+
...(p.ghosts ? ghostsOf(sk.pack, people).map((g) => ({ y: g.y, k: 1, g })) : []),
|
|
339
|
+
// #2665: by day, each item's night place too (at night the items are there already).
|
|
340
|
+
...(p.ghosts && !p.night ? nightGhostsOf(sk.pack).map((n) => ({ y: n.y, k: 1, n })) : [])]
|
|
341
|
+
.sort((x, y) => x.y - y.y || x.k - y.k);
|
|
342
|
+
for (const f of furniture) if (f.depth !== "y" && f.depth < CHAR_DEPTH) sk.drawFurniture(ctx, f, f.asleep || p.active.has(f.id), ms, env);
|
|
343
|
+
for (const it of post) if (it.t === 0) sk.drawProp(ctx, it, ms, env); // a letter on the floor: under the people
|
|
344
|
+
for (const { f, p: q, g, n } of band) {
|
|
345
|
+
if (f) { sk.drawFurniture(ctx, f, f.asleep || p.active.has(f.id), ms, env); continue; }
|
|
346
|
+
if (n) { ctx.save(); ctx.globalAlpha = GHOST_ALPHA; sk.drawFurniture(ctx, n, true, 0, env); ctx.restore(); continue; }
|
|
347
|
+
if (g) { ctx.save(); ctx.globalAlpha = GHOST_ALPHA; sk.drawCharacter(ctx, { x: g.x - 44, y: g.y - 53, w: 88, h: 53, seat: g }, g.look, 0, env); ctx.restore(); continue; }
|
|
348
|
+
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 } };
|
|
349
|
+
// An agent's own pose (office_set, #2578) while its act lasts and nothing calls it; walking is walking.
|
|
350
|
+
const posed = actPosed(p, q.look);
|
|
351
|
+
const look = q.at.walking ? { ...q.look, pose: "walk", heading: q.at.heading } : posed;
|
|
352
|
+
sk.drawCharacter(ctx, box, look, ms, env);
|
|
353
|
+
if (!q.at.walking) speakers.push({ look: q.look, ...speakerAt(sk, look, q.at), area: q.d.area });
|
|
354
|
+
}
|
|
355
|
+
for (const f of furniture) if (f.depth !== "y" && f.depth >= CHAR_DEPTH) sk.drawFurniture(ctx, f, f.asleep || p.active.has(f.id), ms, env);
|
|
356
|
+
for (const it of up) sk.drawProp(ctx, it, ms, env);
|
|
357
|
+
for (const it of post) if (it.t > 0) sk.drawProp(ctx, it, ms, env);
|
|
358
|
+
if (sk.drawForeground) sk.drawForeground(ctx, p.L, env);
|
|
359
|
+
// Who found no spot in an area: a count in its corner (never over its name).
|
|
360
|
+
for (const [ar, n] of p.L.crowd || []) if (p.L.areas[ar] && sk.drawCrowd) sk.drawCrowd(ctx, p.L.areas[ar], n, env);
|
|
361
|
+
// In an area over capacity the name tags show on hover or focus only (#2513: a crowd's tags covered each other).
|
|
362
|
+
const over = p.L.over || new Set();
|
|
363
|
+
for (const q of people) {
|
|
364
|
+
if ((tagsOff || over.has(q.d.area)) && q.d.key !== hot && !q.look.match) continue;
|
|
365
|
+
sk.drawTag(ctx, q.at.x, q.at.y, q.look, env);
|
|
366
|
+
tags.push(tagBox(ctx, q.at.x, q.at.y, q.look.label));
|
|
367
|
+
}
|
|
368
|
+
// A furniture item's own name (#2574: an NPC drawn as furniture), tagged like a person; none on a tile too small to read.
|
|
369
|
+
if (!tagsOff) for (const f of furniture) if (f.label) { sk.drawTag(ctx, f.x, f.y, { label: f.label }, env); tags.push(tagBox(ctx, f.x, f.y, f.label)); }
|
|
370
|
+
} else {
|
|
371
|
+
for (const d of p.desks) { const look = p.looks.get(d.key); sk.drawCharacter(ctx, d, actPosed(p, look), ms, env); speakers.push({ look, x: d.x + 58, y: d.y + 12 }); }
|
|
372
|
+
blit(p.layers.front);
|
|
373
|
+
// Tags left out of the layer: the hovered desk's front again, with its tag.
|
|
374
|
+
const hd = tagsOff && hot ? p.desks.find((d) => d.key === hot) : null;
|
|
375
|
+
if (hd) sk.drawDeskFront(ctx, hd, p.looks.get(hd.key), env);
|
|
376
|
+
for (const it of up) sk.drawProp(ctx, it, ms, env);
|
|
377
|
+
}
|
|
378
|
+
// Speech bubbles: a short line for the state, now and then (typed out; whole at once when still), placed so no two
|
|
379
|
+
// overlap (placeBubbles; #2431).
|
|
380
|
+
const bubbles = [];
|
|
381
|
+
for (const sp of speakers) {
|
|
382
|
+
if (sp.look.dim || sp.look.pose === "away") continue;
|
|
383
|
+
const says = sp.look.night ? "paused" : sp.look.state; // asleep at night: its "Zzz…" lines
|
|
384
|
+
// What an agent says itself (office_set, #2578): whole, kept over the area's thinning like a call.
|
|
385
|
+
const act = p.acts && p.acts.get(sp.look.name), own = actLive(act, says, Date.now(), p.actSeen) && act.say ? act.say : null;
|
|
386
|
+
const b = own ? { text: own, shown: [...own].length } : bubbleAt(sp.look.name, says, ms, linesFor(says, p.lang, sk.pack, p.lines));
|
|
387
|
+
if (b) bubbles.push({ key: sp.look.name, area: sp.area, state: sp.look.state, calling: CALLING.has(sp.look.state), rank: own ? 0 : bubbleRank(sp.look.state), shown: b.shown,
|
|
388
|
+
box: bubbleBox(ctx, sp.x, sp.y, b.text), tail: { x: sp.x, y: sp.y }, sp, b });
|
|
389
|
+
}
|
|
390
|
+
// A crowded area speaks less (thinBubbles: calls, then work, a few at most); no bubble covers a name tag. `defer`
|
|
391
|
+
// (All offices): drawn after every tile, unclipped, so a bubble at a tile's edge is whole.
|
|
392
|
+
const placed = placeBubbles(thinBubbles(bubbles, p.zones ? p.L.over : null), 22, tags);
|
|
393
|
+
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); };
|
|
394
|
+
if (defer) defer.push(say); else say();
|
|
395
|
+
return walking || up.length > 0 || p.flights.size() > 0 || post.some((it) => it.t > 0);
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* 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
|
|
399
|
+
* in (an uploaded character's strip, anchor and size: #2513 review), nearest first; on desks, the desk.
|
|
400
|
+
*/
|
|
401
|
+
function hitPart(p, x, y, t) {
|
|
402
|
+
if (!p.zones) return deskAt(p.L, x, y);
|
|
403
|
+
return personAt(p.all, x, y, (d) => p.walkers.at(d.node.name, t),
|
|
404
|
+
(d, at) => (p.skin.charBox && p.looks.get(d.key) ? p.skin.charBox(p.looks.get(d.key), at) : null));
|
|
405
|
+
}
|
|
406
|
+
|
|
98
407
|
function People({ nodes, pinned, onChange }) {
|
|
99
408
|
const [open, setOpen] = useState(false);
|
|
100
409
|
const [find, setFind] = useState("");
|
|
@@ -124,26 +433,53 @@ function People({ nodes, pinned, onChange }) {
|
|
|
124
433
|
|
|
125
434
|
/**
|
|
126
435
|
* The view: `view` buildOrgView's answer; `traffic` the live items (null: Live is off); `q` the Find box's needle;
|
|
127
|
-
* `lease` the tab's (its reads); `onOpen(name)` what a desk opens (the canvas's: the chat
|
|
436
|
+
* `lease` the tab's (its reads); `onOpen(name)` what a desk opens (the canvas's: the chat; none: a desk opens nothing,
|
|
437
|
+
* as on a share link's page, #2636); `assetUrl(path)` where a skin pack's picture is read (the share's address), and
|
|
438
|
+
* `assetImage(url)` how (the share's, which waits out a 429, #2662; none: the loader's own);
|
|
439
|
+
* `opening` the office it opens on (the share's), instead of the one this browser chose last.
|
|
128
440
|
*/
|
|
129
|
-
export function Office({ view, traffic, q = "", lease, onOpen }) {
|
|
441
|
+
export function Office({ view, traffic, q = "", lease, onOpen, assetUrl, assetImage, opening }) {
|
|
130
442
|
const store = useMemo(() => createOfficeStore(lease ? { fetch: (path, o) => lease.fetch(path, o) } : {}), [lease]);
|
|
131
443
|
const [stored, setStored] = useState([]);
|
|
132
444
|
const [storedRev, setStoredRev] = useState(null); // the rev the list was read at (a save sends it)
|
|
133
445
|
const [canEdit, setCanEdit] = useState(false); // an admin session (never a guest or the public link)
|
|
446
|
+
const [canShare, setCanShare] = useState(false); // #2636: an admin session, here or over the public link
|
|
134
447
|
const [editing, setEditing] = useState(false);
|
|
135
|
-
const [choice, setChoice] = useState(() => store.choice());
|
|
448
|
+
const [choice, setChoice] = useState(() => opening ?? store.choice());
|
|
136
449
|
const [filter, setFilter] = useState(() => store.filter());
|
|
137
450
|
const [style, setStyle] = useState(() => store.skin());
|
|
138
|
-
const [
|
|
139
|
-
|
|
451
|
+
const [hideAsleep, setHideAsleep] = useState(() => store.hideAsleep());
|
|
452
|
+
// Bedtime (#2568), when it is on here: the idle sleep from NIGHT.FROM to NIGHT.TO on this device's clock (read every NIGHT.TICK).
|
|
453
|
+
const [nightOn, setNightOn] = useState(() => store.nightOn());
|
|
454
|
+
const [nightNow, setNightNow] = useState(() => isNight());
|
|
455
|
+
useEffect(() => { if (!nightOn) return undefined; setNightNow(isNight()); const h = setInterval(() => setNightNow(isNight()), NIGHT.TICK); return () => clearInterval(h); }, [nightOn]);
|
|
456
|
+
const night = nightOn && nightNow;
|
|
457
|
+
const [lobbyOn, setLobbyOn] = useState(() => store.lobbyTile()); // #2554: All offices' lobby tile
|
|
458
|
+
const [packs, setPacks] = useState([]); // the skin packs the host keeps: [{ id, name, sha }]
|
|
459
|
+
const [packSkins, setPackSkins] = useState(() => new Map()); // id@sha → a loaded skin, or null (not a pack)
|
|
460
|
+
const [cozyRev, setCozyRev] = useState(0); // #2631: the cozy's picture came in (skinFor has it)
|
|
461
|
+
// Edit layout's session while it is open: { id, officeId, skin, pack (the style's identity), base (the pack as kept),
|
|
462
|
+
// start, draft, rev, list (the offices and rev read when it opened — what Save sends) }; null when closed.
|
|
463
|
+
const [layout, setLayout] = useState(null);
|
|
464
|
+
const layoutRef = useRef(null), layoutSeq = useRef(0);
|
|
465
|
+
layoutRef.current = layout;
|
|
466
|
+
const [width, setWidth] = useState(SIZE0.w); // the stage's size (CSS px; the canvas fills it)
|
|
467
|
+
const [stageH, setStageH] = useState(0); // (0: not known yet)
|
|
468
|
+
const [dpr, setDpr] = useState(deviceRatio);
|
|
140
469
|
const [themeRev, setThemeRev] = useState(0);
|
|
141
470
|
const [pal, setPal] = useState(DEFAULT_PAL); // the tokens in force (read once the stage is there)
|
|
142
471
|
const [tip, setTip] = useState(null); // the desk key the tooltip is for
|
|
143
472
|
const stageRef = useRef(null), canvasRef = useRef(null), tipRef = useRef(null), lastType = useRef("mouse");
|
|
473
|
+
// Fullscreen: null, "native" (the Fullscreen API) or "immersive" (the CSS cover); per visit, never kept.
|
|
474
|
+
const ooRef = useRef(null), [fs, setFs] = useState(null), fsRef = useRef(null), fsAsked = useRef(false), fsTarget = useRef(null);
|
|
475
|
+
fsRef.current = fs;
|
|
144
476
|
const flights = useMemo(() => createFlights(), []);
|
|
145
477
|
const walkers = useMemo(() => createWalkers(), []);
|
|
146
478
|
const seatsRef = useRef(new Map()); // the zones layout's last seats: kept where they can be
|
|
479
|
+
// #2554: All offices' tiles keep their own: seats (by tile and style), walkers and flights (by tile); and the items
|
|
480
|
+
// flying between tiles.
|
|
481
|
+
const tileSeats = useRef(new Map()), tileWalkers = useRef(new Map()), tileFlights = useRef(new Map());
|
|
482
|
+
const cross = useMemo(() => createCrossFlights(), []);
|
|
147
483
|
const still = useMemo(reducedMotion, []);
|
|
148
484
|
|
|
149
485
|
// The host's offices: on opening, and again whenever the stream says they changed. Answers can arrive out of order
|
|
@@ -169,14 +505,43 @@ export function Office({ view, traffic, q = "", lease, onOpen }) {
|
|
|
169
505
|
const off = sub ? lease.hold(sub("offices", () => { void read(); })) : null;
|
|
170
506
|
return () => { if (off) off(); };
|
|
171
507
|
}, [lease, store]);
|
|
508
|
+
// #2578: what agents do here by their own hand (office_set) — on opening, then as the stream brings it.
|
|
509
|
+
// A read a newer one or the stream overtook is dropped (it is older: review — an opening read answered after the one
|
|
510
|
+
// the stream's return began brought a cleared act back); read again when the stream comes back, and — with no live
|
|
511
|
+
// stream (the public link, or a stream polling) — every POLL_MS while the page is shown.
|
|
512
|
+
const [acts, setActs] = useState([]);
|
|
513
|
+
const actSeen = useRef(new Set());
|
|
514
|
+
useEffect(() => {
|
|
515
|
+
if (!lease) return undefined;
|
|
516
|
+
let heard = 0; // reads begun and events heard: the newest wins
|
|
517
|
+
const read = async () => { const at = ++heard, d = await store.acts(); if (lease.current() && d && at === heard) setActs(d); };
|
|
518
|
+
void read();
|
|
519
|
+
const sub = appStore.get().streamOn;
|
|
520
|
+
const polled = () => globalThis.document?.body?.dataset?.webTransport === "poll" || appStore.get().connection === "polling";
|
|
521
|
+
const offs = sub ? [lease.hold(sub("office_acts", (d) => { heard++; if (d && Array.isArray(d.acts)) setActs(actsOk(d.acts, d.now)); else void read(); })),
|
|
522
|
+
lease.hold(sub("connection", (c) => { if (c === "live") void read(); }))] : [];
|
|
523
|
+
const h = lease.interval(() => { if (polled() && !globalThis.document?.hidden) void read(); }, POLL_MS);
|
|
524
|
+
return () => { for (const off of offs) off(); lease.clear(h); };
|
|
525
|
+
}, [lease, store]);
|
|
526
|
+
// The skin packs: on opening, and again whenever the stream says one was uploaded or deleted.
|
|
527
|
+
useEffect(() => {
|
|
528
|
+
if (!lease) return undefined;
|
|
529
|
+
const read = async () => { const d = await store.skins(); if (lease.current() && d) setPacks(d.packs); };
|
|
530
|
+
void read();
|
|
531
|
+
const sub = appStore.get().streamOn;
|
|
532
|
+
const off = sub ? lease.hold(sub("office_skins", () => { void read(); })) : null;
|
|
533
|
+
return () => { if (off) off(); };
|
|
534
|
+
}, [lease, store]);
|
|
172
535
|
// Whether this page may edit the offices: an admin session (the host asks again on every save), never a guest page or
|
|
173
|
-
// the public link (whose sessions the host refuses outright).
|
|
536
|
+
// the public link (whose sessions the host refuses outright). #2636 follow-up (the user's choice): Share… is an admin
|
|
537
|
+
// session's here or over the public link (the host asks again too); never a guest's or a read-only one's.
|
|
174
538
|
useEffect(() => {
|
|
175
539
|
const body = globalThis.document?.body;
|
|
176
|
-
if (!body || body.dataset.tier === "guest"
|
|
540
|
+
if (!body || body.dataset.tier === "guest") return undefined;
|
|
541
|
+
const overLink = body.dataset.publicLink === "1";
|
|
177
542
|
let live = true;
|
|
178
543
|
const auth = globalThis.AgendAuth;
|
|
179
|
-
if (auth && typeof auth.canWrite === "function") Promise.resolve(auth.canWrite()).then((v) => { if (live) setCanEdit(v === true); }, () => {});
|
|
544
|
+
if (auth && typeof auth.canWrite === "function") Promise.resolve(auth.canWrite()).then((v) => { if (live) { setCanEdit(v === true && !overLink); setCanShare(v === true); } }, () => {});
|
|
180
545
|
return () => { live = false; };
|
|
181
546
|
}, []);
|
|
182
547
|
// The stage's width (the grid wraps at it), and the theme (the tokens the canvas reads).
|
|
@@ -186,6 +551,7 @@ export function Office({ view, traffic, q = "", lease, onOpen }) {
|
|
|
186
551
|
const w = el && el.clientWidth, h = el && el.clientHeight;
|
|
187
552
|
if (w > 0) setWidth((cur) => (Math.abs(cur - w) >= 1 ? Math.floor(w) : cur));
|
|
188
553
|
if (h > 0) setStageH((cur) => (Math.abs(cur - h) >= 1 ? Math.floor(h) : cur));
|
|
554
|
+
setDpr(deviceRatio()); // the browser's zoom changes it
|
|
189
555
|
};
|
|
190
556
|
measure();
|
|
191
557
|
const ro = typeof ResizeObserver === "function" ? new ResizeObserver(measure) : null;
|
|
@@ -204,126 +570,412 @@ export function Office({ view, traffic, q = "", lease, onOpen }) {
|
|
|
204
570
|
setPal((cur) => (JSON.stringify(cur) === JSON.stringify(next) ? cur : next));
|
|
205
571
|
}, [themeRev]);
|
|
206
572
|
const mode = modeOf(pal);
|
|
573
|
+
const size = { w: width, h: stageH > 0 ? stageH : SIZE0.h };
|
|
574
|
+
// The canvas bitmap's px per CSS px: the device's, unless the bitmap would be too big to paint (backingScale). Zoom
|
|
575
|
+
// snaps to whole numbers of these (fleet-office-view.js), so a pixel-level view maps a logical unit onto whole pixels.
|
|
576
|
+
const ratio = backingScale(size.w, size.h, dpr);
|
|
207
577
|
|
|
208
|
-
|
|
209
|
-
const
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
}, [
|
|
230
|
-
|
|
231
|
-
|
|
578
|
+
// #2554: All offices — chosen, and at least two offices stored (else the whole fleet, as an office gone is).
|
|
579
|
+
const overview = choice === ALL_ID && overviewOk(stored);
|
|
580
|
+
const custom = choice !== AUTO_ID && !overview ? stored.find((o) => o.id === choice) || null : null;
|
|
581
|
+
// The style: a built-in one, or an uploaded pack once it is loaded (until then, and when it is not one, Desks).
|
|
582
|
+
const wanted = custom ? custom.skin : style;
|
|
583
|
+
// A kept pack wins its id: one kept as "cozy" before it was built in (#2631) is still the style of the offices naming it.
|
|
584
|
+
const packOf = (id) => packs.find((pk) => pk.id === id) || null;
|
|
585
|
+
const styles = STYLES.filter((id) => !packOf(id));
|
|
586
|
+
const packInfo = packOf(wanted);
|
|
587
|
+
const packKey = packInfo ? `${packInfo.id}@${packInfo.sha}` : null;
|
|
588
|
+
// The packs to load: this office's style; in All offices every tile's (each loaded once, shared by the tiles using it).
|
|
589
|
+
const needed = [...new Map((overview ? [...stored.map((o) => o.skin), ...(lobbyOn ? [style] : [])] : [wanted]).map(packOf).filter(Boolean)
|
|
590
|
+
.map((pk) => [`${pk.id}@${pk.sha}`, pk])).entries()];
|
|
591
|
+
const neededKey = needed.map(([k]) => k).join(",");
|
|
592
|
+
useEffect(() => {
|
|
593
|
+
const want = needed.filter(([k]) => !packSkins.has(k));
|
|
594
|
+
if (!want.length) return undefined;
|
|
595
|
+
let live = true;
|
|
596
|
+
const get = lease ? (path, o) => lease.fetch(path, o) : undefined;
|
|
597
|
+
for (const [k, pk] of want) loadPackSkin(pk.id, pk.sha, { ...(get ? { fetch: get } : {}), ...(assetUrl ? { url: assetUrl } : {}), ...(assetImage ? { image: assetImage } : {}) }).then((sk) => { if (live) setPackSkins((m) => new Map(m).set(k, sk)); });
|
|
598
|
+
return () => { live = false; };
|
|
599
|
+
}, [neededKey]);
|
|
600
|
+
const loading = !!packInfo && !packSkins.has(packKey);
|
|
601
|
+
const baseSkin = packInfo ? packSkins.get(packKey) || skinFor("pixel") : skinFor(STYLES.includes(wanted) ? wanted : custom ? wanted : "pixel");
|
|
602
|
+
// #2631: the cozy's picture, once, when this office (or a tile, or the lobby tile) is drawn in it; until then its bare floors.
|
|
603
|
+
const cozyWanted = (overview ? [...stored.map((o) => o.skin), ...(lobbyOn ? [style] : [])] : [wanted]).includes("cozy");
|
|
232
604
|
useEffect(() => {
|
|
233
|
-
if (!
|
|
234
|
-
|
|
605
|
+
if (!cozyWanted) return undefined;
|
|
606
|
+
let live = true;
|
|
607
|
+
loadCozySkin().then((sk) => { if (live && sk && sk !== baseSkin) setCozyRev((n) => n + 1); });
|
|
608
|
+
return () => { live = false; };
|
|
609
|
+
}, [cozyWanted]);
|
|
610
|
+
const zones = !!(baseSkin.pack && baseSkin.pack.stateZones);
|
|
611
|
+
/** The style's identity: a pack's id and sha (a pack replaced is another), or the built-in skin's id. */
|
|
612
|
+
const styleKey = packKey || baseSkin.id;
|
|
613
|
+
// An open session no longer on its office and style (the office went, or its style changed meanwhile): only Cancel.
|
|
614
|
+
const layoutStale = !!layout && (!custom || custom.id !== layout.officeId || custom.skin !== layout.skin || styleKey !== layout.pack || !zones || loading);
|
|
615
|
+
// A stored office's own layout on a zones style (its overrides; while Edit layout is open, the draft).
|
|
616
|
+
const placed = useMemo(() => {
|
|
617
|
+
if (zones && layout && !layoutStale) return { skin: { ...baseSkin, pack: layout.draft }, ignored: false };
|
|
618
|
+
return withOverrides(baseSkin, zones, custom);
|
|
619
|
+
}, [baseSkin, zones, custom, layout, layoutStale]);
|
|
620
|
+
const skin = placed.skin;
|
|
621
|
+
// While Edit layout is open: a faint figure on every seat, in the pose its area is for (#2558), so a seat is placed by
|
|
622
|
+
// what will stand, sit or lie there, not by a dot.
|
|
623
|
+
const ghosts = zones && !!layout && !layoutStale;
|
|
624
|
+
const keep = (n) => !(hideAsleep && ASLEEP.has(n.state));
|
|
625
|
+
const single = useMemo(() => {
|
|
626
|
+
if (overview) return { rooms: [], width: 1, height: 1 }; // All offices: the tiles below
|
|
627
|
+
const all = custom ? membersOf(custom, view) : [...new Map(officeRooms(view, filter).flatMap((r) => r.nodes).map((n) => [n.name, n])).values()];
|
|
628
|
+
const nodes = all.filter(keep);
|
|
629
|
+
if (zones) { const z = zonesLayout(skin.pack, nodes, seatsRef.current, { night }); seatsRef.current = z.seats; return z; }
|
|
630
|
+
// The desks wrap at the stage's width at the scale they open on (desksK: a pixel level near one CSS px a unit).
|
|
631
|
+
const deskW = Math.max(240, Math.floor(width / desksK(ratio)));
|
|
632
|
+
if (!custom) return autoLayout(officeRooms(view, filter).map((r) => (hideAsleep ? { ...r, nodes: r.nodes.filter(keep) } : r)), { width: deskW });
|
|
633
|
+
return officeScene(custom, nodes, skin, false, deskW);
|
|
634
|
+
}, [overview, view, filter, custom, width, ratio, skin, hideAsleep, night]);
|
|
635
|
+
// ── #2554: All offices' tiles — each stored office (then the lobby: whoever is in no office) laid out as its own view
|
|
636
|
+
// lays it out, in its own style (desks wrap at a cell's width), with its own seats, walkers and flights ──
|
|
637
|
+
const lobbyAll = useMemo(() => (overview ? lobbyNodes(stored, view) : []), [overview, stored, view]);
|
|
638
|
+
const lobbyList = lobbyOn ? lobbyAll : [];
|
|
639
|
+
const tiles = useMemo(() => {
|
|
640
|
+
if (!overview) return [];
|
|
641
|
+
const mine = (m, k, make) => { if (!m.current.has(k)) m.current.set(k, make()); return m.current.get(k); };
|
|
642
|
+
const specs = stored.map((o) => ({ key: o.id, office: o, name: o.name, lobby: false, wanted: o.skin, nodes: membersOf(o, view) }));
|
|
643
|
+
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 });
|
|
644
|
+
return specs.map((sp) => {
|
|
645
|
+
const info = packOf(sp.wanted), pk = info ? `${info.id}@${info.sha}` : null;
|
|
646
|
+
const base = info ? packSkins.get(pk) || skinFor("pixel") : skinFor(sp.wanted);
|
|
647
|
+
const z = !!(base.pack && base.pack.stateZones);
|
|
648
|
+
const sk = withOverrides(base, z, sp.lobby ? null : sp.office).skin;
|
|
649
|
+
const seatKey = `${sp.key}@${pk || base.id}`;
|
|
650
|
+
// A desks office is as large as its rooms (cropped: no margins, no corridors outside them).
|
|
651
|
+
const TL0 = officeScene(sp.office, sp.nodes.filter(keep), sk, z, OV.CELL_W, tileSeats.current.get(seatKey), night), TL = z ? TL0 : cropToRooms(TL0);
|
|
652
|
+
if (z) tileSeats.current.set(seatKey, TL0.seats);
|
|
653
|
+
return { key: sp.key, name: sp.name, lobby: sp.lobby, count: sp.nodes.length, skin: sk, zones: z, L: TL, prefix: `${sp.key}\u0001`,
|
|
654
|
+
walkers: mine(tileWalkers, sp.key, createWalkers), flights: mine(tileFlights, sp.key, createFlights) };
|
|
655
|
+
});
|
|
656
|
+
}, [overview, stored, view, packs, packSkins, cozyRev, hideAsleep, lobbyOn, lobbyAll, style, night]);
|
|
657
|
+
const arranged = useMemo(() => tileLayout(tiles.map((tl) => ({ key: tl.key, w: tl.L.width, h: tl.L.height })), size), [tiles, size.w, size.h]);
|
|
658
|
+
const tileLooks = useMemo(() => { const work = workOn(traffic); return tiles.map((tl) => looksOf(tl.L, work, q, mode, tl.zones)); }, [tiles, traffic, q, mode, themeRev]);
|
|
659
|
+
// The scene the view pans and zooms: the office's, or the overview of every tile.
|
|
660
|
+
const L = useMemo(() => (overview ? { mode: "overview", rooms: [], width: arranged.width, height: arranged.height } : single), [overview, single, arranged]);
|
|
661
|
+
const looks = useMemo(() => looksOf(L, workOn(traffic), q, mode, zones), [L, traffic, q, mode, themeRev]);
|
|
662
|
+
// #2564: what comes in from other offices — one office's and style's own: a letter's door and drop are that layout's,
|
|
663
|
+
// so another office (or style) starts with none (one shown meanwhile is not carried over to a different room).
|
|
664
|
+
// #2578: the acts of the people in this stored office (by name); none elsewhere. On a desks style (review: accepted
|
|
665
|
+
// acts drew nothing there) the line and the pose show; nobody walks (the host refuses go there).
|
|
666
|
+
const actsHere = useMemo(() => new Map(custom && !overview ? acts.filter((a) => a.office === custom.id).map((a) => [a.instance, a]) : []), [acts, custom, overview]);
|
|
667
|
+
const mailKey = `${custom ? custom.id : AUTO_ID}@${baseSkin.id}`;
|
|
668
|
+
const mail = useMemo(() => { const m = createMail(); m.prime(traffic || []); return m; }, [mailKey]);
|
|
669
|
+
useEffect(() => { flights.sync(traffic || [], now(), still); if (zones && !overview && mail.sync(traffic || [], now(), L, still)) loop.current?.wake(); }, [traffic, mail]);
|
|
670
|
+
// Zones: everyone's seat is where they walk to — an idle person now and then strolls near it and back (#2560; not
|
|
671
|
+
// with reduced motion, nor while Edit layout is open: the seats are being placed); someone with mail from another
|
|
672
|
+
// office walks to it first (#2564). An NPC drawn as furniture with a `walk` strolls near its spot alike (#2596).
|
|
673
|
+
const wander = zones && !overview && !still && !layout;
|
|
674
|
+
useEffect(() => {
|
|
675
|
+
if (!zones || overview) return undefined;
|
|
676
|
+
const go = () => {
|
|
677
|
+
const t = now(), to = walkTargets(desksOf(L), L, t, wander, skin.pack.furniture, night);
|
|
678
|
+
// An act's walk; not someone in bed for the night (#2568: bedtime wins, as for mail).
|
|
679
|
+
for (const d of desksOf(L)) { const a = actsHere.get(d.node.name), at = !d.night && actLive(a, d.node.state, Date.now(), actSeen.current) ? actTarget(a, L, walkers, t) : null; if (at) to.set(d.node.name, at); }
|
|
680
|
+
for (const [name, at] of mail.targets(L, t)) to.set(name, at);
|
|
681
|
+
walkers.sync(to, t, L, still);
|
|
682
|
+
// Reduced motion has no loop: drawn again here, or an act's walk (set after the scene was drawn) never shows.
|
|
683
|
+
if (still) { if (scene.current && !globalThis.document?.hidden) draw(0); } else loop.current?.wake();
|
|
684
|
+
};
|
|
685
|
+
go();
|
|
686
|
+
if (still) return undefined;
|
|
687
|
+
const h = setInterval(go, WANDER.TICK);
|
|
688
|
+
return () => clearInterval(h);
|
|
689
|
+
}, [L, wander, mail, skin, night, actsHere]);
|
|
690
|
+
// …and in each tile on a zones style; the traffic within a tile is its own, between tiles the overview's.
|
|
691
|
+
useEffect(() => {
|
|
692
|
+
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);
|
|
693
|
+
loop.current?.wake();
|
|
694
|
+
}, [tiles]);
|
|
695
|
+
useEffect(() => {
|
|
696
|
+
if (!overview) return;
|
|
697
|
+
for (const tl of tiles) tl.flights.sync(traffic || [], now(), still);
|
|
698
|
+
cross.sync(traffic || [], now(), still);
|
|
235
699
|
loop.current?.wake();
|
|
236
|
-
}, [
|
|
700
|
+
}, [traffic, tiles]);
|
|
701
|
+
|
|
702
|
+
// ── The view (#2540): { k, x, y } as the org chart canvas's (fleet-canvas-view.js) — logical units → the stage's CSS
|
|
703
|
+
// px. Set without a render (a ref): the canvas redraws, the overview, the tooltip and Edit layout's overlay follow ──
|
|
704
|
+
const vp = useRef(null), raw = useRef(1); // raw: the wheel's and a pinch's zoom (before the range)
|
|
705
|
+
// touched: a person moved the view (it is kept); fitted: the view is a fit (a resize fits it again, to the new size).
|
|
706
|
+
const touched = useRef(false), fitted = useRef(false), drag = useRef(gestureState());
|
|
707
|
+
const miniViewRef = useRef(null), miniRef = useRef(null), overlayEl = useRef(null), tipDeskRef = useRef(null), pctRef = useRef(null);
|
|
708
|
+
const labelEls = useRef(new Map()); // #2554: the tiles' labels (tile key → element)
|
|
709
|
+
// What a view is kept for: the office and its style (All offices: its own key).
|
|
710
|
+
const officeKey = overview ? `${ALL_ID}@tiles` : `${custom ? custom.id : AUTO_ID}@${baseSkin.id}`;
|
|
711
|
+
const keyRef = useRef(officeKey), saveTimer = useRef(0);
|
|
712
|
+
// The fit mode (Fit: contain; Fill: cover), kept per office and style like the view.
|
|
713
|
+
const [fitRev, setFitRev] = useState(0);
|
|
714
|
+
const fitMode = useMemo(() => store.fitMode(officeKey), [store, officeKey, fitRev]);
|
|
715
|
+
const view0 = () => vp.current || { k: 1, x: 0, y: 0 };
|
|
716
|
+
const flushSave = () => {
|
|
717
|
+
if (saveTimer.current) { clearTimeout(saveTimer.current); saveTimer.current = 0; }
|
|
718
|
+
if (touched.current && vp.current) store.saveView(keyRef.current, savedOf(vp.current, sizeRef.current, fitted.current));
|
|
719
|
+
};
|
|
720
|
+
const sizeRef = useRef(size);
|
|
721
|
+
sizeRef.current = size;
|
|
722
|
+
/** Edit layout's overlay over the scene (placed through the CSSOM, never a style attribute: #1300). */
|
|
723
|
+
const placeOverlay = (el) => {
|
|
724
|
+
overlayEl.current = el || null;
|
|
725
|
+
if (!el || !el.style) return;
|
|
726
|
+
const v = view0();
|
|
727
|
+
el.style.setProperty("left", `${v.x}px`); el.style.setProperty("top", `${v.y}px`);
|
|
728
|
+
el.style.setProperty("width", `${L.width * v.k}px`); el.style.setProperty("height", `${L.height * v.k}px`);
|
|
729
|
+
};
|
|
730
|
+
/** The tooltip: what the canvas's card says, above the desk (below it at the top edge), wherever the view puts it. */
|
|
731
|
+
const placeTip = () => {
|
|
732
|
+
const el = tipRef.current, d = tipDeskRef.current;
|
|
733
|
+
if (!el || !d || !el.style) return;
|
|
734
|
+
const v = view0(), p = toScreen(v, d.x, d.y), x = Math.max(4, Math.min(p.x, size.w - 244)), above = p.y > 90;
|
|
735
|
+
el.style.setProperty("transform", above ? `translate(${x}px, ${p.y + 6}px) translateY(-100%)` : `translate(${x}px, ${p.y + d.h * v.k}px)`);
|
|
736
|
+
};
|
|
737
|
+
/**
|
|
738
|
+
* Put the view on everything that shows it beside the canvas: the overview's frame (the part of the scene in view;
|
|
739
|
+
* the whole view on its data-k/x/y), the zoom's percentage, the overlay, the tooltip.
|
|
740
|
+
*/
|
|
741
|
+
const mark = () => {
|
|
742
|
+
const v = view0(), m = miniViewRef.current;
|
|
743
|
+
if (m) {
|
|
744
|
+
const f = miniFrame(v, size, L.width, L.height);
|
|
745
|
+
for (const [a, val] of [["x", f.x], ["y", f.y], ["width", f.width], ["height", f.height], ["data-k", v.k], ["data-x", v.x], ["data-y", v.y]]) m.setAttribute(a, String(val));
|
|
746
|
+
}
|
|
747
|
+
const mb = miniRef.current;
|
|
748
|
+
if (mb && mb.style) { // the overview: inside the stage, scaled to it
|
|
749
|
+
const b = miniBox(size, L.width, L.height);
|
|
750
|
+
for (const [a, val] of [["left", b.x], ["top", b.y], ["width", b.w], ["height", b.h]]) mb.style.setProperty(a, `${val}px`);
|
|
751
|
+
}
|
|
752
|
+
const pc = pctRef.current;
|
|
753
|
+
if (pc) { const lb = zoomLabel(v.k); if (pc.textContent !== lb) pc.textContent = lb; pc.setAttribute("aria-label", to("zoomLevel", lb)); }
|
|
754
|
+
if (overlayEl.current) placeOverlay(overlayEl.current);
|
|
755
|
+
// 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
|
|
756
|
+
// edge); hidden while its tile is off screen (nothing past the stage's edge for a focus to scroll it to: the
|
|
757
|
+
// controls stay where they are).
|
|
758
|
+
const shownTiles = overview ? new Set(visibleTiles(arranged, v, size).map((tt) => tt.key)) : null;
|
|
759
|
+
for (const [i, tt] of (overview ? arranged.tiles : []).entries()) {
|
|
760
|
+
const el = labelEls.current.get(tt.key);
|
|
761
|
+
if (!el || !el.style) continue;
|
|
762
|
+
const lp = toScreen(v, tt.x, tt.y), next = arranged.tiles[i + 1];
|
|
763
|
+
const room = next && next.label.y === tt.label.y ? (next.x - tt.x) * v.k - 6 : size.w - lp.x - 8;
|
|
764
|
+
el.style.setProperty("left", `${lp.x}px`); el.style.setProperty("top", `${lp.y}px`);
|
|
765
|
+
el.style.setProperty("max-width", `${Math.max(40, room)}px`);
|
|
766
|
+
el.style.setProperty("visibility", shownTiles.has(tt.key) ? "visible" : "hidden");
|
|
767
|
+
}
|
|
768
|
+
const st = stageRef.current;
|
|
769
|
+
if (st && (st.scrollLeft || st.scrollTop)) { st.scrollLeft = 0; st.scrollTop = 0; }
|
|
770
|
+
placeTip();
|
|
771
|
+
};
|
|
772
|
+
/** …and redraw. */
|
|
773
|
+
const apply = () => {
|
|
774
|
+
mark();
|
|
775
|
+
if (scene.current) { scene.current.dirty = true; if (still) { if (!globalThis.document?.hidden) draw(0); } else loop.current?.wake(); }
|
|
776
|
+
};
|
|
777
|
+
/**
|
|
778
|
+
* A new view (kept on the scene: clampPan); `user`: a person moved it (it is kept, a moment later); `fit`: it is a
|
|
779
|
+
* fit (a resize fits it again).
|
|
780
|
+
*/
|
|
781
|
+
const setView = (v, user = true, fit = false) => {
|
|
782
|
+
vp.current = clampPan(v, L.width, L.height, size);
|
|
783
|
+
fitted.current = fit;
|
|
784
|
+
apply();
|
|
785
|
+
if (!user) return;
|
|
786
|
+
touched.current = true;
|
|
787
|
+
if (saveTimer.current) clearTimeout(saveTimer.current);
|
|
788
|
+
saveTimer.current = setTimeout(flushSave, 400);
|
|
789
|
+
};
|
|
790
|
+
/**
|
|
791
|
+
* The scene fitted (`mode`: contain or cover). All offices' contain keeps FIT_PAD px clear all round, so no tile sits
|
|
792
|
+
* under the zoom controls at the side.
|
|
793
|
+
*/
|
|
794
|
+
const fitL = (mode) => fitScene(L.width, L.height, size, mode, overview && mode !== "cover" ? FIT_PAD : 0);
|
|
795
|
+
/** The view an office opens on: its kept one (a kept fit: fitted to this size), else its opening; and whether a fit. */
|
|
796
|
+
const openView = () => {
|
|
797
|
+
const kept = store.view(officeKey);
|
|
798
|
+
if (kept && kept.fit) return { v: fitL(fitMode), fit: true };
|
|
799
|
+
if (kept) return { v: viewFromSaved(kept, size), fit: false };
|
|
800
|
+
const fitted0 = zones || overview; // All offices opens whole, as a zones scene
|
|
801
|
+
return { v: overview ? fitL(fitMode) : openingView(L.width, L.height, size, ratio, fitted0, fitMode), fit: fitted0 };
|
|
802
|
+
};
|
|
803
|
+
// An office (or style) opened: its kept view, else its opening (zones fitted; desks from the top left, or centred where smaller). A resize fits
|
|
804
|
+
// a fitted view again (and opens an untouched one again); a view a person moved keeps the same point in the middle.
|
|
805
|
+
// Either way it is then kept on the scene (clampPan): a kept view from a bigger panel cannot leave it off-screen.
|
|
806
|
+
const opened = useRef(null), sized = useRef(null);
|
|
807
|
+
useLayoutEffect(() => {
|
|
808
|
+
const was = sized.current;
|
|
809
|
+
sized.current = { w: size.w, h: size.h, ratio };
|
|
810
|
+
const reopen = () => { const o = openView(); vp.current = o.v; fitted.current = o.fit; };
|
|
811
|
+
if (opened.current !== officeKey) {
|
|
812
|
+
if (opened.current !== null) flushSave();
|
|
813
|
+
opened.current = officeKey; keyRef.current = officeKey; touched.current = false;
|
|
814
|
+
reopen();
|
|
815
|
+
} else if (was && (was.w !== size.w || was.h !== size.h || was.ratio !== ratio)) {
|
|
816
|
+
if (fitted.current) vp.current = fitL(fitMode);
|
|
817
|
+
else if (!touched.current) reopen();
|
|
818
|
+
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 }; }
|
|
819
|
+
} else if (fitted.current) vp.current = fitL(fitMode); // the scene's size changed
|
|
820
|
+
vp.current = clampPan(view0(), L.width, L.height, size);
|
|
821
|
+
raw.current = vp.current.k;
|
|
822
|
+
}, [officeKey, size.w, size.h, ratio, L.width, L.height]);
|
|
823
|
+
useEffect(() => () => flushSave(), []);
|
|
824
|
+
/** Fit (the button, `0`): the office's mode — `mode` when it was just switched. */
|
|
825
|
+
const fit = (mode = fitMode) => { const v = fitL(mode); raw.current = v.k; setView(v, true, true); };
|
|
826
|
+
/** Fill on or off: kept for this office, and fitted at once. */
|
|
827
|
+
const toggleFill = () => {
|
|
828
|
+
const next = fitMode === "cover" ? "contain" : "cover";
|
|
829
|
+
store.saveFitMode(officeKey, next); setFitRev((n) => n + 1);
|
|
830
|
+
fit(next);
|
|
831
|
+
};
|
|
832
|
+
/** + / −: the next pixel level about the stage's middle. */
|
|
833
|
+
const zoomStep = (dir) => { const v = view0(), k = stepK(v.k, dir, ratio); raw.current = k; setView(zoomTo(v, k, size.w / 2, size.h / 2)); };
|
|
834
|
+
/** Bring a box (logical units) into view, unchanged when it already is. */
|
|
835
|
+
const show = (b) => {
|
|
836
|
+
const v = view0(), p = toScreen(v, b.x, b.y);
|
|
837
|
+
if (p.x >= 8 && p.y >= 8 && p.x + b.w * v.k <= size.w - 8 && p.y + b.h * v.k <= size.h - 8) return;
|
|
838
|
+
setView(centreOn(v, size, b.x + b.w / 2, b.y + b.h / 2));
|
|
839
|
+
};
|
|
237
840
|
|
|
238
|
-
// ──
|
|
239
|
-
//
|
|
841
|
+
// ── Fullscreen: the API on the container where the page may (its promise, or webkit's events, say how it went), else
|
|
842
|
+
// immersive; leaving undoes whichever it was ──
|
|
843
|
+
const enterFs = () => {
|
|
844
|
+
const el = ooRef.current, d = globalThis.document;
|
|
845
|
+
if (!el || fsRef.current) return;
|
|
846
|
+
const req = el.requestFullscreen || el.webkitRequestFullscreen;
|
|
847
|
+
const enabled = d ? (d.fullscreenEnabled ?? d.webkitFullscreenEnabled) : false;
|
|
848
|
+
if (typeof req !== "function" || enabled === false) { setFs("immersive"); return; }
|
|
849
|
+
fsAsked.current = true; fsTarget.current = el;
|
|
850
|
+
let r;
|
|
851
|
+
try { r = req.call(el); } catch { fsAsked.current = false; setFs("immersive"); return; }
|
|
852
|
+
if (r && typeof r.then === "function") {
|
|
853
|
+
r.then(() => { if (ooRef.current === el && fullscreenEl() === el) setFs("native"); },
|
|
854
|
+
() => { if (fsAsked.current && ooRef.current === el) { fsAsked.current = false; setFs("immersive"); } });
|
|
855
|
+
}
|
|
856
|
+
};
|
|
857
|
+
const exitFs = () => {
|
|
858
|
+
fsAsked.current = false;
|
|
859
|
+
const el = ooRef.current, d = globalThis.document;
|
|
860
|
+
if (el && d && fullscreenEl() === el) {
|
|
861
|
+
const ex = d.exitFullscreen || d.webkitExitFullscreen;
|
|
862
|
+
try { const r = typeof ex === "function" ? ex.call(d) : null; if (r && typeof r.catch === "function") r.catch(() => {}); } catch { /* already out */ }
|
|
863
|
+
}
|
|
864
|
+
setFs(null);
|
|
865
|
+
};
|
|
866
|
+
const toggleFs = () => { if (fsRef.current) exitFs(); else enterFs(); };
|
|
867
|
+
const editingRef = useRef(false);
|
|
868
|
+
editingRef.current = editing;
|
|
869
|
+
// The browser's own way out (its Esc, its UI) and its refusals; `f` and Esc; leaving the page leaves fullscreen.
|
|
870
|
+
useEffect(() => {
|
|
871
|
+
const d = globalThis.document;
|
|
872
|
+
if (!d || typeof d.addEventListener !== "function") return undefined;
|
|
873
|
+
const change = () => {
|
|
874
|
+
const on = !!ooRef.current && fullscreenEl() === ooRef.current;
|
|
875
|
+
if (on) fsAsked.current = false;
|
|
876
|
+
setFs((cur) => (on ? "native" : cur === "native" ? null : cur));
|
|
877
|
+
};
|
|
878
|
+
const refused = () => { if (!fsAsked.current) return; fsAsked.current = false; setFs("immersive"); };
|
|
879
|
+
const key = (e) => {
|
|
880
|
+
if (e.defaultPrevented || e.altKey || e.ctrlKey || e.metaKey) return;
|
|
881
|
+
if (e.key === "Escape") { if (fsRef.current === "immersive") { e.preventDefault(); exitFs(); } return; }
|
|
882
|
+
if (e.key !== "f" && e.key !== "F") return;
|
|
883
|
+
const el = ooRef.current, tg = e.target;
|
|
884
|
+
// Only here: from inside the view, or with nothing focused (never while typing, nor over Edit offices' dialog).
|
|
885
|
+
if (!el || typing(tg) || editingRef.current || !(tg === d.body || tg === d.documentElement || tg === d || (tg && el.contains(tg)))) return;
|
|
886
|
+
e.preventDefault();
|
|
887
|
+
toggleFs();
|
|
888
|
+
};
|
|
889
|
+
const evs = [["fullscreenchange", change], ["webkitfullscreenchange", change], ["fullscreenerror", refused], ["webkitfullscreenerror", refused], ["keydown", key]];
|
|
890
|
+
for (const [n, fn] of evs) d.addEventListener(n, fn);
|
|
891
|
+
return () => {
|
|
892
|
+
for (const [n, fn] of evs) d.removeEventListener(n, fn);
|
|
893
|
+
fsAsked.current = false;
|
|
894
|
+
const el = fullscreenEl(), ex = d.exitFullscreen || d.webkitExitFullscreen;
|
|
895
|
+
if (el && (el === ooRef.current || el === fsTarget.current) && typeof ex === "function") {
|
|
896
|
+
try { const r = ex.call(d); if (r && typeof r.catch === "function") r.catch(() => {}); } catch { /* already out */ }
|
|
897
|
+
}
|
|
898
|
+
};
|
|
899
|
+
}, []);
|
|
900
|
+
// Immersive: the dashboard's band the view sits in is lifted over the header, the tabs and the nav (app.css).
|
|
901
|
+
useEffect(() => {
|
|
902
|
+
const root = fs === "immersive" ? globalThis.document?.documentElement : null;
|
|
903
|
+
if (!root || !root.classList) return undefined;
|
|
904
|
+
root.classList.add("oo-immersive");
|
|
905
|
+
return () => root.classList.remove("oo-immersive");
|
|
906
|
+
}, [fs]);
|
|
907
|
+
/** A desk opens its chat: out of fullscreen first (the chat is beside the view, or instead of it). */
|
|
908
|
+
const open = (name) => { if (!onOpen) return; if (fsRef.current) exitFs(); onOpen(name); };
|
|
909
|
+
|
|
910
|
+
// ── Drawing: the back and front layers are pictures of the scene at the view's resolution (remade when the office
|
|
911
|
+
// or the zoom changes, never on a pan); each frame draws the matte, the layers, the people and the items in the air ──
|
|
240
912
|
const scene = useRef(null);
|
|
241
913
|
const loop = useRef(null);
|
|
914
|
+
// The matte around the scene: the pack's own, else its background's edge (sampled once), else the skin's (tokens);
|
|
915
|
+
// All offices: the dashboard's background (each tile is its own scene).
|
|
916
|
+
const matte = overview ? pal.bg : (skin.pack && skin.pack.matte) || sampledMatte(skin.backdrop) || (skin.matte ? skin.matte({ pal, mode }) : pal.bg);
|
|
917
|
+
// …and behind the canvas too (the stage), so no page grey shows while it is (re)sized.
|
|
918
|
+
useLayoutEffect(() => { const el = stageRef.current; if (el && el.style) el.style.setProperty("background-color", matte); }, [matte]);
|
|
242
919
|
useLayoutEffect(() => {
|
|
243
920
|
const cv = canvasRef.current;
|
|
244
921
|
const ctx = cv && typeof cv.getContext === "function" ? cv.getContext("2d") : null;
|
|
245
922
|
if (!ctx) { scene.current = null; return; }
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
const dpr = backingScale(W, H, Math.max(1, Math.min(3, globalThis.devicePixelRatio || 1)));
|
|
249
|
-
const BW = Math.max(1, Math.floor(W * dpr)), BH = Math.max(1, Math.floor(H * dpr));
|
|
923
|
+
// The canvas is the stage: its bitmap at `ratio` px per CSS px (never larger than a browser paints).
|
|
924
|
+
const BW = Math.max(1, Math.floor(size.w * ratio)), BH = Math.max(1, Math.floor(size.h * ratio));
|
|
250
925
|
if (cv.width !== BW || cv.height !== BH) { cv.width = BW; cv.height = BH; }
|
|
251
|
-
cv.style.setProperty("width", `${
|
|
252
|
-
cv.style.setProperty("height", `${
|
|
926
|
+
cv.style.setProperty("width", `${BW / ratio}px`);
|
|
927
|
+
cv.style.setProperty("height", `${BH / ratio}px`);
|
|
253
928
|
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") } } };
|
|
254
|
-
const
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
paint(g);
|
|
263
|
-
return c;
|
|
264
|
-
};
|
|
265
|
-
const all = L.rooms.flatMap((r) => r.desks);
|
|
266
|
-
const back = layer((g) => {
|
|
267
|
-
g.fillStyle = env.pal.bg; g.fillRect(0, 0, L.width, L.height);
|
|
268
|
-
if (zones) { skin.drawBackground(g, L, env); return; }
|
|
269
|
-
skin.drawFloor(g, L, env);
|
|
270
|
-
for (const r of L.rooms) skin.drawRoom(g, r, env);
|
|
271
|
-
for (const d of all) skin.drawDeskBack(g, d, looks.get(d.key), env);
|
|
272
|
-
});
|
|
273
|
-
const front = zones ? null : layer((g) => { for (const d of all) skin.drawDeskFront(g, d, looks.get(d.key), env); });
|
|
274
|
-
const shown = new Set([...looks.values()].map((l) => l.state));
|
|
275
|
-
scene.current = { ctx, scale, BW, BH, back, front, env, desks: all.filter((d) => d.node), looks, L, skin, zones, dirty: true,
|
|
276
|
-
active: zones ? activeFurniture(skin.pack, shown) : new Set(), lines: bubbleDefaults(), lang: lang() };
|
|
929
|
+
const base = { ctx, ratio, BW, BH, matte, env, dirty: true };
|
|
930
|
+
if (overview) {
|
|
931
|
+
// Each tile a scene of its own, where the overview puts it (arranged.tiles, in the same order).
|
|
932
|
+
const parts = tiles.map((tl, i) => ({ ...partOf(tl.L, tileLooks[i], tl.skin, tl.zones, tl.walkers, tl.flights), night, key: tl.key, prefix: tl.prefix, tile: arranged.tiles[i] }));
|
|
933
|
+
scene.current = { ...base, overview: true, parts, lay: arranged };
|
|
934
|
+
} else {
|
|
935
|
+
scene.current = { ...base, overview: false, part: { ...partOf(L, looks, skin, zones, walkers, flights), ghosts, night, mail, acts: actsHere, actSeen: actSeen.current } };
|
|
936
|
+
}
|
|
277
937
|
if (still) draw(0); else loop.current?.wake();
|
|
278
|
-
}, [L, looks, skin, pal,
|
|
938
|
+
}, [L, looks, skin, pal, size.w, size.h, ratio, matte, overview, tiles, tileLooks, arranged, ghosts, night, mail, actsHere]);
|
|
279
939
|
|
|
280
940
|
/** One frame at `ms` (0: still); whether anything is moving (in the air, or someone walking). */
|
|
281
941
|
function draw(ms) {
|
|
282
942
|
const s = scene.current;
|
|
283
943
|
if (!s) return false;
|
|
284
|
-
const { ctx,
|
|
944
|
+
const { ctx, BW, BH, env } = s;
|
|
945
|
+
// The view in bitmap px: a logical unit is `a` px (a whole number at a pixel level), the origin on a whole px.
|
|
946
|
+
const v = view0(), a = v.k * s.ratio, ox = Math.round(v.x * s.ratio), oy = Math.round(v.y * s.ratio);
|
|
285
947
|
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
286
948
|
ctx.clearRect(0, 0, BW, BH);
|
|
287
|
-
|
|
288
|
-
ctx.setTransform(scale, 0, 0, scale, 0, 0);
|
|
949
|
+
ctx.fillStyle = s.matte; ctx.fillRect(0, 0, BW, BH);
|
|
289
950
|
ctx.imageSmoothingEnabled = false;
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
sk.drawCharacter(ctx, box, p.at.walking ? { ...p.look, pose: "walk" } : p.look, ms, env);
|
|
305
|
-
if (!p.at.walking) speakers.push({ look: p.look, x: p.at.x + 10, y: p.at.y - 50 });
|
|
306
|
-
}
|
|
307
|
-
for (const f of furniture) if (f.depth >= CHAR_DEPTH) sk.drawFurniture(ctx, f, s.active.has(f.id), ms, env);
|
|
308
|
-
for (const p of people) sk.drawTag(ctx, p.at.x, p.at.y, p.look, env);
|
|
309
|
-
} else {
|
|
310
|
-
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 }); }
|
|
951
|
+
if (!s.overview) return drawPart(ctx, s.part, a, ox, oy, ms, still, tipNow.current, env);
|
|
952
|
+
// All offices: each tile on screen drawn as its own view, scaled into it and clipped to it; one off screen is not
|
|
953
|
+
// drawn (its flights still land). Then a frame round each tile, and the items flying between tiles.
|
|
954
|
+
const hot = tipNow.current, on = new Set(visibleTiles(s.lay, v, sizeRef.current).map((tt) => tt.key)), said = [];
|
|
955
|
+
let moving = false;
|
|
956
|
+
for (const p of s.parts) {
|
|
957
|
+
const tt = p.tile;
|
|
958
|
+
if (!on.has(tt.key)) { if (!still) p.flights.frame(ms, p.L); continue; }
|
|
959
|
+
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;
|
|
960
|
+
ctx.save();
|
|
961
|
+
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
962
|
+
ctx.beginPath(); ctx.rect(tx, ty, tw, th); ctx.clip();
|
|
963
|
+
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;
|
|
964
|
+
ctx.restore();
|
|
311
965
|
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
312
|
-
|
|
313
|
-
ctx.
|
|
966
|
+
ctx.strokeStyle = env.pal.border; ctx.lineWidth = 1;
|
|
967
|
+
ctx.strokeRect(tx - 0.5, ty - 0.5, tw + 1, th + 1);
|
|
314
968
|
}
|
|
315
|
-
const
|
|
316
|
-
|
|
317
|
-
//
|
|
318
|
-
//
|
|
319
|
-
const
|
|
320
|
-
|
|
321
|
-
if (
|
|
322
|
-
|
|
323
|
-
if (b) bubbles.push({ key: sp.look.name, calling: CALLING.has(sp.look.state), shown: b.shown, box: bubbleBox(ctx, sp.x, sp.y, b.text), tail: { x: sp.x, y: sp.y }, sp, b });
|
|
969
|
+
for (const say of said) say();
|
|
970
|
+
const up = still ? [] : cross.frame(ms, s.parts);
|
|
971
|
+
// Each drawn by its sender's tile's skin (#2562: their own prop, else that pack's for its kind), at that tile's scale;
|
|
972
|
+
// a sender in no tile drawn: the built-in item, in overview units.
|
|
973
|
+
for (const it of up) {
|
|
974
|
+
const from = senderTile(s.parts, it.from);
|
|
975
|
+
if (from) { ctx.setTransform(a * from.tile.s, 0, 0, a * from.tile.s, ox, oy); from.skin.drawProp(ctx, { ...it, x: it.x / from.tile.s, y: it.y / from.tile.s }, ms, env); }
|
|
976
|
+
else { ctx.setTransform(a, 0, 0, a, ox, oy); skinFor("pixel").drawProp(ctx, it, ms, env); }
|
|
324
977
|
}
|
|
325
|
-
|
|
326
|
-
return walking || up.length > 0 || flights.size() > 0;
|
|
978
|
+
return moving || up.length > 0 || cross.size() > 0;
|
|
327
979
|
}
|
|
328
980
|
// The loop: drawing only when the frame (FPS) changed or something is in the air; stopped while the tab is hidden,
|
|
329
981
|
// gone with the view. Office review: while something walks or flies, one requestAnimationFrame per display frame (the
|
|
@@ -359,76 +1011,221 @@ export function Office({ view, traffic, q = "", lease, onOpen }) {
|
|
|
359
1011
|
}, []);
|
|
360
1012
|
useEffect(() => { loop.current?.wake(); }, [traffic]);
|
|
361
1013
|
|
|
362
|
-
//
|
|
363
|
-
const
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
const
|
|
1014
|
+
// The person hovered or focused: their name tag shows even in a crowd.
|
|
1015
|
+
const tipNow = useRef(null);
|
|
1016
|
+
tipNow.current = tip;
|
|
1017
|
+
useEffect(() => { if (!scene.current) return; scene.current.dirty = true; if (still) draw(0); else loop.current?.wake(); }, [tip]);
|
|
1018
|
+
|
|
1019
|
+
// Everyone drawn: { key, node, box (the desk, in the view's units), dot (where the overview marks them), office (All
|
|
1020
|
+
// offices: the tile's name) }. In All offices a key is the tile's prefix and the desk's key: one person in two
|
|
1021
|
+
// offices is two people here.
|
|
1022
|
+
const people = overview
|
|
1023
|
+
? tiles.flatMap((tl, i) => { const tt = arranged.tiles[i];
|
|
1024
|
+
return tt ? desksOf(tl.L).map((d) => ({ key: tl.prefix + d.key, node: d.node, box: tileBox(tt, d), office: tl.name,
|
|
1025
|
+
dot: fromTile(tt, tl.zones ? d.seat : { x: d.x + d.w / 2, y: d.y + d.h / 2 }) })) : []; })
|
|
1026
|
+
: 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 } }));
|
|
1027
|
+
// Preact owns the markup, not the view: put it back on the overview, the overlay and the tooltip after every render.
|
|
1028
|
+
const tipDesk = tip ? people.find((d) => d.key === tip) || null : null;
|
|
1029
|
+
tipDeskRef.current = tipDesk ? tipDesk.box : null;
|
|
1030
|
+
useLayoutEffect(mark);
|
|
1031
|
+
// ── Pointing (#2540: through the view): hover (or a first tap) shows a desk's card, a click (or a second tap) opens
|
|
1032
|
+
// its chat; a drag on the scene pans it (never on Edit layout's handles: they move), the wheel and a pinch zoom ──
|
|
1033
|
+
/** The view's point under a pointer event (logical units), or null. */
|
|
1034
|
+
const pointOf = (e) => {
|
|
371
1035
|
const r = canvasRef.current ? canvasRef.current.getBoundingClientRect() : null;
|
|
372
|
-
|
|
373
|
-
const x = (e.clientX - r.left) / k, y = (e.clientY - r.top) / k;
|
|
374
|
-
// The zones scene draws people where they walk: they are hit there, not at the seat they are heading for.
|
|
375
|
-
return zones ? personAt(L.rooms.flatMap((rm) => rm.desks), x, y, (d) => walkers.at(d.node.name, now())) : deskAt(L, x, y);
|
|
1036
|
+
return r ? toWorld(view0(), e.clientX - r.left, e.clientY - r.top) : null;
|
|
376
1037
|
};
|
|
377
|
-
|
|
1038
|
+
/** The person under a pointer event as drawn ({ key, node }), or null; All offices: in the tile there, in its own units. */
|
|
1039
|
+
const deskOf = (e) => {
|
|
1040
|
+
const w = pointOf(e), s = scene.current;
|
|
1041
|
+
if (!w) return null;
|
|
1042
|
+
// The zones scene draws people where they walk: they are hit there, not at the seat they are heading for (hitPart).
|
|
1043
|
+
if (!s || !s.overview) {
|
|
1044
|
+
if (overview) return null;
|
|
1045
|
+
return hitPart(s && s.part ? s.part : { L, zones, walkers, skin, looks, all: L.rooms.flatMap((rm) => rm.desks) }, w.x, w.y, now());
|
|
1046
|
+
}
|
|
1047
|
+
const tt = tileAt(s.lay, w.x, w.y), p = tt ? s.parts.find((x) => x.tile.key === tt.key) : null;
|
|
1048
|
+
if (!p) return null;
|
|
1049
|
+
const at = inTile(tt, w.x, w.y), d = hitPart(p, at.x, at.y, now());
|
|
1050
|
+
return d ? { key: p.prefix + d.key, node: d.node } : null;
|
|
1051
|
+
};
|
|
1052
|
+
const g = gestures(drag.current, { el: () => stageRef.current, pan: (dx, dy) => { const v = view0(); setView({ ...v, x: v.x + dx, y: v.y + dy }); },
|
|
1053
|
+
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)); } });
|
|
1054
|
+
/** Where a drag pans: the canvas, and Edit layout's overlay off its handles (the controls, the bar and the list: never). */
|
|
1055
|
+
const onScene = (el) => !!el && (el === canvasRef.current || (typeof el.closest === "function" && !!el.closest(".ol-svg") && !el.closest(".ol-handle")));
|
|
1056
|
+
const onDown = (e) => {
|
|
1057
|
+
lastType.current = e.pointerType || "mouse";
|
|
1058
|
+
if (!onScene(e.target)) return;
|
|
1059
|
+
g.down(e);
|
|
1060
|
+
if (drag.current.pts.size === 2) raw.current = view0().k; // a pinch starts from the zoom shown
|
|
1061
|
+
};
|
|
1062
|
+
const onWheel = (e) => {
|
|
1063
|
+
if (!onScene(e.target) && !(typeof e.target?.closest === "function" && e.target.closest(".ol-handle, .oo-tile-label"))) return;
|
|
1064
|
+
e.preventDefault();
|
|
1065
|
+
const r = stageRef.current.getBoundingClientRect(), v = view0();
|
|
1066
|
+
raw.current = Math.min(OK_MAX, Math.max(OK_MIN, raw.current * wheelFactor(e)));
|
|
1067
|
+
if (raw.current !== v.k) setView(zoomOffice(v, raw.current, e.clientX - r.left, e.clientY - r.top));
|
|
1068
|
+
};
|
|
1069
|
+
const onKeyDown = (e) => {
|
|
1070
|
+
if (e.defaultPrevented) return; // Edit layout's handles move with the arrows
|
|
1071
|
+
const tag = e.target && e.target.tagName;
|
|
1072
|
+
if (tag === "INPUT" || tag === "SELECT" || tag === "TEXTAREA") return;
|
|
1073
|
+
const z = zoomKey(e);
|
|
1074
|
+
if (z) { e.preventDefault(); if (z === "fit") fit(); else zoomStep(z === "in" ? 1 : -1); return; }
|
|
1075
|
+
const dir = PAN_KEYS[e.key];
|
|
1076
|
+
if (dir && e.target === canvasRef.current && !e.altKey && !e.ctrlKey && !e.metaKey) { e.preventDefault(); const v = view0(); setView({ ...v, x: v.x - dir[0] * 80, y: v.y - dir[1] * 80 }); }
|
|
1077
|
+
};
|
|
1078
|
+
// The keyboard's way through: a desk in the list, or a handle of Edit layout, is brought into view when focused.
|
|
1079
|
+
const onFocusIn = (e) => {
|
|
1080
|
+
const el = e.target;
|
|
1081
|
+
if (!el || typeof el.getAttribute !== "function") return;
|
|
1082
|
+
const hk = el.getAttribute("data-handle");
|
|
1083
|
+
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; }
|
|
1084
|
+
const key = el.classList && el.classList.contains("oo-desk") ? el.getAttribute("data-key") : null;
|
|
1085
|
+
const d = key ? people.find((x) => x.key === key) : null;
|
|
1086
|
+
if (d) show(d.box);
|
|
1087
|
+
};
|
|
1088
|
+
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); };
|
|
378
1089
|
const onClick = (e) => {
|
|
379
1090
|
const d = deskOf(e);
|
|
380
1091
|
if (!d || !d.node.i) { setTip(null); return; }
|
|
381
1092
|
if (lastType.current === "touch" && tip !== d.key) { setTip(d.key); return; } // a first tap shows; a second opens
|
|
382
|
-
|
|
1093
|
+
open(d.node.name);
|
|
1094
|
+
};
|
|
1095
|
+
// All offices: a double-click on a tile (off its people) opens that office; the lobby is no office.
|
|
1096
|
+
const onDblClick = (e) => {
|
|
1097
|
+
const s = scene.current, w = pointOf(e);
|
|
1098
|
+
if (!overview || !s || !s.overview || !w || deskOf(e)) return;
|
|
1099
|
+
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;
|
|
1100
|
+
if (tl && !tl.lobby) pick(tl.key);
|
|
1101
|
+
};
|
|
1102
|
+
const onMini = (e) => {
|
|
1103
|
+
const w = miniPoint(miniRef.current ? miniRef.current.getBoundingClientRect() : null, { x: 0, y: 0, w: L.width, h: L.height }, e.clientX, e.clientY);
|
|
1104
|
+
if (w) setView(centreOn(view0(), size, w.x, w.y));
|
|
383
1105
|
};
|
|
384
1106
|
|
|
385
|
-
const auto = !custom;
|
|
1107
|
+
const auto = !custom && !overview;
|
|
1108
|
+
// Edit layout: an admin, a stored office, a zones style (built in, or a pack loaded here); not while the offices'
|
|
1109
|
+
// editor is open, nor a second session (nor in All offices: custom is null there).
|
|
1110
|
+
const canLayout = canEdit && storedRev !== null && !!custom && zones && !loading && baseSkin.id === custom.skin && !editing && !layout;
|
|
1111
|
+
const openLayout = () => {
|
|
1112
|
+
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 });
|
|
1113
|
+
setTip(null);
|
|
1114
|
+
};
|
|
1115
|
+
const closeLayout = () => setLayout(null);
|
|
1116
|
+
const onDraft = (draft) => setLayout((cur) => (cur ? { ...cur, draft } : cur));
|
|
1117
|
+
// Save: the session's list, this office's overrides replaced (none: back to the style's own), with the session's rev.
|
|
1118
|
+
// A change made meanwhile (409) reads the list again: the session takes it (the view too), the draft is kept, and
|
|
1119
|
+
// Save again — the person's call, told so — puts it on that list.
|
|
1120
|
+
const saveLayout = async (ov) => {
|
|
1121
|
+
const s = layoutRef.current;
|
|
1122
|
+
const mine = s && s.list.find((o) => o.id === s.officeId);
|
|
1123
|
+
if (!s || !mine || mine.skin !== s.skin) return { ok: false, message: t("orgl.stale") };
|
|
1124
|
+
const list = s.list.map((o) => (o.id === s.officeId ? (({ overrides: _gone, ...rest }) => (ov ? { ...rest, overrides: ov } : rest))(o) : o)).map(officeForSave);
|
|
1125
|
+
const sent = ask();
|
|
1126
|
+
const r = await store.saveOffices(list, s.rev);
|
|
1127
|
+
if (r.ok) { adopt({ rev: r.rev, offices: r.offices }, sent); return { ok: true }; }
|
|
1128
|
+
if (r.status === 409) {
|
|
1129
|
+
const at = ask(); const cur = adopt(await store.offices(), at);
|
|
1130
|
+
if (cur) setLayout((c) => (c && c.id === s.id ? { ...c, list: cur.offices, rev: cur.rev } : c));
|
|
1131
|
+
return { ok: false, message: t("orgl.changed") };
|
|
1132
|
+
}
|
|
1133
|
+
return { ok: false, message: r.status === 429 ? t("orgl.rateLimited") : t("orgl.failed", r.error || r.status) };
|
|
1134
|
+
};
|
|
1135
|
+
const styleName = (id) => (styles.includes(id) ? to(`style_${id}`) : (packs.find((pk) => pk.id === id) || { name: id }).name);
|
|
386
1136
|
const f = filterOk(filter);
|
|
387
1137
|
const opts = teamOptions(view);
|
|
388
1138
|
const teamValue = (opts.find((o) => o.team === f.team) || opts[0]).value;
|
|
389
1139
|
const setF = (next) => { const v = filterOk(next); store.saveFilter(v); setFilter(v); };
|
|
390
|
-
|
|
1140
|
+
// While Edit layout is open the office stays (its picker is disabled; this is the guard behind it).
|
|
1141
|
+
const pick = (id) => { if (layoutRef.current) return; store.saveChoice(id); setChoice(id); setTip(null); };
|
|
391
1142
|
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]);
|
|
392
|
-
const desks = desksOf(L);
|
|
393
1143
|
const tipText = tipDesk ? nodeText(tipDesk.node) : null;
|
|
394
|
-
|
|
1144
|
+
// The overview: the scene's rooms (or areas; All offices: the tiles) and a dot for each person (a call in its colour),
|
|
1145
|
+
// the view's frame on it.
|
|
1146
|
+
const miniRects = overview ? arranged.tiles.map((tt) => ({ key: tt.key, x: tt.x, y: tt.y, w: tt.w, h: tt.h }))
|
|
1147
|
+
: 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 }));
|
|
1148
|
+
const dotR = Math.max(L.width, L.height) / 90;
|
|
1149
|
+
// The switcher: the whole fleet, All offices (two or more stored), each stored office.
|
|
1150
|
+
const officeSelect = (cls) => html`<select class=${`tb-select ${cls}`} value=${layout ? layout.officeId : overview ? ALL_ID : custom ? custom.id : AUTO_ID} disabled=${!!layout}
|
|
1151
|
+
aria-label=${to("office")} onChange=${(e) => pick(e.target.value)}>
|
|
1152
|
+
<option value=${AUTO_ID}>${to("allOffice")}</option>
|
|
1153
|
+
${overviewOk(stored) ? html`<option value=${ALL_ID}>${to("allOffices")}</option>` : null}
|
|
1154
|
+
${stored.map((o) => html`<option key=${o.id} value=${o.id}>${o.name}</option>`)}
|
|
1155
|
+
</select>`;
|
|
1156
|
+
return html`<div class=${`oo${fs ? ` fs ${fs}` : ""}`} ref=${ooRef}>
|
|
395
1157
|
<div class="oo-bar">
|
|
396
|
-
<label class="oo-pick"><span class="oo-pick-label">${to("office")}</span>
|
|
397
|
-
<select class="tb-select" value=${custom ? custom.id : AUTO_ID} onChange=${(e) => pick(e.target.value)}>
|
|
398
|
-
<option value=${AUTO_ID}>${to("allOffice")}</option>
|
|
399
|
-
${stored.map((o) => html`<option key=${o.id} value=${o.id}>${o.name}</option>`)}
|
|
400
|
-
</select></label>
|
|
1158
|
+
<label class="oo-pick"><span class="oo-pick-label">${to("office")}</span>${officeSelect("oo-office")}</label>
|
|
401
1159
|
${auto ? html`<label class="oo-pick"><span class="oo-pick-label">${to("styleLabel")}</span>
|
|
402
|
-
<select class="tb-select" value=${skin.id} onChange=${(e) => { store.saveSkin(e.target.value); setStyle(e.target.value); setTip(null); }}>
|
|
403
|
-
${
|
|
1160
|
+
<select class="tb-select" value=${STYLES.includes(style) || packs.some((pk) => pk.id === style) ? style : skin.id} onChange=${(e) => { store.saveSkin(e.target.value); setStyle(e.target.value); setTip(null); }}>
|
|
1161
|
+
${styles.map((id) => html`<option key=${id} value=${id}>${to(`style_${id}`)}</option>`)}
|
|
1162
|
+
${packs.map((pk) => html`<option key=${`pack:${pk.id}`} value=${pk.id}>${pk.name}</option>`)}
|
|
404
1163
|
</select></label>` : null}
|
|
405
1164
|
${auto ? html`<label class="oo-pick"><span class="oo-pick-label">${to("show")}</span>
|
|
406
1165
|
<select class="tb-select" value=${teamValue} onChange=${(e) => { const o = opts.find((x) => x.value === e.target.value); setF({ ...f, team: o ? o.team : "" }); }}>
|
|
407
1166
|
${opts.map((o) => html`<option key=${o.value} value=${o.value}>${o.label}</option>`)}
|
|
408
1167
|
</select></label>
|
|
409
1168
|
<${People} nodes=${everyone} pinned=${f.pinned} onChange=${(pinned) => setF({ ...f, pinned })} />` : null}
|
|
410
|
-
|
|
1169
|
+
<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>
|
|
1170
|
+
${zones || overview ? html`<label class="check oo-night"><input type="checkbox" checked=${nightOn} onChange=${() => { const v = !nightOn; store.saveNightOn(v); setNightOn(v); }} /><span>${to("nightOn")}</span></label>` : null}
|
|
1171
|
+
${overview ? html`<label class="check oo-lobby-tile" title=${lobbyAll.length ? undefined : to("lobbyEveryone")}><input type="checkbox" checked=${lobbyOn && lobbyAll.length > 0}
|
|
1172
|
+
disabled=${!lobbyAll.length} onChange=${() => { const v = !lobbyOn; store.saveLobbyTile(v); setLobbyOn(v); setTip(null); }} /><span>${to("lobbyTile")}</span></label>
|
|
1173
|
+
${lobbyAll.length ? null : html`<span class="muted oo-note oo-lobby-none">${to("lobbyEveryone")}</span>`}` : null}
|
|
1174
|
+
${custom && baseSkin.id !== custom.skin && !loading ? html`<span class="muted oo-note">${to("skinUnknown", styleName(custom.skin))}</span>` : null}
|
|
1175
|
+
${placed.ignored ? html`<span class="muted oo-note">${to("layoutIgnored")}</span>` : null}
|
|
411
1176
|
${L.unseated ? html`<span class="muted oo-note">${to("unseated", L.unseated)}</span>` : null}
|
|
412
|
-
${canEdit && storedRev !== null ? html`<button type="button" class="btn btn-sm oo-edit" onClick=${() => setEditing(true)}
|
|
1177
|
+
${canEdit && storedRev !== null ? html`<button type="button" class="btn btn-sm oo-edit" onClick=${() => { if (!overview) setEditing(true); }} disabled=${!!layout || overview}
|
|
1178
|
+
title=${overview ? to("overviewNoEdit") : undefined}>${t("orge.edit")}</button>` : null}
|
|
1179
|
+
${canLayout ? html`<button type="button" class="btn btn-sm oo-layout" onClick=${openLayout}>${t("orgl.edit")}</button>` : null}
|
|
1180
|
+
${canShare ? html`<${ShareButton} offices=${stored} current=${overview ? "all" : custom ? custom.id : null} disabled=${!!layout}
|
|
1181
|
+
get=${lease ? (path, o) => lease.fetch(path, o) : undefined} />` : null}
|
|
413
1182
|
</div>
|
|
414
|
-
${editing ? html`<${OfficeEditor} offices=${stored} rev=${storedRev} view=${view} store=${store}
|
|
1183
|
+
${editing && !overview ? html`<${OfficeEditor} offices=${stored} rev=${storedRev} view=${view} store=${store} packs=${packs}
|
|
415
1184
|
ask=${ask} current=${() => shown.current} onSaved=${adopt} onClose=${() => setEditing(false)} />` : null}
|
|
416
|
-
${
|
|
417
|
-
<div class
|
|
418
|
-
|
|
419
|
-
|
|
1185
|
+
${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>`}
|
|
1186
|
+
<div class=${`oo-stage${layout ? " editing" : ""}`} ref=${stageRef} onPointerDown=${onDown} onPointerMove=${g.move} onPointerUp=${g.up} onPointerCancel=${g.up}
|
|
1187
|
+
onWheel=${onWheel} onKeyDown=${onKeyDown} onFocusIn=${onFocusIn} onClickCapture=${g.clickCapture}>
|
|
1188
|
+
<canvas ref=${canvasRef} class=${`oo-canvas${tipDesk && tipDesk.node.i ? " hot" : ""}`} role="application" tabindex="0" aria-label=${to(overview ? "labelAll" : "label")}
|
|
1189
|
+
onPointerMove=${onMove} onPointerLeave=${() => setTip(null)} onClick=${onClick} ondblclick=${onDblClick}></canvas>
|
|
1190
|
+
${overview ? tiles.map((tl) => {
|
|
1191
|
+
const text = to("tileLabel", tl.name, tl.count), at = (el) => { if (el) labelEls.current.set(tl.key, el); else labelEls.current.delete(tl.key); };
|
|
1192
|
+
return tl.lobby ? html`<span key=${tl.key} ref=${at} class="oo-tile-label lobby">${text}</span>`
|
|
1193
|
+
: 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)}
|
|
1194
|
+
onClick=${() => pick(tl.key)}>${text}</button>`;
|
|
1195
|
+
}) : null}
|
|
1196
|
+
${layout ? html`<${LayoutEditor} key=${layout.id} base=${layout.base} start=${layout.start} place=${placeOverlay} dock=${!fs}
|
|
1197
|
+
stale=${layoutStale ? t("orgl.stale") : ""} onChange=${onDraft} onSave=${saveLayout} onClose=${closeLayout} />` : null}
|
|
420
1198
|
${tipText ? html`<div ref=${tipRef} class="oo-tip" role="tooltip">
|
|
421
1199
|
<strong>${tipText.primary}</strong>${tipText.secondary ? html` <span class="muted mono">${tipText.secondary}</span>` : null}
|
|
422
1200
|
<div>${tipText.sub}</div><div class="muted">${caption(officePose(tipDesk.node.state), tipDesk.node.state)}</div>
|
|
423
|
-
${tipDesk.
|
|
1201
|
+
${tipDesk.office ? html`<div class="muted oo-tip-office">${tipDesk.office}</div>` : null}
|
|
1202
|
+
${tipDesk.node.i && onOpen && lastType.current === "touch" ? html`<div class="muted">${to("tapAgain")}</div>` : null}
|
|
424
1203
|
</div>` : null}
|
|
425
|
-
<ul class="oo-desks" aria-label=${to("desks")}>${
|
|
426
|
-
const nt = nodeText(d.node), cap = caption(officePose(d.node.state), d.node.state);
|
|
1204
|
+
<ul class="oo-desks" aria-label=${to("desks")}>${people.map((d) => {
|
|
1205
|
+
const nt = nodeText(d.node), cap = caption(officePose(d.node.state), d.node.state), where = d.office ? ` · ${d.office}` : "";
|
|
427
1206
|
return html`<li key=${d.key}>${d.node.i
|
|
428
|
-
? html`<button type="button" class="oo-desk" data-name=${d.node.name} aria-label=${`${nt.full} · ${cap}
|
|
429
|
-
onFocus=${() => setTip(d.key)} onBlur=${() => setTip(null)} onClick=${() =>
|
|
430
|
-
: html`<span class="oo-desk" data-name=${d.node.name}>${nt.full}</span>`}</li>`;
|
|
1207
|
+
? html`<button type="button" class="oo-desk" data-name=${d.node.name} data-key=${d.key} aria-label=${`${nt.full}${where} · ${cap}.${onOpen ? ` ${t("orgc.openChat")}` : ""}`}
|
|
1208
|
+
onFocus=${() => setTip(d.key)} onBlur=${() => setTip(null)} onClick=${() => open(d.node.name)}>${nt.primary}</button>`
|
|
1209
|
+
: html`<span class="oo-desk" data-name=${d.node.name} data-key=${d.key}>${nt.full}</span>`}</li>`;
|
|
431
1210
|
})}</ul>
|
|
1211
|
+
<div class="oc-zoom oo-zoom" role="group" aria-label=${t("orgc.view")}>
|
|
1212
|
+
<output ref=${pctRef} class="oo-zoom-pct"></output>
|
|
1213
|
+
<button type="button" class="icon-btn oo-zoom-in" aria-label=${t("orgc.zoomIn")} title=${t("orgc.zoomIn")} onClick=${() => zoomStep(1)}><${Icon} name="plus" size=${16} /></button>
|
|
1214
|
+
<button type="button" class="icon-btn oo-zoom-out" aria-label=${t("orgc.zoomOut")} title=${t("orgc.zoomOut")} onClick=${() => zoomStep(-1)}><${Icon} name="minus" size=${16} /></button>
|
|
1215
|
+
<button type="button" class="icon-btn oo-fit" aria-label=${to("fit")} title=${to("fit")} onClick=${() => fit()}><${Icon} name="fitwidth" size=${16} /></button>
|
|
1216
|
+
<button type="button" class=${`icon-btn oo-fill${fitMode === "cover" ? " on" : ""}`} aria-pressed=${fitMode === "cover" ? "true" : "false"} aria-label=${to("fillTitle")}
|
|
1217
|
+
title=${to("fillTitle")} onClick=${toggleFill}>${to("fill")}</button>
|
|
1218
|
+
<button type="button" class="icon-btn oo-fs" aria-label=${to(fs ? "exitFullscreen" : "fullscreen")} title=${to(fs ? "exitFullscreen" : "fullscreen")} onClick=${toggleFs}><${Icon} name=${fs ? "fullscreenexit" : "fullscreen"} size=${16} /></button>
|
|
1219
|
+
</div>
|
|
1220
|
+
${fs ? html`<div class="oo-fs-bar">${officeSelect("oo-fs-office")}</div>` : null}
|
|
1221
|
+
<svg ref=${miniRef} class="oc-mini oo-mini" viewBox=${`0 0 ${Math.max(1, L.width)} ${Math.max(1, L.height)}`} aria-hidden="true" onClick=${onMini}>
|
|
1222
|
+
<title>${to("overview")}</title>
|
|
1223
|
+
<rect class="oo-mini-matte" x=${-L.width} y=${-L.height} width=${L.width * 3} height=${L.height * 3} fill=${matte} />
|
|
1224
|
+
<rect class="oo-mini-scene" x="0" y="0" width=${L.width} height=${L.height} />
|
|
1225
|
+
${miniRects.map((r) => html`<rect key=${r.key} class="oo-mini-room" x=${r.x} y=${r.y} width=${r.w} height=${r.h} />`)}
|
|
1226
|
+
${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} />`)}
|
|
1227
|
+
<rect ref=${miniViewRef} class="oc-mini-view oo-mini-view" />
|
|
1228
|
+
</svg>
|
|
432
1229
|
</div>
|
|
433
1230
|
</div>`;
|
|
434
1231
|
}
|