lgimgui 0.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.
@@ -0,0 +1,294 @@
1
+ import { rectContains } from './geometry.js'
2
+
3
+ const KEY_NAMES = {
4
+ ' ': 'Space',
5
+ ArrowLeft: 'Left',
6
+ ArrowRight: 'Right',
7
+ ArrowUp: 'Up',
8
+ ArrowDown: 'Down',
9
+ }
10
+
11
+ function keyName(event) {
12
+ return KEY_NAMES[event.key] ?? event.key
13
+ }
14
+
15
+ export function createInput(canvas) {
16
+ const queue = []
17
+ let hitList = []
18
+ let scrollTargets = []
19
+ let windowFocused = true
20
+
21
+ const frame = emptyFrame()
22
+ let pointer = { x: -1, y: -1, type: 'mouse' }
23
+ let active = null
24
+ let hot = null
25
+ let hotEntry = null
26
+ let pressOrigin = { x: 0, y: 0 }
27
+
28
+ function emptyFrame() {
29
+ return {
30
+ x: -1,
31
+ y: -1,
32
+ dx: 0,
33
+ dy: 0,
34
+ pointerType: 'mouse',
35
+ down: false,
36
+ pressed: new Set(),
37
+ released: new Set(),
38
+ clicked: new Set(),
39
+ pressedAnywhere: false,
40
+ releasedAnywhere: false,
41
+ hot: null,
42
+ active: null,
43
+ dragOrigin: { x: 0, y: 0 },
44
+ wheelX: 0,
45
+ wheelY: 0,
46
+ wheelTarget: null,
47
+ wheelTargetX: null,
48
+ pinch: 0,
49
+ keysDown: new Set(),
50
+ keysPressed: new Set(),
51
+ keysReleased: new Set(),
52
+ text: '',
53
+ windowFocused: true,
54
+ moved: false,
55
+ idle: true,
56
+ context: null,
57
+ contextEntry: null,
58
+ pressedEntry: null,
59
+ }
60
+ }
61
+
62
+ function position(event) {
63
+ const bounds = canvas.getBoundingClientRect()
64
+ return { x: event.clientX - bounds.left, y: event.clientY - bounds.top }
65
+ }
66
+
67
+ function topmost(x, y) {
68
+ for (let i = hitList.length - 1; i >= 0; i--) {
69
+ const entry = hitList[i]
70
+ if (!rectContains(entry.rect, x, y)) continue
71
+ if (entry.clip && !rectContains(entry.clip, x, y)) continue
72
+ return entry
73
+ }
74
+ return null
75
+ }
76
+
77
+ function innermostScroll(x, y, horizontal) {
78
+ let target = null
79
+ for (const entry of scrollTargets) {
80
+ if (entry.horizontal !== horizontal) continue
81
+ if (rectContains(entry.rect, x, y) && (!entry.clip || rectContains(entry.clip, x, y))) target = entry
82
+ }
83
+ return target
84
+ }
85
+
86
+ function refreshHot() {
87
+ const entry = topmost(pointer.x, pointer.y)
88
+ hotEntry = entry
89
+ if (active) {
90
+ hot = entry && entry.id === active ? active : null
91
+ return
92
+ }
93
+ hot = entry ? entry.id : null
94
+ }
95
+
96
+ const handlers = {
97
+ pointerdown(event) {
98
+ if (event.button !== 0 && event.pointerType === 'mouse') return
99
+ pointer = { ...position(event), type: event.pointerType }
100
+ canvas.setPointerCapture?.(event.pointerId)
101
+ canvas.focus?.({ preventScroll: true })
102
+ queue.push({ type: 'down', x: pointer.x, y: pointer.y, pointerType: event.pointerType })
103
+ event.preventDefault()
104
+ },
105
+ pointermove(event) {
106
+ pointer = { ...position(event), type: event.pointerType }
107
+ queue.push({ type: 'move', x: pointer.x, y: pointer.y, pointerType: event.pointerType })
108
+ },
109
+ pointerup(event) {
110
+ if (event.button !== 0 && event.pointerType === 'mouse') return
111
+ pointer = { ...position(event), type: event.pointerType }
112
+ queue.push({ type: 'up', x: pointer.x, y: pointer.y })
113
+ },
114
+ contextmenu(event) {
115
+ pointer = { ...position(event), type: 'mouse' }
116
+ canvas.focus?.({ preventScroll: true })
117
+ queue.push({ type: 'context', x: pointer.x, y: pointer.y })
118
+ event.preventDefault()
119
+ },
120
+ pointercancel() {
121
+ queue.push({ type: 'up', x: pointer.x, y: pointer.y })
122
+ },
123
+ pointerleave() {
124
+ queue.push({ type: 'leave' })
125
+ },
126
+ wheel(event) {
127
+ const scale = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? 400 : 1
128
+ const pos = position(event)
129
+ queue.push({ type: 'wheel', x: pos.x, y: pos.y, dx: event.deltaX * scale, dy: event.deltaY * scale, pinch: Boolean(event.ctrlKey) })
130
+ event.preventDefault()
131
+ },
132
+ keydown(event) {
133
+ const name = keyName(event)
134
+ queue.push({ type: 'keydown', key: name, repeat: event.repeat, meta: event.metaKey || event.ctrlKey, shift: event.shiftKey })
135
+ if (event.key.length === 1 && !event.metaKey && !event.ctrlKey) queue.push({ type: 'text', text: event.key })
136
+ if (name === 'Tab' || name === 'Space' || name.startsWith('Arrow')) event.preventDefault()
137
+ },
138
+ keyup(event) {
139
+ queue.push({ type: 'keyup', key: keyName(event) })
140
+ },
141
+ blur() {
142
+ windowFocused = false
143
+ queue.push({ type: 'up', x: pointer.x, y: pointer.y })
144
+ queue.push({ type: 'blur' })
145
+ },
146
+ focus() {
147
+ windowFocused = true
148
+ queue.push({ type: 'focus' })
149
+ },
150
+ }
151
+
152
+ function attach() {
153
+ canvas.tabIndex = canvas.tabIndex >= 0 ? canvas.tabIndex : 0
154
+ canvas.style.touchAction = 'none'
155
+ canvas.style.outline = 'none'
156
+ for (const [name, handler] of Object.entries(handlers)) {
157
+ const target = name === 'blur' || name === 'focus' ? window : canvas
158
+ target.addEventListener(name, handler, { passive: !['wheel', 'pointerdown', 'keydown', 'contextmenu'].includes(name) })
159
+ }
160
+ }
161
+
162
+ function detach() {
163
+ for (const [name, handler] of Object.entries(handlers)) {
164
+ const target = name === 'blur' || name === 'focus' ? window : canvas
165
+ target.removeEventListener(name, handler)
166
+ }
167
+ }
168
+
169
+ function pending() {
170
+ return queue.length > 0
171
+ }
172
+
173
+ function beginFrame() {
174
+ const next = emptyFrame()
175
+ next.idle = queue.length === 0
176
+ next.keysDown = frame.keysDown
177
+ const previousX = frame.x
178
+ const previousY = frame.y
179
+ let releaseActive = false
180
+
181
+ for (const event of queue) {
182
+ switch (event.type) {
183
+ case 'move':
184
+ pointer.x = event.x
185
+ pointer.y = event.y
186
+ next.pointerType = event.pointerType
187
+ refreshHot()
188
+ break
189
+ case 'down': {
190
+ pointer.x = event.x
191
+ pointer.y = event.y
192
+ next.pointerType = event.pointerType
193
+ active = null
194
+ refreshHot()
195
+ next.down = true
196
+ next.pressedAnywhere = true
197
+ next.pressedEntry = hotEntry
198
+ pressOrigin = { x: event.x, y: event.y }
199
+ if (hot) {
200
+ active = hot
201
+ next.pressed.add(hot)
202
+ }
203
+ break
204
+ }
205
+ case 'up': {
206
+ pointer.x = event.x
207
+ pointer.y = event.y
208
+ refreshHot()
209
+ next.releasedAnywhere = true
210
+ if (active) {
211
+ next.released.add(active)
212
+ if (hot === active) next.clicked.add(active)
213
+ releaseActive = true
214
+ }
215
+ next.down = false
216
+ break
217
+ }
218
+ case 'context':
219
+ pointer.x = event.x
220
+ pointer.y = event.y
221
+ refreshHot()
222
+ next.context = { x: event.x, y: event.y }
223
+ next.contextEntry = hotEntry
224
+ break
225
+ case 'leave':
226
+ if (!active) {
227
+ hot = null
228
+ hotEntry = null
229
+ }
230
+ break
231
+ case 'wheel': {
232
+ if (event.pinch) {
233
+ next.pinch += event.dy
234
+ break
235
+ }
236
+ next.wheelX += event.dx
237
+ next.wheelY += event.dy
238
+ const horizontal = Math.abs(event.dx) > Math.abs(event.dy)
239
+ const target = innermostScroll(event.x, event.y, horizontal)
240
+ if (horizontal) next.wheelTargetX = target ? target.id : null
241
+ else next.wheelTarget = target ? target.id : null
242
+ break
243
+ }
244
+ case 'keydown':
245
+ next.keysPressed.add(event.key)
246
+ next.keysDown.add(event.key)
247
+ if (event.meta) next.keysDown.add('Meta')
248
+ if (event.shift) next.keysDown.add('Shift')
249
+ else next.keysDown.delete('Shift')
250
+ break
251
+ case 'keyup':
252
+ next.keysReleased.add(event.key)
253
+ next.keysDown.delete(event.key)
254
+ if (event.key === 'Meta' || event.key === 'Control') next.keysDown.delete('Meta')
255
+ break
256
+ case 'text':
257
+ next.text += event.text
258
+ break
259
+ case 'blur':
260
+ next.keysDown.clear()
261
+ break
262
+ default:
263
+ break
264
+ }
265
+ }
266
+ queue.length = 0
267
+
268
+ if (!next.pressedAnywhere && !next.releasedAnywhere) refreshHot()
269
+
270
+ next.x = pointer.x
271
+ next.y = pointer.y
272
+ next.dx = previousX < 0 ? 0 : pointer.x - previousX
273
+ next.dy = previousY < 0 ? 0 : pointer.y - previousY
274
+ next.moved = next.dx !== 0 || next.dy !== 0
275
+ next.down = active !== null && !releaseActive ? true : next.down && !releaseActive
276
+ next.hot = hot
277
+ next.active = active
278
+ next.hotEntry = hotEntry
279
+ next.dragOrigin = pressOrigin
280
+ next.windowFocused = windowFocused
281
+
282
+ if (releaseActive) active = null
283
+
284
+ Object.assign(frame, next)
285
+ return frame
286
+ }
287
+
288
+ function endFrame(list, scrolls) {
289
+ hitList = list
290
+ scrollTargets = scrolls
291
+ }
292
+
293
+ return { attach, detach, beginFrame, endFrame, pending, get frame() { return frame }, get hits() { return hitList }, get pointer() { return pointer } }
294
+ }
@@ -0,0 +1,220 @@
1
+ import { padding } from './geometry.js'
2
+
3
+ export function scaleNode(node, cx, cy, factor) {
4
+ if (factor === 1) return
5
+ node.rect = {
6
+ x: cx + (node.rect.x - cx) * factor,
7
+ y: cy + (node.rect.y - cy) * factor,
8
+ w: node.rect.w * factor,
9
+ h: node.rect.h * factor,
10
+ }
11
+ node.drawScale = (node.drawScale ?? 1) * factor
12
+ for (const child of node.children) scaleNode(child, cx, cy, factor)
13
+ }
14
+
15
+ export function translateNode(node, dx, dy) {
16
+ if (dx === 0 && dy === 0) return
17
+ node.rect = { ...node.rect, x: node.rect.x + dx, y: node.rect.y + dy }
18
+ for (const child of node.children) translateNode(child, dx, dy)
19
+ }
20
+
21
+ function applyTransform(node) {
22
+ const transform = node.props.transform
23
+ if (!transform) return
24
+ for (const child of node.children) {
25
+ if (child.props.detached) continue
26
+ scaleNode(child, node.rect.x, node.rect.y, transform.scale ?? 1)
27
+ translateNode(child, transform.tx ?? 0, transform.ty ?? 0)
28
+ }
29
+ }
30
+
31
+ const MAIN = { row: 'w', column: 'h' }
32
+ const CROSS = { row: 'h', column: 'w' }
33
+
34
+ function axisSize(node, axis) {
35
+ return axis === 'w' ? node.props.w : node.props.h
36
+ }
37
+
38
+ function mainAxisOf(node) {
39
+ const direction = node.parent?.props.direction ?? 'row'
40
+ return direction === 'column' ? 'h' : direction === 'row' ? 'w' : null
41
+ }
42
+
43
+ function isFlexible(node, axis) {
44
+ const size = axisSize(node, axis)
45
+ return size === 'fill' || ((node.props.fr ?? 0) > 0 && mainAxisOf(node) === axis)
46
+ }
47
+
48
+ function fixed(node, axis) {
49
+ const size = axisSize(node, axis)
50
+ if (typeof size !== 'number') return null
51
+ if (node.props.reveal != null) return size * Math.max(0, Math.min(1, node.props.reveal))
52
+ return size
53
+ }
54
+
55
+ function flowChildren(node) {
56
+ return node.children.filter((child) => child.props.x == null && child.props.y == null && !child.props.detached)
57
+ }
58
+
59
+ export function measure(node, context) {
60
+ for (const child of node.children) measure(child, context)
61
+ const pad = padding(node.props.pad)
62
+ const gap = node.props.gap ?? 0
63
+ const direction = node.props.direction ?? 'row'
64
+ const flow = flowChildren(node)
65
+
66
+ let w = 0
67
+ let h = 0
68
+ if (node.intrinsic) {
69
+ const size = node.intrinsic(context)
70
+ w = size.w
71
+ h = size.h
72
+ } else if (direction === 'stack') {
73
+ for (const child of flow) {
74
+ w = Math.max(w, child.natural.w)
75
+ h = Math.max(h, child.natural.h)
76
+ }
77
+ } else {
78
+ const main = MAIN[direction]
79
+ const cross = CROSS[direction]
80
+ let mainTotal = 0
81
+ let crossMax = 0
82
+ let present = 0
83
+ for (const child of flow) {
84
+ mainTotal += child.natural[main]
85
+ crossMax = Math.max(crossMax, child.natural[cross])
86
+ if (child.natural[main] > 0) present += 1
87
+ }
88
+ mainTotal += Math.max(0, present - 1) * gap
89
+ if (main === 'w') {
90
+ w = mainTotal
91
+ h = crossMax
92
+ } else {
93
+ h = mainTotal
94
+ w = crossMax
95
+ }
96
+ }
97
+ w += pad[1] + pad[3]
98
+ h += pad[0] + pad[2]
99
+
100
+ const fixedW = fixed(node, 'w')
101
+ const fixedH = fixed(node, 'h')
102
+ node.natural = {
103
+ w: Math.max(node.props.minW ?? 0, fixedW ?? (isFlexible(node, 'w') ? node.props.minW ?? 0 : w)),
104
+ h: Math.max(node.props.minH ?? 0, fixedH ?? (isFlexible(node, 'h') ? node.props.minH ?? 0 : h)),
105
+ }
106
+ if (node.props.reveal != null) {
107
+ const reveal = Math.max(0, Math.min(1, node.props.reveal))
108
+ node.natural.w *= reveal
109
+ node.natural.h *= reveal
110
+ }
111
+ node.content = { w, h }
112
+ }
113
+
114
+ function alignOffset(align, available, size) {
115
+ if (align === 'center') return (available - size) / 2
116
+ if (align === 'end') return available - size
117
+ return 0
118
+ }
119
+
120
+ function placeAbsolute(child, node, pad) {
121
+ const inner = innerRect(node.rect, pad)
122
+ const w = fixed(child, 'w') ?? (isFlexible(child, 'w') ? inner.w : child.natural.w)
123
+ const h = fixed(child, 'h') ?? (isFlexible(child, 'h') ? inner.h : child.natural.h)
124
+ const x = child.props.x ?? alignOffset(child.props.alignSelf ?? node.props.align ?? 'start', inner.w, w)
125
+ const y = child.props.y ?? alignOffset(child.props.alignSelf ?? node.props.align ?? 'start', inner.h, h)
126
+ child.rect = { x: inner.x + x, y: inner.y + y, w, h }
127
+ }
128
+
129
+ function innerRect(rect, pad) {
130
+ return {
131
+ x: rect.x + pad[3],
132
+ y: rect.y + pad[0],
133
+ w: Math.max(0, rect.w - pad[1] - pad[3]),
134
+ h: Math.max(0, rect.h - pad[0] - pad[2]),
135
+ }
136
+ }
137
+
138
+ export function position(node, rect) {
139
+ node.rect = rect
140
+ const pad = padding(node.props.pad)
141
+ const inner = innerRect(rect, pad)
142
+ const direction = node.props.direction ?? 'row'
143
+ const flow = flowChildren(node)
144
+ const gap = node.props.gap ?? 0
145
+ const align = node.props.align ?? 'start'
146
+
147
+ for (const child of node.children) {
148
+ if (child.props.detached) continue
149
+ if (child.props.x != null || child.props.y != null) placeAbsolute(child, node, pad)
150
+ }
151
+
152
+ if (direction === 'stack') {
153
+ for (const child of flow) {
154
+ const w = fixed(child, 'w') ?? (isFlexible(child, 'w') || align === 'stretch' ? inner.w : child.natural.w)
155
+ const h = fixed(child, 'h') ?? (isFlexible(child, 'h') || align === 'stretch' ? inner.h : child.natural.h)
156
+ const selfAlign = child.props.alignSelf ?? align
157
+ child.rect = {
158
+ x: inner.x + alignOffset(selfAlign, inner.w, w),
159
+ y: inner.y + alignOffset(selfAlign, inner.h, h),
160
+ w,
161
+ h,
162
+ }
163
+ }
164
+ } else {
165
+ const main = MAIN[direction]
166
+ const cross = CROSS[direction]
167
+ const mainAvailable = inner[main]
168
+ const crossAvailable = inner[cross]
169
+ let used = 0
170
+ let shares = 0
171
+ let present = 0
172
+ for (const child of flow) {
173
+ if (isFlexible(child, main)) {
174
+ shares += child.props.fr ?? 1
175
+ present += 1
176
+ } else {
177
+ const size = fixed(child, main) ?? child.natural[main]
178
+ used += size
179
+ if (size > 0) present += 1
180
+ }
181
+ }
182
+ used += Math.max(0, present - 1) * gap
183
+ const leftover = Math.max(0, mainAvailable - used)
184
+ const justify = node.props.justify ?? 'start'
185
+ let cursor = 0
186
+ let spacing = gap
187
+ if (shares === 0) {
188
+ if (justify === 'center') cursor = leftover / 2
189
+ else if (justify === 'end') cursor = leftover
190
+ else if (justify === 'between' && flow.length > 1) spacing = gap + leftover / (flow.length - 1)
191
+ }
192
+ for (const child of flow) {
193
+ const flexible = isFlexible(child, main)
194
+ const mainSize = flexible
195
+ ? Math.max(child.natural[main], (leftover * (child.props.fr ?? 1)) / shares)
196
+ : (fixed(child, main) ?? child.natural[main])
197
+ const selfAlign = child.props.alignSelf ?? align
198
+ const crossSize =
199
+ fixed(child, cross) ??
200
+ (isFlexible(child, cross) || selfAlign === 'stretch' ? crossAvailable : Math.min(child.natural[cross], crossAvailable))
201
+ const crossOffset = alignOffset(selfAlign, crossAvailable, crossSize)
202
+ child.rect =
203
+ main === 'w'
204
+ ? { x: inner.x + cursor, y: inner.y + crossOffset, w: mainSize, h: crossSize }
205
+ : { x: inner.x + crossOffset, y: inner.y + cursor, w: crossSize, h: mainSize }
206
+ cursor += mainSize + (mainSize > 0 || flexible ? spacing : 0)
207
+ }
208
+ }
209
+
210
+ for (const child of node.children) {
211
+ if (child.props.detached) continue
212
+ position(child, child.rect)
213
+ }
214
+ applyTransform(node)
215
+ }
216
+
217
+ export function layout(root, viewport, context) {
218
+ measure(root, context)
219
+ position(root, viewport)
220
+ }
@@ -0,0 +1,113 @@
1
+ const DEFAULT = { stiffness: 260, damping: 26, mass: 1, rest: 0.001 }
2
+ const MAX_STEP = 1 / 240
3
+
4
+ export const springs = {
5
+ snappy: { stiffness: 420, damping: 30, mass: 1 },
6
+ gentle: { stiffness: 170, damping: 22, mass: 1 },
7
+ bouncy: { stiffness: 300, damping: 16, mass: 1 },
8
+ lift: { stiffness: 320, damping: 24, mass: 1 },
9
+ morph: { stiffness: 240, damping: 26, mass: 1 },
10
+ presence: { stiffness: 200, damping: 24, mass: 1 },
11
+ reveal: { stiffness: 900, damping: 50, mass: 1 },
12
+ fade: { stiffness: 500, damping: 46, mass: 1 },
13
+ liquid: { stiffness: 140, damping: 24, mass: 1 },
14
+ }
15
+
16
+ export function springForDuration(milliseconds) {
17
+ const omega = 7.5 / Math.max(0.05, milliseconds / 1000)
18
+ return { stiffness: omega * omega, damping: 2 * omega, mass: 1 }
19
+ }
20
+
21
+ export function createSpringClock() {
22
+ let dt = 0
23
+ let animating = false
24
+ let reduceMotion = false
25
+
26
+ function tick(seconds) {
27
+ dt = Math.min(seconds, 1 / 20)
28
+ animating = false
29
+ }
30
+
31
+ function step(spring, target, opts) {
32
+ const config = opts ?? DEFAULT
33
+ if (reduceMotion) {
34
+ spring.value = target
35
+ spring.velocity = 0
36
+ return target
37
+ }
38
+ const stiffness = config.stiffness ?? DEFAULT.stiffness
39
+ const damping = config.damping ?? DEFAULT.damping
40
+ const mass = config.mass ?? DEFAULT.mass
41
+ const rest = config.rest ?? DEFAULT.rest
42
+ const steps = Math.max(1, Math.ceil(dt / MAX_STEP))
43
+ const h = dt / steps
44
+ for (let i = 0; i < steps; i++) {
45
+ const displacement = spring.value - target
46
+ const force = -stiffness * displacement - damping * spring.velocity
47
+ spring.velocity += (force / mass) * h
48
+ spring.value += spring.velocity * h
49
+ }
50
+ const settled = Math.abs(spring.velocity) < rest && Math.abs(spring.value - target) < rest
51
+ if (settled) {
52
+ spring.value = target
53
+ spring.velocity = 0
54
+ } else {
55
+ animating = true
56
+ }
57
+ return spring.value
58
+ }
59
+
60
+ function animate(data, key, target, opts) {
61
+ let spring = data[key]
62
+ if (!spring) {
63
+ spring = { value: target, velocity: 0 }
64
+ data[key] = spring
65
+ return target
66
+ }
67
+ return step(spring, target, opts)
68
+ }
69
+
70
+ function animateFrom(data, key, initial, target, opts) {
71
+ if (!data[key]) data[key] = { value: initial, velocity: 0 }
72
+ return step(data[key], target, opts)
73
+ }
74
+
75
+ function ease(data, key, target, speed = 8) {
76
+ let current = data[key]
77
+ if (current == null) {
78
+ data[key] = target
79
+ return target
80
+ }
81
+ if (reduceMotion) {
82
+ data[key] = target
83
+ return target
84
+ }
85
+ const next = current + (target - current) * Math.min(1, speed * dt)
86
+ if (Math.abs(next - target) < 0.0005) {
87
+ data[key] = target
88
+ return target
89
+ }
90
+ animating = true
91
+ data[key] = next
92
+ return next
93
+ }
94
+
95
+ return {
96
+ tick,
97
+ animate,
98
+ animateFrom,
99
+ ease,
100
+ get dt() {
101
+ return dt
102
+ },
103
+ get animating() {
104
+ return animating
105
+ },
106
+ get reduceMotion() {
107
+ return reduceMotion
108
+ },
109
+ set reduceMotion(value) {
110
+ reduceMotion = value
111
+ },
112
+ }
113
+ }
@@ -0,0 +1,54 @@
1
+ export function createStateStore() {
2
+ const entries = new Map()
3
+ let frame = 0
4
+
5
+ function get(id, init) {
6
+ let entry = entries.get(id)
7
+ if (!entry) {
8
+ entry = { seen: frame, data: init ? init() : {} }
9
+ entries.set(id, entry)
10
+ }
11
+ entry.seen = frame
12
+ return entry.data
13
+ }
14
+
15
+ function peek(id) {
16
+ return entries.get(id)?.data ?? null
17
+ }
18
+
19
+ function has(id) {
20
+ return entries.has(id)
21
+ }
22
+
23
+ function remove(id) {
24
+ entries.delete(id)
25
+ }
26
+
27
+ function nextFrame() {
28
+ frame += 1
29
+ }
30
+
31
+ function collect(keepFrames = 2) {
32
+ for (const [id, entry] of entries) {
33
+ if (frame - entry.seen > keepFrames && !entry.data.retained) entries.delete(id)
34
+ }
35
+ }
36
+
37
+ function unseen() {
38
+ const result = []
39
+ for (const [id, entry] of entries) {
40
+ if (entry.seen !== frame) result.push([id, entry.data])
41
+ }
42
+ return result
43
+ }
44
+
45
+ function size() {
46
+ return entries.size
47
+ }
48
+
49
+ function keys() {
50
+ return [...entries.keys()]
51
+ }
52
+
53
+ return { get, peek, has, remove, nextFrame, collect, unseen, size, keys }
54
+ }