@brandonlukas/luminar 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,424 @@
1
+ import type { ColorPreset, ParticleParams, SliderHandle } from '../lib/types'
2
+ import { COLOR_PRESETS, defaultParams, FIELD_BORDER_MIN, FIELD_BORDER_MAX } from '../lib/constants'
3
+ import { PointsMaterial } from 'three'
4
+ import { UnrealBloomPass } from 'three/examples/jsm/postprocessing/UnrealBloomPass.js'
5
+
6
+ export class ControlPanel {
7
+ private panel!: HTMLDivElement
8
+ private controlHandles = new Map<string, SliderHandle>()
9
+ private selectHandles = new Map<string, HTMLSelectElement>()
10
+ private trailToggle?: HTMLInputElement
11
+ private container: HTMLElement
12
+ private params: ParticleParams
13
+ private material: PointsMaterial
14
+ private bloomPass: UnrealBloomPass
15
+ private callbacks: {
16
+ onParticleCountChange: (count: number) => void
17
+ onLifetimeChange: () => void
18
+ onTrailToggle: (enabled: boolean) => void
19
+ onTrailDecayChange: (value: number) => void
20
+ onClearFieldA: () => void
21
+ onClearFieldB: () => void
22
+ onColorChange: () => void
23
+ }
24
+
25
+ constructor(
26
+ container: HTMLElement,
27
+ params: ParticleParams,
28
+ material: PointsMaterial,
29
+ bloomPass: UnrealBloomPass,
30
+ callbacks: {
31
+ onParticleCountChange: (count: number) => void
32
+ onLifetimeChange: () => void
33
+ onTrailToggle: (enabled: boolean) => void
34
+ onTrailDecayChange: (value: number) => void
35
+ onClearFieldA: () => void
36
+ onClearFieldB: () => void
37
+ onColorChange: () => void
38
+ },
39
+ ) {
40
+ this.container = container
41
+ this.params = params
42
+ this.material = material
43
+ this.bloomPass = bloomPass
44
+ this.callbacks = callbacks
45
+ }
46
+
47
+ create() {
48
+ this.panel = document.createElement('div')
49
+ this.panel.className = 'controls'
50
+
51
+ const header = document.createElement('div')
52
+ header.className = 'controls__header'
53
+
54
+ const title = document.createElement('div')
55
+ title.className = 'controls__title'
56
+ title.textContent = 'Controls'
57
+
58
+ const toggleBtn = document.createElement('button')
59
+ toggleBtn.className = 'controls__toggle'
60
+ toggleBtn.textContent = '−'
61
+ toggleBtn.type = 'button'
62
+ toggleBtn.addEventListener('click', () => {
63
+ this.panel.classList.toggle('controls--collapsed')
64
+ toggleBtn.textContent = this.panel.classList.contains('controls--collapsed') ? '+' : '−'
65
+ // Trigger resize to recalculate field offset
66
+ window.dispatchEvent(new Event('resize'))
67
+ })
68
+
69
+ header.appendChild(title)
70
+ header.appendChild(toggleBtn)
71
+ this.panel.appendChild(header)
72
+
73
+ // Field colors
74
+ const colorControlA = this.addSelect('Field A color', COLOR_PRESETS, this.params.colorPresetA, (key) => {
75
+ this.params.colorPresetA = key
76
+ this.callbacks.onColorChange()
77
+ })
78
+ const colorControlB = this.addSelect('Field B color', COLOR_PRESETS, this.params.colorPresetB, (key) => {
79
+ this.params.colorPresetB = key
80
+ this.callbacks.onColorChange()
81
+ })
82
+ this.selectHandles.set('colorA', colorControlA)
83
+ this.selectHandles.set('colorB', colorControlB)
84
+
85
+ // Speed
86
+ const speedControl = this.addSlider(
87
+ this.panel,
88
+ 'Speed',
89
+ 0.1,
90
+ 8,
91
+ 0.1,
92
+ this.params.speed,
93
+ (value: number) => {
94
+ this.params.speed = value
95
+ },
96
+ )
97
+ this.controlHandles.set('speed', speedControl)
98
+
99
+ // Advanced section
100
+ const advancedToggle = document.createElement('button')
101
+ advancedToggle.type = 'button'
102
+ advancedToggle.className = 'controls__button'
103
+ advancedToggle.textContent = 'Show advanced'
104
+
105
+ const advancedSection = document.createElement('div')
106
+ advancedSection.className = 'controls__advanced'
107
+ advancedSection.style.display = 'none'
108
+
109
+ // Noise strength
110
+ const noiseControl = this.addSlider(advancedSection, 'Noise', 0, 1, 0.01, this.params.noiseStrength, (value) => {
111
+ this.params.noiseStrength = value
112
+ })
113
+ this.controlHandles.set('noiseStrength', noiseControl)
114
+
115
+ // Size
116
+ const sizeControl = this.addSlider(advancedSection, 'Size', 0.5, 4, 0.1, this.params.size, (value) => {
117
+ this.params.size = value
118
+ this.material.size = value
119
+ })
120
+ this.controlHandles.set('size', sizeControl)
121
+
122
+ // Particle count
123
+ const particleCountControl = this.addSlider(
124
+ advancedSection,
125
+ 'Particle count',
126
+ 100,
127
+ 8000,
128
+ 100,
129
+ this.params.particleCount,
130
+ (value) => {
131
+ this.params.particleCount = Math.round(value)
132
+ this.callbacks.onParticleCountChange(this.params.particleCount)
133
+ },
134
+ )
135
+ this.controlHandles.set('particleCount', particleCountControl)
136
+
137
+ // Bloom strength
138
+ const bloomStrengthControl = this.addSlider(
139
+ advancedSection,
140
+ 'Bloom strength',
141
+ 0.2,
142
+ 2.5,
143
+ 0.05,
144
+ this.params.bloomStrength,
145
+ (value) => {
146
+ this.params.bloomStrength = value
147
+ this.updateBloom()
148
+ },
149
+ )
150
+ this.controlHandles.set('bloomStrength', bloomStrengthControl)
151
+
152
+ // Bloom radius
153
+ const bloomRadiusControl = this.addSlider(
154
+ advancedSection,
155
+ 'Bloom radius',
156
+ 0.0,
157
+ 1.2,
158
+ 0.02,
159
+ this.params.bloomRadius,
160
+ (value) => {
161
+ this.params.bloomRadius = value
162
+ this.updateBloom()
163
+ },
164
+ )
165
+ this.controlHandles.set('bloomRadius', bloomRadiusControl)
166
+
167
+ // Life min
168
+ const lifeMinControl = this.addSlider(advancedSection, 'Life min (s)', 0.1, 2.0, 0.05, this.params.lifeMin, (value) => {
169
+ this.params.lifeMin = value
170
+ if (this.params.lifeMin > this.params.lifeMax) {
171
+ this.params.lifeMax = value
172
+ const lifeMaxHandle = this.controlHandles.get('lifeMax')
173
+ if (lifeMaxHandle) this.syncSlider(lifeMaxHandle, this.params.lifeMax)
174
+ }
175
+ this.callbacks.onLifetimeChange()
176
+ })
177
+ this.controlHandles.set('lifeMin', lifeMinControl)
178
+
179
+ // Life max
180
+ const lifeMaxControl = this.addSlider(advancedSection, 'Life max (s)', 0.2, 5.0, 0.05, this.params.lifeMax, (value) => {
181
+ this.params.lifeMax = value
182
+ if (this.params.lifeMax < this.params.lifeMin) {
183
+ this.params.lifeMin = value
184
+ const lifeMinHandle = this.controlHandles.get('lifeMin')
185
+ if (lifeMinHandle) this.syncSlider(lifeMinHandle, this.params.lifeMin)
186
+ }
187
+ this.callbacks.onLifetimeChange()
188
+ })
189
+ this.controlHandles.set('lifeMax', lifeMaxControl)
190
+
191
+ // Field border
192
+ const fieldDistControl = this.addSlider(
193
+ advancedSection,
194
+ 'Field border',
195
+ FIELD_BORDER_MIN,
196
+ FIELD_BORDER_MAX,
197
+ 0.01,
198
+ this.params.fieldValidDistance,
199
+ (value) => {
200
+ this.params.fieldValidDistance = value
201
+ },
202
+ )
203
+ this.controlHandles.set('fieldDist', fieldDistControl)
204
+
205
+ // Trails toggle
206
+ const trailsToggle = this.addToggle(advancedSection, 'Trails', this.params.trailsEnabled, (enabled) => {
207
+ this.params.trailsEnabled = enabled
208
+ this.callbacks.onTrailToggle(enabled)
209
+ })
210
+ this.trailToggle = trailsToggle
211
+
212
+ // Trail decay
213
+ const trailDecayControl = this.addSlider(
214
+ advancedSection,
215
+ 'Trail decay',
216
+ 0.7,
217
+ 0.99,
218
+ 0.005,
219
+ this.params.trailDecay,
220
+ (value) => {
221
+ this.params.trailDecay = value
222
+ this.callbacks.onTrailDecayChange(value)
223
+ },
224
+ )
225
+ this.controlHandles.set('trailDecay', trailDecayControl)
226
+
227
+ advancedToggle.addEventListener('click', () => {
228
+ const isHidden = advancedSection.style.display === 'none'
229
+ advancedSection.style.display = isHidden ? 'block' : 'none'
230
+ advancedToggle.textContent = isHidden ? 'Hide advanced' : 'Show advanced'
231
+ })
232
+
233
+ this.panel.appendChild(advancedToggle)
234
+ this.panel.appendChild(advancedSection)
235
+
236
+ // Reset button (right after advanced section)
237
+ const resetBtn = document.createElement('button')
238
+ resetBtn.type = 'button'
239
+ resetBtn.className = 'controls__button'
240
+ resetBtn.textContent = 'Reset to defaults'
241
+ resetBtn.addEventListener('click', () => this.reset())
242
+ this.panel.appendChild(resetBtn)
243
+
244
+ // Field management
245
+ const fieldSection = document.createElement('div')
246
+ fieldSection.className = 'controls__section'
247
+ const fieldSubtitle = document.createElement('div')
248
+ fieldSubtitle.className = 'controls__subtitle'
249
+ fieldSubtitle.textContent = 'Fields'
250
+ fieldSection.appendChild(fieldSubtitle)
251
+
252
+ const clearARow = document.createElement('div')
253
+ clearARow.className = 'controls__row'
254
+ const clearALabel = document.createElement('span')
255
+ clearALabel.textContent = 'Clear Field A'
256
+ const clearABtn = document.createElement('button')
257
+ clearABtn.type = 'button'
258
+ clearABtn.className = 'controls__button'
259
+ clearABtn.textContent = 'Clear'
260
+ clearABtn.addEventListener('click', () => this.callbacks.onClearFieldA())
261
+ clearARow.appendChild(clearALabel)
262
+ clearARow.appendChild(clearABtn)
263
+ fieldSection.appendChild(clearARow)
264
+
265
+ const clearBRow = document.createElement('div')
266
+ clearBRow.className = 'controls__row'
267
+ const clearBLabel = document.createElement('span')
268
+ clearBLabel.textContent = 'Clear Field B'
269
+ const clearBBtn = document.createElement('button')
270
+ clearBBtn.type = 'button'
271
+ clearBBtn.className = 'controls__button'
272
+ clearBBtn.textContent = 'Clear'
273
+ clearBBtn.addEventListener('click', () => this.callbacks.onClearFieldB())
274
+ clearBRow.appendChild(clearBLabel)
275
+ clearBRow.appendChild(clearBBtn)
276
+ fieldSection.appendChild(clearBRow)
277
+
278
+ this.panel.appendChild(fieldSection)
279
+
280
+ this.container.appendChild(this.panel)
281
+ }
282
+
283
+ syncFieldValidDistance(value: number) {
284
+ this.params.fieldValidDistance = value
285
+ const handle = this.controlHandles.get('fieldDist')
286
+ if (handle) {
287
+ this.syncSlider(handle, value)
288
+ }
289
+ }
290
+
291
+ private reset() {
292
+ Object.assign(this.params, defaultParams)
293
+ this.material.size = this.params.size
294
+ this.updateBloom()
295
+ this.callbacks.onLifetimeChange()
296
+ this.callbacks.onTrailToggle(this.params.trailsEnabled)
297
+ this.callbacks.onTrailDecayChange(this.params.trailDecay)
298
+
299
+ // Sync all controls
300
+ for (const [key, handle] of this.controlHandles.entries()) {
301
+ const paramKey = key as keyof ParticleParams
302
+ if (typeof this.params[paramKey] === 'number') {
303
+ this.syncSlider(handle, this.params[paramKey] as number)
304
+ }
305
+ }
306
+
307
+ for (const [key, select] of this.selectHandles.entries()) {
308
+ if (key === 'colorA') {
309
+ select.value = this.params.colorPresetA
310
+ }
311
+ if (key === 'colorB') {
312
+ select.value = this.params.colorPresetB
313
+ }
314
+ }
315
+
316
+ if (this.trailToggle) {
317
+ this.trailToggle.checked = this.params.trailsEnabled
318
+ }
319
+ }
320
+
321
+ private addToggle(parent: HTMLDivElement | HTMLElement, label: string, value: boolean, onChange: (value: boolean) => void) {
322
+ const row = document.createElement('label')
323
+ row.className = 'controls__row'
324
+
325
+ const text = document.createElement('span')
326
+ text.textContent = label
327
+
328
+ const input = document.createElement('input')
329
+ input.type = 'checkbox'
330
+ input.checked = value
331
+ input.addEventListener('change', (event) => {
332
+ const next = (event.target as HTMLInputElement).checked
333
+ onChange(next)
334
+ })
335
+
336
+ row.appendChild(text)
337
+ row.appendChild(input)
338
+ parent.appendChild(row)
339
+
340
+ return input
341
+ }
342
+
343
+ private addSlider(
344
+ parent: HTMLDivElement | HTMLElement,
345
+ label: string,
346
+ min: number,
347
+ max: number,
348
+ step: number,
349
+ value: number,
350
+ onChange: (value: number) => void,
351
+ ): SliderHandle {
352
+ const row = document.createElement('label')
353
+ row.className = 'controls__row'
354
+
355
+ const text = document.createElement('span')
356
+ text.textContent = label
357
+
358
+ const input = document.createElement('input')
359
+ input.type = 'range'
360
+ input.min = String(min)
361
+ input.max = String(max)
362
+ input.step = String(step)
363
+ input.value = String(value)
364
+
365
+ const valueTag = document.createElement('span')
366
+ valueTag.className = 'controls__value'
367
+ valueTag.textContent = this.formatValue(value, step)
368
+
369
+ input.addEventListener('input', (event) => {
370
+ const next = parseFloat((event.target as HTMLInputElement).value)
371
+ valueTag.textContent = this.formatValue(next, step)
372
+ onChange(next)
373
+ })
374
+
375
+ row.appendChild(text)
376
+ row.appendChild(input)
377
+ row.appendChild(valueTag)
378
+ parent.appendChild(row)
379
+
380
+ return { input, valueTag }
381
+ }
382
+
383
+ private addSelect(label: string, options: ColorPreset[], value: string, onChange: (key: string) => void): HTMLSelectElement {
384
+ const row = document.createElement('label')
385
+ row.className = 'controls__row'
386
+
387
+ const text = document.createElement('span')
388
+ text.textContent = label
389
+
390
+ const select = document.createElement('select')
391
+ select.className = 'controls__select'
392
+ for (const option of options) {
393
+ const optEl = document.createElement('option')
394
+ optEl.value = option.key
395
+ optEl.textContent = option.label
396
+ select.appendChild(optEl)
397
+ }
398
+ select.value = value
399
+ select.addEventListener('change', (event) => {
400
+ const next = (event.target as HTMLSelectElement).value
401
+ onChange(next)
402
+ })
403
+
404
+ row.appendChild(text)
405
+ row.appendChild(select)
406
+ this.panel.appendChild(row)
407
+
408
+ return select
409
+ }
410
+
411
+ private updateBloom() {
412
+ this.bloomPass.strength = this.params.bloomStrength
413
+ this.bloomPass.radius = this.params.bloomRadius
414
+ }
415
+
416
+ private formatValue(value: number, step: number) {
417
+ return step >= 1 ? value.toFixed(0) : value.toFixed(2)
418
+ }
419
+
420
+ private syncSlider(control: SliderHandle, value: number) {
421
+ control.input.value = String(value)
422
+ control.valueTag.textContent = this.formatValue(value, parseFloat(control.input.step))
423
+ }
424
+ }
@@ -0,0 +1,71 @@
1
+ import type { VectorDatum, FieldTransform } from '../lib/types'
2
+ import { WORLD_EXTENT } from '../lib/constants'
3
+
4
+ export class FieldLoader {
5
+ private fieldStatusEl: HTMLElement | null = null
6
+ private onFieldLoaded: (data: VectorDatum[], transform: FieldTransform) => void
7
+
8
+ constructor(
9
+ onFieldLoaded: (data: VectorDatum[], transform: FieldTransform) => void,
10
+ ) {
11
+ this.onFieldLoaded = onFieldLoaded
12
+ }
13
+
14
+ setStatusElement(el: HTMLElement | null) {
15
+ this.fieldStatusEl = el
16
+ }
17
+
18
+ async load() {
19
+ try {
20
+ const res = await fetch('/vector-field.json', { cache: 'no-store' })
21
+ if (!res.ok) {
22
+ this.updateStatus('default (built-in)')
23
+ return
24
+ }
25
+ const data = (await res.json()) as VectorDatum[]
26
+ if (Array.isArray(data) && data.length > 0) {
27
+ const { transform, bounds } = this.computeFieldTransform(data)
28
+ this.onFieldLoaded(data, transform)
29
+ this.updateStatus(`loaded ${data.length} vectors (${bounds.width.toFixed(1)}×${bounds.height.toFixed(1)})`)
30
+ console.log('Field bounds:', bounds, 'scale:', transform.scale)
31
+ } else {
32
+ this.updateStatus('default (empty file)')
33
+ }
34
+ } catch (error) {
35
+ console.error('Failed to load vector field', error)
36
+ this.updateStatus('default (load error)')
37
+ }
38
+ }
39
+
40
+ computeFieldTransform(data: VectorDatum[]) {
41
+ let minX = data[0].x
42
+ let maxX = data[0].x
43
+ let minY = data[0].y
44
+ let maxY = data[0].y
45
+
46
+ for (const d of data) {
47
+ if (d.x < minX) minX = d.x
48
+ if (d.x > maxX) maxX = d.x
49
+ if (d.y < minY) minY = d.y
50
+ if (d.y > maxY) maxY = d.y
51
+ }
52
+
53
+ const dataWidth = maxX - minX
54
+ const dataHeight = maxY - minY
55
+ const dataSize = Math.max(dataWidth, dataHeight)
56
+
57
+ const targetSize = WORLD_EXTENT * 1.8
58
+ const scale = dataSize > 0 ? targetSize / dataSize : 1
59
+ const offsetX = -(minX + maxX) * 0.5 * scale
60
+ const offsetY = -(minY + maxY) * 0.5 * scale
61
+
62
+ return {
63
+ transform: { scale, offsetX, offsetY },
64
+ bounds: { minX, maxX, minY, maxY, width: dataWidth, height: dataHeight },
65
+ }
66
+ }
67
+
68
+ updateStatus(label: string) {
69
+ if (this.fieldStatusEl) this.fieldStatusEl.textContent = label
70
+ }
71
+ }