entangled-worlds-orbiters-shared 4.2.0 → 5.1.0

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.
Files changed (56) hide show
  1. package/README.md +3 -2
  2. package/dist/shortcuts/catalogue.d.ts +72 -0
  3. package/dist/shortcuts/catalogue.js +118 -0
  4. package/dist/shortcuts/chord.d.ts +24 -2
  5. package/dist/shortcuts/chord.js +86 -8
  6. package/dist/shortcuts/format.d.ts +7 -4
  7. package/dist/shortcuts/format.js +40 -14
  8. package/dist/shortcuts/index.d.ts +4 -8
  9. package/dist/shortcuts/index.js +8 -11
  10. package/dist/shortcuts/match.d.ts +14 -6
  11. package/dist/shortcuts/match.js +14 -8
  12. package/dist/shortcuts/source.d.ts +62 -0
  13. package/dist/shortcuts/source.js +139 -0
  14. package/dist/sky/geometry.d.ts +31 -0
  15. package/dist/sky/geometry.js +84 -0
  16. package/dist/sky/index.d.ts +6 -0
  17. package/dist/sky/index.js +6 -0
  18. package/dist/sky/params.d.ts +44 -0
  19. package/dist/sky/params.js +52 -0
  20. package/dist/sky/shaders.d.ts +14 -0
  21. package/dist/sky/shaders.js +149 -0
  22. package/dist/sky/sky.d.ts +24 -0
  23. package/dist/sky/sky.js +268 -0
  24. package/dist/sky/worldSkyFile.d.ts +16 -0
  25. package/dist/sky/worldSkyFile.js +51 -0
  26. package/package.json +8 -2
  27. package/src/shortcuts/catalogue.ts +172 -0
  28. package/src/shortcuts/chord.ts +96 -6
  29. package/src/shortcuts/format.ts +44 -17
  30. package/src/shortcuts/index.ts +31 -28
  31. package/src/shortcuts/match.ts +25 -13
  32. package/src/shortcuts/source.ts +204 -0
  33. package/src/sky/geometry.ts +99 -0
  34. package/src/sky/index.ts +16 -0
  35. package/src/sky/params.ts +89 -0
  36. package/src/sky/shaders.ts +159 -0
  37. package/src/sky/sky.ts +330 -0
  38. package/src/sky/worldSkyFile.ts +60 -0
  39. package/dist/shortcuts/entangledWorlds.d.ts +0 -21
  40. package/dist/shortcuts/entangledWorlds.js +0 -40
  41. package/dist/shortcuts/labels.d.ts +0 -10
  42. package/dist/shortcuts/labels.js +0 -130
  43. package/dist/shortcuts/orbiters.d.ts +0 -55
  44. package/dist/shortcuts/orbiters.js +0 -109
  45. package/dist/shortcuts/overrides.d.ts +0 -30
  46. package/dist/shortcuts/overrides.js +0 -81
  47. package/dist/shortcuts/sections.d.ts +0 -23
  48. package/dist/shortcuts/sections.js +0 -19
  49. package/dist/shortcuts/site.d.ts +0 -9
  50. package/dist/shortcuts/site.js +0 -25
  51. package/src/shortcuts/entangledWorlds.ts +0 -51
  52. package/src/shortcuts/labels.ts +0 -139
  53. package/src/shortcuts/orbiters.ts +0 -132
  54. package/src/shortcuts/overrides.ts +0 -110
  55. package/src/shortcuts/sections.ts +0 -36
  56. package/src/shortcuts/site.ts +0 -33
@@ -1,7 +1,7 @@
1
1
  // A key string for people: how a chord is shown (`⌘⇧Z`, `Ctrl+Shift+Z`, `Q`), and the key string a
2
2
  // press makes when someone sets a key ("press a key to change").
3
3
 
4
- import { isModifierCode, parseChord, type Chord } from './chord.js'
4
+ import { isModifierCode, parseChord, typedKey, type Chord } from './chord.js'
5
5
 
6
6
  /** Apple keyboards show modifiers as symbols, and their command key is ⌘ (`Mod`); others write words and Ctrl. */
7
7
  export type ShortcutPlatform = 'apple' | 'other'
@@ -15,8 +15,8 @@ export function shortcutPlatform(): ShortcutPlatform {
15
15
 
16
16
  /**
17
17
  * The character a key place prints on this person's keyboard, when the browser can say
18
- * (`navigator.keyboard.getLayoutMap()`, Chromium only); a key place it does not know is shown from
19
- * its name.
18
+ * (`navigator.keyboard.getLayoutMap()`, Chromium only). Only keys matched by their place use it:
19
+ * a letter, digit or punctuation key matches the character it names, so it shows that character.
20
20
  */
21
21
  export type KeyLayout = ReadonlyMap<string, string>
22
22
 
@@ -32,7 +32,8 @@ const NAMED: Readonly<Record<string, string>> = {
32
32
 
33
33
  /** What a key place is called on a keyboard: `KeyQ` → `Q`, `Digit1` → `1`, `Numpad4` → `Num 4`. */
34
34
  export function keyLabel(code: string, layout?: KeyLayout): string {
35
- const printed = layout?.get(code)
35
+ const byCharacter = /^(Key[A-Z]|Digit\d)$/.test(code) || typedKey(NAMED[code])?.code === code
36
+ const printed = byCharacter ? undefined : layout?.get(code)
36
37
  if (printed && printed.trim()) return printed.length === 1 ? printed.toUpperCase() : printed
37
38
  if (/^Key[A-Z]$/.test(code)) return code.slice(3)
38
39
  if (/^Digit\d$/.test(code)) return code.slice(5)
@@ -40,6 +41,12 @@ export function keyLabel(code: string, layout?: KeyLayout): string {
40
41
  return NAMED[code] ?? code
41
42
  }
42
43
 
44
+ /** What a US keyboard types with Shift at each punctuation key. */
45
+ const SHIFTED: Readonly<Record<string, string>> = {
46
+ Minus: '_', Equal: '+', BracketLeft: '{', BracketRight: '}', Backslash: '|', Semicolon: ':', Quote: '"',
47
+ Backquote: '~', Comma: '<', Period: '>', Slash: '?',
48
+ }
49
+
43
50
  const SYMBOLS = { ctrl: '⌃', alt: '⌥', shift: '⇧', meta: '⌘' } as const
44
51
  const WORDS = { ctrl: 'Ctrl', alt: 'Alt', shift: 'Shift', meta: 'Meta' } as const
45
52
 
@@ -64,10 +71,14 @@ export function chordParts(keys: string, platform: ShortcutPlatform = shortcutPl
64
71
  const parts: string[] = []
65
72
  if (ctrl) parts.push(names.ctrl)
66
73
  if (held(chord.alt)) parts.push(platform === 'apple' ? names.alt : 'Alt')
67
- if (held(chord.shift)) parts.push(names.shift)
74
+ // A punctuation key is the character it types, Shift included: `Shift+Slash` is `?`.
75
+ // A command (⇧⌘,) is shown as written: its Shift is a key to press, not part of a character.
76
+ const punctuation = !ctrl && !meta && typedKey(NAMED[chord.code])?.code === chord.code ? SHIFTED[chord.code] : undefined
77
+ if (held(chord.shift) && !punctuation) parts.push(names.shift)
68
78
  if (meta) parts.push(names.meta)
69
- // The help key is `?` wherever it lets Shift be held (its place is `/`): show what people press.
70
- parts.push(chord.code === 'Slash' && chord.shift === 'any' && !layout ? '?' : keyLabel(chord.code, layout))
79
+ // The help key is `?` wherever it lets Shift be held (`/` or `?`): show what people press.
80
+ const shifted = punctuation && (held(chord.shift) || (chord.code === 'Slash' && chord.shift === 'any'))
81
+ parts.push(shifted ? punctuation : keyLabel(chord.code, layout))
71
82
  return parts
72
83
  }
73
84
 
@@ -82,24 +93,40 @@ export function formatChord(keys: string, platform: ShortcutPlatform = shortcutP
82
93
 
83
94
  /**
84
95
  * The key string a press makes, for someone setting a key: exactly the modifiers held, the command
85
- * key of their platform written `Mod` (⌘ on Apple, Ctrl elsewhere) so the key works on both. `null`
86
- * while only modifiers are down, or for a press with no key place.
96
+ * key of their platform written `Mod` (⌘ on Apple, Ctrl elsewhere) so the key works on both. A letter
97
+ * or punctuation character typed is written as the key that types it on a US keyboard (`/` is
98
+ * `Slash` whichever keys typed it), the way the matcher reads it back. `null` while only modifiers
99
+ * are down, or for a press with no key place.
87
100
  */
88
101
  export function chordFromEvent(
89
- event: { code: string; shiftKey: boolean; altKey: boolean; ctrlKey: boolean; metaKey: boolean },
102
+ event: { code: string; key?: string; shiftKey: boolean; altKey: boolean; ctrlKey: boolean; metaKey: boolean },
90
103
  platform: ShortcutPlatform = shortcutPlatform(),
91
104
  ): string | null {
92
105
  if (!event.code || isModifierCode(event.code)) return null
106
+ const typed = typedKey(event.key)
107
+ const punctuation = typed?.shift !== undefined
108
+ // A punctuation character carries its own Shift, and the AltGr or ⌥ that typed it is no modifier.
109
+ const altGr = punctuation && event.altKey && event.ctrlKey && !event.metaKey
110
+ // Field by field: a browser's KeyboardEvent keeps them on its prototype, where a spread does not reach.
111
+ const press = punctuation
112
+ ? {
113
+ // With ⌘ or Ctrl some browsers report the key unshifted: the Shift held is then part of the chord.
114
+ shiftKey: Boolean(typed.shift) || (event.shiftKey && !altGr && (event.ctrlKey || event.metaKey)),
115
+ altKey: false,
116
+ ctrlKey: altGr ? false : event.ctrlKey,
117
+ metaKey: event.metaKey,
118
+ }
119
+ : event
93
120
  const parts: string[] = []
94
121
  // ⌘ (Ctrl elsewhere) is written Mod so the key works on both — unless Ctrl and ⌘ are both held,
95
122
  // which Mod cannot say: then both by name.
96
- if (event.ctrlKey && event.metaKey) parts.push('Ctrl', 'Meta')
97
- else if (platform === 'apple' ? event.metaKey : event.ctrlKey) parts.push('Mod')
98
- else if (event.ctrlKey) parts.push('Ctrl')
99
- else if (event.metaKey) parts.push('Meta')
100
- if (event.altKey) parts.push('Alt')
101
- if (event.shiftKey) parts.push('Shift')
102
- parts.push(event.code)
123
+ if (press.ctrlKey && press.metaKey) parts.push('Ctrl', 'Meta')
124
+ else if (platform === 'apple' ? press.metaKey : press.ctrlKey) parts.push('Mod')
125
+ else if (press.ctrlKey) parts.push('Ctrl')
126
+ else if (press.metaKey) parts.push('Meta')
127
+ if (press.altKey) parts.push('Alt')
128
+ if (press.shiftKey) parts.push('Shift')
129
+ parts.push(typed?.code ?? event.code)
103
130
  const keys = parts.join('+')
104
131
  return parseChord(keys) ? keys : null
105
132
  }
@@ -1,18 +1,23 @@
1
- // Keyboard shortcuts, one system for every app: each app's catalogue of actions (id, section, label,
2
- // default keys, scope) and one matcher that says which action a key press is. Keys are named by
3
- // their place on the keyboard (`KeyboardEvent.code`). Framework-free: no React, no DOM beyond reading
4
- // a key event.
1
+ // Keyboard shortcuts, one system for every app: the reader of the shortcut catalogue (every app's
2
+ // actions and default keys, served by the backend) and of a person's own keys, and one matcher that
3
+ // says which action a key press is. Keys are named as a US keyboard names them
4
+ // (`KeyboardEvent.code`) and matched by the character a press types, so they work on any keyboard.
5
+ // Framework-free: no React, no DOM beyond reading a key event.
5
6
 
6
7
  export {
7
8
  parseChord,
8
9
  parseKeys,
9
10
  keysOverlap,
11
+ chordMatch,
10
12
  chordMatches,
11
13
  chordsOverlap,
14
+ typedKey,
12
15
  isCommandChord,
13
16
  type Chord,
14
17
  type ModifierRule,
15
18
  type ModifierState,
19
+ type ChordMatch,
20
+ type ChordPress,
16
21
  } from './chord.js'
17
22
  export {
18
23
  matchShortcut,
@@ -24,21 +29,11 @@ export {
24
29
  isKeyClaimed,
25
30
  isTypingTarget,
26
31
  type ShortcutAction,
32
+ type ShortcutNames,
27
33
  type ShortcutBindings,
28
34
  type ShortcutOverrides,
29
35
  type ShortcutEvent,
30
36
  } from './match.js'
31
- export {
32
- ORBITERS_PLAY_SHORTCUTS,
33
- ORBITERS_STUDIO_SHORTCUTS,
34
- ORBITERS_PLAY_SECTION,
35
- ORBITERS_STUDIO_SECTION,
36
- ORBITERS_KEYBOARD_SURFACE_KEYS,
37
- ORBITERS_COMMAND_GROUPS,
38
- ORBITERS_PLAY_SHEET,
39
- type OrbitersShortcut,
40
- type OrbitersShortcutScope,
41
- } from './orbiters.js'
42
37
  export {
43
38
  shortcutPlatform,
44
39
  keyLabel,
@@ -50,20 +45,28 @@ export {
50
45
  type ShortcutPlatform,
51
46
  type KeyLayout,
52
47
  } from './format.js'
53
- export { SHORTCUT_SECTIONS, SHORTCUT_APPS, type ShortcutSection, type ShortcutAppId } from './sections.js'
54
- export { SITE_SHORTCUTS, SITE_SECTION, type SiteShortcut } from './site.js'
55
48
  export {
56
- EW_STUDIO_SHORTCUTS,
57
- EW_PLAYER_SHORTCUTS,
58
- EW_STUDIO_SECTION,
59
- EW_PLAYER_SECTION,
60
- EW_STUDIO_COMMAND_GROUPS,
61
- type EntangledWorldsShortcut,
62
- } from './entangledWorlds.js'
63
- export { shortcutLabel, shortcutSectionLabel, shortcutName } from './labels.js'
49
+ EMPTY_CATALOGUE,
50
+ parseCatalogue,
51
+ localName,
52
+ localAppName,
53
+ localPartName,
54
+ shortcutApps,
55
+ sectionActions,
56
+ findSection,
57
+ type ShortcutCatalogue,
58
+ type ShortcutSection,
59
+ type ShortcutNamed,
60
+ type ShortcutSheetColumn,
61
+ type ShortcutSheetRow,
62
+ } from './catalogue.js'
64
63
  export {
65
- createShortcutOverridesSource,
64
+ createShortcutSource,
65
+ localShortcutCatalogueStore,
66
+ CATALOGUE_STORAGE_KEY,
66
67
  parseOverrides,
67
- type ShortcutOverridesSource,
68
+ type ShortcutCatalogueStore,
69
+ type ShortcutSource,
68
70
  type ReadAccountSettings,
69
- } from './overrides.js'
71
+ type ReadShortcutCatalogue,
72
+ } from './source.js'
@@ -2,16 +2,16 @@
2
2
  // press names, so every app reads keys the same way: the modifiers, a field being typed in, and a
3
3
  // surface that holds letters of its own are decided here, once.
4
4
 
5
- import { chordMatches, isCommandChord, isModifierCode, keysOverlap, parseChord, parseKeys, type Chord, type ModifierState } from './chord.js'
5
+ import { chordMatch, chordMatches, isCommandChord, isModifierCode, keysOverlap, parseChord, parseKeys, type Chord, type ModifierState } from './chord.js'
6
6
 
7
- /** One thing a key can do, as an app's catalogue declares it. */
7
+ /** One thing a key can do, as the shortcut catalogue (served by the backend) lists it. */
8
8
  export interface ShortcutAction<Scope extends string = string> {
9
9
  /** `<app>.<area>.<action>`, e.g. `orbiters.play.panel.jamming`: the key a person's override is stored under. */
10
10
  readonly id: string
11
- /** The group it is shown in, one per app surface (`orbiters-play`). Keys clash only inside a section. */
12
- readonly section: string
13
- /** What it does, in sentence case. */
11
+ /** What it does, in sentence case, in English. */
14
12
  readonly label: string
13
+ /** Its name in other languages, by locale (`{ es: '…' }`); see `localName`. */
14
+ readonly names?: ShortcutNames
15
15
  /** The keys it has until the person changes them (chord strings, see `chord.ts`). */
16
16
  readonly defaultKeys: readonly string[]
17
17
  /** What it acts on, in the app's own terms (Orbiters: one deck, or this machine). */
@@ -20,14 +20,21 @@ export interface ShortcutAction<Scope extends string = string> {
20
20
  readonly group?: string
21
21
  /** `false`: a key only, never a command to pick from a list (a key held down, the menu itself). */
22
22
  readonly command?: boolean
23
+ /** A site action: the page it opens (`{username}` stands for the signed-in person's). */
24
+ readonly href?: string
25
+ /** It needs someone signed in. */
26
+ readonly signedIn?: boolean
23
27
  }
24
28
 
29
+ /** A name in other languages than English, by locale (`es`). */
30
+ export type ShortcutNames = Readonly<Record<string, string>>
31
+
25
32
  /** The keys of each action, in the order they are tried: the first action a press matches is the one. */
26
33
  export type ShortcutBindings = Iterable<readonly [actionId: string, keys: readonly string[]]>
27
34
 
28
35
  /**
29
36
  * A person's changes, as their settings store them: an action's own keys (`[]` = no key). An action
30
- * left out keeps its defaults, so a default changed in code reaches everyone who never touched it.
37
+ * left out keeps its defaults, so a default changed in the catalogue reaches everyone who never touched it.
31
38
  */
32
39
  export type ShortcutOverrides = ReadonlyArray<{ readonly id: string; readonly keys: readonly string[] }>
33
40
 
@@ -134,20 +141,25 @@ function readsCharacters(target: unknown): boolean {
134
141
 
135
142
  /**
136
143
  * The action this press is, or `null`. Not one while the person is typing in a field, nor a key a
137
- * surface holds; a widget that reads characters keeps its characters but not the ⌘ commands.
144
+ * surface holds; a widget that reads characters keeps its characters but not the ⌘ commands. A key
145
+ * that matches by the character typed wins over one that only matches by the key's place, whatever
146
+ * their order (Shift+7 types `/` on a Spanish keyboard: the `/` action, not the `7` one).
138
147
  */
139
148
  export function matchShortcut(event: ShortcutEvent, bindings: ShortcutBindings): string | null {
140
149
  if (!event.code || isTypingTarget(event.target) || isKeyClaimed(event)) return null
141
150
  const characters = readsCharacters(event.target)
151
+ let byPlace: { id: string; command: boolean } | null = null
142
152
  for (const [actionId, keys] of bindings) {
143
153
  for (const key of keys) {
144
154
  const chord = parseChord(key)
145
- if (!chord || !chordMatches(chord, event.code, event)) continue
146
- if (characters && !isCommandChord(chord)) return null
147
- return actionId
155
+ const how = chord && chordMatch(chord, event)
156
+ if (!chord || !how) continue
157
+ if (how === 'character') return characters && !isCommandChord(chord) ? null : actionId
158
+ byPlace ??= { id: actionId, command: isCommandChord(chord) }
148
159
  }
149
160
  }
150
- return null
161
+ if (!byPlace || (characters && !byPlace.command)) return null
162
+ return byPlace.id
151
163
  }
152
164
 
153
165
  // ---------------------------------------------------------------------------------------------
@@ -173,7 +185,7 @@ function sequences(bindings: ShortcutBindings): Array<readonly [string, Chord[]]
173
185
  * nothing; any other key, a typed one or a wait past `waitMs` forgets it.
174
186
  */
175
187
  export function createShortcutMatcher(waitMs: number = SEQUENCE_WAIT_MS, now: () => number = Date.now) {
176
- let first: (ModifierState & { code: string; at: number }) | null = null
188
+ let first: (ModifierState & { code: string; key: string; at: number }) | null = null
177
189
  return function match(event: ShortcutEvent, bindings: ShortcutBindings): string | null {
178
190
  if (event.code && isModifierCode(event.code)) return null
179
191
  const pending = first && now() - first.at <= waitMs ? first : null
@@ -187,7 +199,7 @@ export function createShortcutMatcher(waitMs: number = SEQUENCE_WAIT_MS, now: ()
187
199
  if (chordMatches(one, pending.code, pending) && chordMatches(two, event.code, event)) return id
188
200
  }
189
201
  if (known.some(([, [one]]) => chordMatches(one, event.code, event))) {
190
- first = { code: event.code, shiftKey: event.shiftKey, altKey: event.altKey, ctrlKey: event.ctrlKey, metaKey: event.metaKey, at: now() }
202
+ first = { code: event.code, key: event.key, shiftKey: event.shiftKey, altKey: event.altKey, ctrlKey: event.ctrlKey, metaKey: event.metaKey, at: now() }
191
203
  return null
192
204
  }
193
205
  return matchShortcut(event, bindings)
@@ -0,0 +1,204 @@
1
+ // Where an app's keys come from, read the same way by every app: the catalogue (every action and its
2
+ // default keys, from the backend) and the person's own changes (their account's settings; someone
3
+ // signed out has the defaults). Both are read at start and again whenever the page comes back to the
4
+ // front, so a key changed in the catalogue or on the settings page applies on return, without a reload.
5
+ //
6
+ // Framework-free: the app hands in how it reads each (its own API client) and gets the catalogue, the
7
+ // overrides, and each list of actions' resolved keys back, kept until either changes.
8
+
9
+ import { EMPTY_CATALOGUE, parseCatalogue, sectionActions, type ShortcutCatalogue } from './catalogue.js'
10
+ import { resolveBindings, type ShortcutAction, type ShortcutOverrides } from './match.js'
11
+
12
+ /** The overrides in a stored value, or `null` when it is not a list of `{ id, keys }`. */
13
+ export function parseOverrides(value: unknown): ShortcutOverrides | null {
14
+ if (!Array.isArray(value)) return null
15
+ return value.filter(
16
+ (entry): entry is { id: string; keys: string[] } =>
17
+ !!entry && typeof entry.id === 'string' && Array.isArray(entry.keys) && entry.keys.every((k: unknown) => typeof k === 'string'),
18
+ )
19
+ }
20
+
21
+ /**
22
+ * How the app reads the signed-in person's settings: `null` when no one is signed in (the
23
+ * default keys); `undefined` when the read failed (the overrides in force are kept: a blip must not
24
+ * swap someone's keys for the defaults).
25
+ */
26
+ export type ReadAccountSettings = (options: { fresh: boolean }) => Promise<{ shortcuts?: unknown } | null | undefined>
27
+
28
+ /**
29
+ * How the app reads the catalogue (`{ version, sections }`); `undefined` or a throw when the read
30
+ * failed (the catalogue in force is kept). There is no copy in the app: until the first read
31
+ * answers, no key does anything.
32
+ */
33
+ export type ReadShortcutCatalogue = (options: { fresh: boolean }) => Promise<unknown>
34
+
35
+ export interface ShortcutSource {
36
+ /** The catalogue in force (empty until it is read). */
37
+ catalogue(): ShortcutCatalogue
38
+ /** The actions of these sections, the first named winning a shared key; the same list until the catalogue changes. */
39
+ actions(...sectionIds: string[]): readonly ShortcutAction[]
40
+ /** The person's overrides in force. */
41
+ overrides(): ShortcutOverrides
42
+ /** A list of actions with their keys now (kept until the catalogue or the overrides change). */
43
+ bindings(actions: readonly ShortcutAction[]): Map<string, readonly string[]>
44
+ /** Called whenever the catalogue or the overrides change; returns the unsubscribe. */
45
+ subscribe(listener: () => void): () => void
46
+ /** Read both again (`fresh`: past the app's own cached read). The newest read of each wins, whatever order they end in. */
47
+ refresh(options?: { fresh?: boolean }): Promise<void>
48
+ /** Read now, and again whenever the page comes back to the front; once per source. */
49
+ bind(): void
50
+ /** For tests: set the overrides directly. */
51
+ setOverrides(overrides: ShortcutOverrides): void
52
+ /** For tests: set the catalogue directly. */
53
+ setCatalogue(catalogue: ShortcutCatalogue): void
54
+ }
55
+
56
+ /**
57
+ * Where a device keeps the last catalogue it read: offline (the desktop app with no connection) the
58
+ * keys are the ones it last had, and on every start they answer before the read comes back. A copy of
59
+ * what was served, never a list of its own.
60
+ */
61
+ export interface ShortcutCatalogueStore {
62
+ load(): unknown
63
+ save(value: unknown): void
64
+ }
65
+
66
+ export const CATALOGUE_STORAGE_KEY = 'ps.shortcutCatalogue'
67
+
68
+ /** This browser's localStorage, when there is one and it may be used; otherwise nothing is kept. */
69
+ export function localShortcutCatalogueStore(): ShortcutCatalogueStore | null {
70
+ const storage = (globalThis as { localStorage?: Storage }).localStorage
71
+ if (!storage) return null
72
+ return {
73
+ load() {
74
+ try {
75
+ const raw = storage.getItem(CATALOGUE_STORAGE_KEY)
76
+ return raw ? JSON.parse(raw) : undefined
77
+ } catch {
78
+ return undefined
79
+ }
80
+ },
81
+ save(value) {
82
+ try {
83
+ storage.setItem(CATALOGUE_STORAGE_KEY, JSON.stringify(value))
84
+ } catch {
85
+ // Full or refused (a private window): the next start reads the backend first, as without a store.
86
+ }
87
+ },
88
+ }
89
+ }
90
+
91
+ /** Coming back to the front twice in a row (visible, then focus) reads once. */
92
+ const REFRESH_GAP_MS = 2000
93
+
94
+ /** Keeps the answer of the newest read that answered: an older one ending later says nothing new. */
95
+ function newestRead<T>(read: (fresh: boolean) => Promise<T | undefined>, apply: (value: T) => void) {
96
+ let latest = 0
97
+ let applied = 0
98
+ return async (fresh: boolean) => {
99
+ const ticket = ++latest
100
+ let value: T | undefined
101
+ try {
102
+ value = await read(fresh)
103
+ } catch {
104
+ value = undefined
105
+ }
106
+ if (value === undefined || ticket < applied) return
107
+ applied = ticket
108
+ apply(value)
109
+ }
110
+ }
111
+
112
+ export function createShortcutSource({
113
+ readCatalogue,
114
+ readAccount,
115
+ store = localShortcutCatalogueStore(),
116
+ }: {
117
+ readCatalogue: ReadShortcutCatalogue
118
+ readAccount: ReadAccountSettings
119
+ /** Where the last catalogue read is kept (this browser's localStorage by default; `null`: nowhere). */
120
+ store?: ShortcutCatalogueStore | null
121
+ }): ShortcutSource {
122
+ let catalogue: ShortcutCatalogue = (store && parseCatalogue(store.load())) || EMPTY_CATALOGUE
123
+ let overrides: ShortcutOverrides = []
124
+ let resolved = new WeakMap<readonly ShortcutAction[], Map<string, readonly string[]>>()
125
+ let lastRefreshAt = -Infinity
126
+ let bound = false
127
+ const listeners = new Set<() => void>()
128
+
129
+ const changed = () => {
130
+ resolved = new WeakMap()
131
+ listeners.forEach((listener) => listener())
132
+ }
133
+ const applyOverrides = (next: ShortcutOverrides) => {
134
+ if (JSON.stringify(next) === JSON.stringify(overrides)) return
135
+ overrides = next
136
+ changed()
137
+ }
138
+ const applyCatalogue = (next: ShortcutCatalogue) => {
139
+ if (next === catalogue || (next.version && next.version === catalogue.version && next.sections.length === catalogue.sections.length)) return
140
+ catalogue = next
141
+ changed()
142
+ }
143
+
144
+ const refreshCatalogue = newestRead(
145
+ async (fresh) => {
146
+ const value = await readCatalogue({ fresh })
147
+ const read = value === undefined ? null : parseCatalogue(value)
148
+ if (!read) return undefined
149
+ if (read.version !== catalogue.version) store?.save(value)
150
+ return read
151
+ },
152
+ applyCatalogue,
153
+ )
154
+ const refreshAccount = newestRead(
155
+ async (fresh) => {
156
+ const settings = await readAccount({ fresh })
157
+ return settings === undefined ? undefined : settings === null ? [] : (parseOverrides(settings.shortcuts) ?? [])
158
+ },
159
+ applyOverrides,
160
+ )
161
+
162
+ const refresh = async ({ fresh = false }: { fresh?: boolean } = {}) => {
163
+ lastRefreshAt = Date.now()
164
+ await Promise.all([refreshCatalogue(fresh), refreshAccount(fresh)])
165
+ }
166
+
167
+ return {
168
+ catalogue: () => catalogue,
169
+ actions: (...sectionIds) => sectionActions(catalogue, ...sectionIds),
170
+ overrides: () => overrides,
171
+ bindings(actions) {
172
+ let map = resolved.get(actions)
173
+ if (!map) {
174
+ map = resolveBindings(actions, overrides)
175
+ resolved.set(actions, map)
176
+ }
177
+ return map
178
+ },
179
+ subscribe(listener) {
180
+ listeners.add(listener)
181
+ return () => {
182
+ listeners.delete(listener)
183
+ }
184
+ },
185
+ refresh,
186
+ bind() {
187
+ const win = globalThis as unknown as {
188
+ addEventListener?: (type: string, fn: () => void) => void
189
+ document?: { visibilityState?: string; addEventListener?: (type: string, fn: () => void) => void }
190
+ }
191
+ if (bound || typeof win.addEventListener !== 'function' || !win.document?.addEventListener) return
192
+ bound = true
193
+ void refresh()
194
+ const onFront = () => {
195
+ if (win.document?.visibilityState !== 'visible' || Date.now() - lastRefreshAt < REFRESH_GAP_MS) return
196
+ void refresh({ fresh: true })
197
+ }
198
+ win.document.addEventListener('visibilitychange', onFront)
199
+ win.addEventListener('focus', onFront)
200
+ },
201
+ setOverrides: applyOverrides,
202
+ setCatalogue: applyCatalogue,
203
+ }
204
+ }
@@ -0,0 +1,99 @@
1
+ // Star and line buffers. One layout for every sky: direction `Int16 × 3` (normalised) + colour and
2
+ // brightness `Uint8 × 4` (normalised) = 10 bytes a star, the world-sky file's own layout, so a world
3
+ // sky's catalogue stars are copied in as they are and only the seeded stars are worked out here.
4
+
5
+ import * as THREE from 'three';
6
+ import { blackbodyColor } from '../star/blackbody.js';
7
+ import { createLcg } from '../star/layout.js';
8
+ import type { WorldSky } from './worldSkyFile.js';
9
+
10
+ const INT16 = 32767;
11
+
12
+ /** The band's frame: `basis` maps band space (x toward the core, y the band's pole) to the scene. */
13
+ export interface BandFrame {
14
+ basis: THREE.Matrix4;
15
+ core: THREE.Vector3;
16
+ }
17
+
18
+ /** An equatorial direction in the scene frame (y = celestial north). */
19
+ export function equatorial(raDeg: number, decDeg: number, out = new THREE.Vector3()): THREE.Vector3 {
20
+ const a = THREE.MathUtils.degToRad(raDeg);
21
+ const d = THREE.MathUtils.degToRad(decDeg);
22
+ return out.set(Math.cos(d) * Math.cos(a), Math.sin(d), -Math.cos(d) * Math.sin(a));
23
+ }
24
+
25
+ /** A seeded band, tilted `tiltDeg` from the equator, its core at a seeded longitude. */
26
+ export function seededBandFrame(seed: number, tiltDeg: number): BandFrame {
27
+ const basis = new THREE.Matrix4().makeRotationX(THREE.MathUtils.degToRad(tiltDeg));
28
+ const lon = createLcg(seed ^ 0x5bd1e995)() * Math.PI * 2;
29
+ basis.multiply(new THREE.Matrix4().makeRotationY(-lon));
30
+ return { basis, core: new THREE.Vector3(1, 0, 0).applyMatrix4(basis).normalize() };
31
+ }
32
+
33
+ /**
34
+ * The Milky Way: the galactic plane (J2000 north pole and centre). From every catalogued host it
35
+ * is the same plane to well under a degree, so it holds for every world sky.
36
+ */
37
+ export function galacticBandFrame(): BandFrame {
38
+ const pole = equatorial(192.85948, 27.12825);
39
+ const centre = equatorial(266.40499, -28.93617);
40
+ const x = centre.sub(pole.clone().multiplyScalar(centre.dot(pole))).normalize();
41
+ const z = new THREE.Vector3().crossVectors(x, pole);
42
+ return { basis: new THREE.Matrix4().makeBasis(x, pole, z), core: x.clone() };
43
+ }
44
+
45
+ /** Naked-eye stand-ins reach this brightness (0..1), under the glow tier so only real stars glow; faint fill stays under the catalogue's limit. */
46
+ const STAND_IN_BRIGHTNESS = 0.7;
47
+ const FILL_BRIGHTNESS = 0.22;
48
+
49
+ /**
50
+ * The star buffer, exactly `count` stars (the device's star budget). A world sky first: its
51
+ * catalogue stars copied as they are (brightest first, so a small budget keeps the brightest), then
52
+ * its stand-ins, then seeded faint fill; a seeded sky is `count` seeded stars. Stars gather into the
53
+ * band by `band`.
54
+ */
55
+ export function buildStarGeometry(opts: { seed: number; count: number; band: number; frame: BandFrame; world?: WorldSky | null }): THREE.BufferGeometry {
56
+ const { seed, band, frame, world } = opts;
57
+ const total = Math.max(0, opts.count);
58
+ const worldStars = world ? Math.min(world.starCount, total) : 0;
59
+ const standIns = world ? Math.min(world.standIns, total - worldStars) : 0;
60
+ const dir = new Int16Array(total * 3);
61
+ const cb = new Uint8Array(total * 4);
62
+ if (world) {
63
+ dir.set(world.directions.subarray(0, worldStars * 3));
64
+ cb.set(world.colourBrightness.subarray(0, worldStars * 4));
65
+ }
66
+ const rand = createLcg(seed);
67
+ const v = new THREE.Vector3();
68
+ const bandShare = 0.1 + 0.5 * band;
69
+ for (let i = worldStars; i < total; i++) {
70
+ const inBand = rand() < bandShare;
71
+ const lon = rand() * Math.PI * 2;
72
+ const lat = inBand ? (rand() + rand() + rand() - 1.5) * 0.2 : Math.asin(rand() * 2 - 1);
73
+ v.set(Math.cos(lat) * Math.cos(lon), Math.sin(lat), Math.cos(lat) * Math.sin(lon)).applyMatrix4(frame.basis);
74
+ dir[i * 3] = Math.round(v.x * INT16);
75
+ dir[i * 3 + 1] = Math.round(v.y * INT16);
76
+ dir[i * 3 + 2] = Math.round(v.z * INT16);
77
+ // Many faint, few bright. Under a world sky: stand-ins at naked-eye brightness, then faint fill.
78
+ const scale = !world ? 1 : i < worldStars + standIns ? STAND_IN_BRIGHTNESS : FILL_BRIGHTNESS;
79
+ const c = blackbodyColor(2800 + Math.pow(rand(), 2.2) * 12000);
80
+ cb[i * 4] = Math.round(c.r * 255);
81
+ cb[i * 4 + 1] = Math.round(c.g * 255);
82
+ cb[i * 4 + 2] = Math.round(c.b * 255);
83
+ cb[i * 4 + 3] = Math.round(Math.pow(rand(), 4) * scale * 255);
84
+ }
85
+ const geo = new THREE.BufferGeometry();
86
+ geo.setAttribute('position', new THREE.BufferAttribute(dir, 3, true));
87
+ geo.setAttribute('aColourBrightness', new THREE.BufferAttribute(cb, 4, true));
88
+ // Stars sit at infinity in every direction: never frustum-cull them.
89
+ geo.boundingSphere = new THREE.Sphere(new THREE.Vector3(), Infinity);
90
+ return geo;
91
+ }
92
+
93
+ /** The constellation figures of a world sky, straight from the file. */
94
+ export function buildLineGeometry(world: WorldSky): THREE.BufferGeometry {
95
+ const geo = new THREE.BufferGeometry();
96
+ geo.setAttribute('position', new THREE.BufferAttribute(world.lines, 3, true));
97
+ geo.boundingSphere = new THREE.Sphere(new THREE.Vector3(), Infinity);
98
+ return geo;
99
+ }
@@ -0,0 +1,16 @@
1
+ // ./sky — the sky behind a world: a parametric star field (seeded, or the world's own sky as seen
2
+ // from its host star, read from the backend's precomputed file), an optional nebula + Milky Way
3
+ // baked once per change, and constellation figures. Replaces the six-face painted skycube.
4
+
5
+ export { createSky } from './sky.js';
6
+ export type { Sky, CreateSkyOptions } from './sky.js';
7
+ export {
8
+ DEFAULT_SKY_PARAMS,
9
+ SKY_PARAM_RANGES,
10
+ SKY_LOOK_KEYS,
11
+ SKY_SHAPE_KEYS,
12
+ normalizeSkyParams,
13
+ } from './params.js';
14
+ export type { SkyParams, SkySource } from './params.js';
15
+ export { parseWorldSky, WORLD_SKY_MAGIC, WORLD_SKY_FORMAT } from './worldSkyFile.js';
16
+ export type { WorldSky } from './worldSkyFile.js';