@brandonlukas/luminar 0.2.0 → 0.2.2

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/src/main.ts CHANGED
@@ -1,70 +1,29 @@
1
1
  import './style.css'
2
- import {
3
- AdditiveBlending,
4
- BufferAttribute,
5
- BufferGeometry,
6
- Color,
7
- OrthographicCamera,
8
- Points,
9
- PointsMaterial,
10
- Scene,
11
- Vector2,
12
- WebGLRenderer,
13
- } from 'three'
2
+ import { AdditiveBlending, BufferGeometry, Color, OrthographicCamera, Points, PointsMaterial, Scene, Vector2, WebGLRenderer } from 'three'
14
3
  import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js'
15
4
  import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js'
16
5
  import { UnrealBloomPass } from 'three/examples/jsm/postprocessing/UnrealBloomPass.js'
17
-
18
- type VectorSample = { x: number; y: number }
19
- type VectorDatum = { x: number; y: number; dx: number; dy: number }
20
- type SliderHandle = { input: HTMLInputElement; valueTag: HTMLSpanElement }
21
- type ColorPreset = { key: string; label: string; rgb: [number, number, number] }
22
-
23
- const WORLD_EXTENT = 1.25
24
- const FLOW_SCALE = 0.85
25
- const SPEED_TO_GLOW = 2.6
26
- const JITTER = 0.015
27
- const FIELD_BORDER_MIN = 0.01
28
- const FIELD_BORDER_MAX = 0.1
29
- const COLOR_PRESETS: ColorPreset[] = [
30
- { key: 'luminous-violet', label: 'Luminous violet', rgb: [0.6, 0.25, 0.9] }, // current color
31
- { key: 'pure-white', label: 'Pure white', rgb: [1, 1, 1] },
32
- { key: 'neon-cyan', label: 'Neon cyan', rgb: [0.25, 0.95, 1] },
33
- { key: 'electric-lime', label: 'Electric lime', rgb: [0.75, 1, 0.25] },
34
- { key: 'solar-flare', label: 'Solar flare', rgb: [1, 0.55, 0.15] },
35
- { key: 'aurora-mint', label: 'Aurora mint', rgb: [0.4, 1, 0.85] },
36
- { key: 'magenta-nova', label: 'Magenta nova', rgb: [0.95, 0.2, 0.7] },
37
- { key: 'glacier-blue', label: 'Glacier blue', rgb: [0.35, 0.75, 1] },
38
- { key: 'sunrise-coral', label: 'Sunrise coral', rgb: [1, 0.6, 0.5] },
39
- { key: 'ember-gold', label: 'Ember gold', rgb: [1, 0.8, 0.2] },
40
- ]
41
- const DEFAULT_COLOR_PRESET = 'luminous-violet'
42
- const defaultParams = {
43
- size: 2,
44
- bloomStrength: 1.2,
45
- bloomRadius: 0.35,
46
- lifeMin: 0.5,
47
- lifeMax: 1.4,
48
- fieldValidDistance: 0.03,
49
- speed: 3.0,
50
- particleCount: 4200,
51
- colorPreset: DEFAULT_COLOR_PRESET,
52
- }
53
- const params = { ...defaultParams }
54
-
55
- let positions = new Float32Array(params.particleCount * 3)
56
- let colors = new Float32Array(params.particleCount * 3)
57
- let lifetimes = new Float32Array(params.particleCount)
6
+ import { AfterimagePass } from 'three/examples/jsm/postprocessing/AfterimagePass.js'
7
+ import { ParticleSystem } from './modules/particle-system'
8
+ import { FieldLoader } from './modules/field-loader'
9
+ import { ControlPanel } from './modules/controls'
10
+ import { RecordingManager } from './modules/recording'
11
+ import { defaultParams, COLOR_PRESETS } from './lib/constants'
12
+ import { parseCsv } from './lib/csv-parser'
13
+ import type { ParticleParams } from './lib/types'
58
14
 
59
15
  const container = document.querySelector<HTMLDivElement>('#app')
60
16
  if (!container) throw new Error('Missing #app container')
61
17
 
62
- const app = container
18
+ const params: ParticleParams = { ...defaultParams }
63
19
 
64
- app.innerHTML = ''
20
+ // Camera framing constant
21
+ const VIEW_SIZE = 1.8
22
+
23
+ // Scene setup
65
24
  const renderer = new WebGLRenderer({ antialias: false, alpha: true })
66
25
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
67
- app.appendChild(renderer.domElement)
26
+ container.appendChild(renderer.domElement)
68
27
 
69
28
  const scene = new Scene()
70
29
  scene.background = new Color(0x02040a)
@@ -75,14 +34,16 @@ camera.position.z = 2
75
34
  const composer = new EffectComposer(renderer)
76
35
  const renderPass = new RenderPass(scene, camera)
77
36
  const bloomPass = new UnrealBloomPass(new Vector2(1, 1), params.bloomStrength, 0.82, params.bloomRadius)
78
- updateBloom()
37
+ const afterimagePass = new AfterimagePass(params.trailDecay)
79
38
  composer.addPass(renderPass)
80
39
  composer.addPass(bloomPass)
40
+ composer.addPass(afterimagePass)
41
+ afterimagePass.enabled = params.trailsEnabled
42
+ afterimagePass.uniforms['damp'].value = params.trailDecay
81
43
 
82
- const geometry = new BufferGeometry()
83
- geometry.setAttribute('position', new BufferAttribute(positions, 3))
84
- geometry.setAttribute('color', new BufferAttribute(colors, 3))
85
-
44
+ // Particle geometry and material
45
+ const geometryA = new BufferGeometry()
46
+ const geometryB = new BufferGeometry()
86
47
  const material = new PointsMaterial({
87
48
  size: params.size,
88
49
  sizeAttenuation: true,
@@ -92,686 +53,275 @@ const material = new PointsMaterial({
92
53
  blending: AdditiveBlending,
93
54
  depthWrite: false,
94
55
  })
95
- material.onBeforeCompile = (shader) => {
96
- // Make points circular with a smooth falloff toward the edges
97
- shader.fragmentShader = shader.fragmentShader.replace(
98
- 'gl_FragColor = vec4( outgoingLight, diffuseColor.a );',
99
- `float d = length(gl_PointCoord - 0.5);
100
- if (d > 0.5) discard;
101
- float alpha = diffuseColor.a * smoothstep(0.5, 0.45, d);
102
- gl_FragColor = vec4(outgoingLight, alpha);`,
103
- )
104
- }
105
-
106
- const particles = new Points(geometry, material)
107
- scene.add(particles)
108
-
109
- let fieldData: VectorDatum[] | null = null
110
- let fieldStatusEl: HTMLElement | null = null
111
- let fieldTransform = { scale: 1, offsetX: 0, offsetY: 0 }
112
- let fieldDistControlHandle: SliderHandle | null = null
113
-
114
- let mediaRecorder: MediaRecorder | null = null
115
- let recordedChunks: Blob[] = []
116
- let isRecording = false
117
- let recordingStartTime = 0
118
- let recordingDuration = 5 // seconds
119
- let recordingResolution: 'current' | '1080p' | '1440p' | '4k' = 'current'
120
- let recordButton: HTMLButtonElement | null = null
121
- let recordStatus: HTMLDivElement | null = null
122
- let originalCanvasSize: { width: number; height: number } | null = null
123
-
124
- initParticles()
125
- resize()
126
- window.addEventListener('resize', resize)
127
- let lastTime = performance.now()
128
- animate(0)
129
- createOverlay()
130
- createControls()
131
- reseedLifetimes()
132
- loadVectorField()
133
-
134
- function initParticles() {
135
- for (let i = 0; i < params.particleCount; i += 1) {
136
- resetParticle(i)
137
- }
138
- geometry.attributes.position.needsUpdate = true
139
- geometry.attributes.color.needsUpdate = true
140
- }
141
-
142
- function resetParticle(i: number) {
143
- const i3 = i * 3
144
- const palette = getActiveColorPreset()
145
-
146
- // If field data exists, spawn particles near random data points (within valid region)
147
- if (fieldData && fieldData.length > 0) {
148
- const randomPoint = fieldData[Math.floor(Math.random() * fieldData.length)]
149
- const jitterRange = params.fieldValidDistance * 0.3
150
- const dataX = randomPoint.x + randomRange(-jitterRange, jitterRange) / fieldTransform.scale
151
- const dataY = randomPoint.y + randomRange(-jitterRange, jitterRange) / fieldTransform.scale
152
- positions[i3] = dataX * fieldTransform.scale + fieldTransform.offsetX
153
- positions[i3 + 1] = dataY * fieldTransform.scale + fieldTransform.offsetY
154
- } else {
155
- // Fallback: uniform spawning across entire world
156
- positions[i3] = randomRange(-WORLD_EXTENT, WORLD_EXTENT)
157
- positions[i3 + 1] = randomRange(-WORLD_EXTENT, WORLD_EXTENT)
158
- }
159
- positions[i3 + 2] = 0
160
-
161
- lifetimes[i] = randomRange(params.lifeMin, params.lifeMax)
162
-
163
- const glow = 0.4 + Math.random() * 0.2
164
- applyColor(i3, glow, palette, true)
165
- }
166
56
 
167
- function sampleField(x: number, y: number, _time: number): VectorSample | null {
168
- if (fieldData && fieldData.length > 0) {
169
- // Transform particle position to data coordinates
170
- const dataX = (x - fieldTransform.offsetX) / fieldTransform.scale
171
- const dataY = (y - fieldTransform.offsetY) / fieldTransform.scale
172
-
173
- let nearest = fieldData[0]
174
- let bestDist = Number.MAX_VALUE
175
- for (let i = 0; i < fieldData.length; i += 1) {
176
- const d = fieldData[i]
177
- const dx = d.x - dataX
178
- const dy = d.y - dataY
179
- const dist = dx * dx + dy * dy
180
- if (dist < bestDist) {
181
- bestDist = dist
182
- nearest = d
183
- }
184
- }
185
- const threshold = params.fieldValidDistance / fieldTransform.scale
186
- if (Math.sqrt(bestDist) > threshold) {
187
- return null
188
- }
189
- // Scale the vector components by the same factor
190
- return { x: nearest.dx * fieldTransform.scale, y: nearest.dy * fieldTransform.scale }
191
- }
192
-
193
- // Fallback: uniform rightward flow
194
- return { x: 1, y: 0 }
195
- }
57
+ const particlesA = new Points(geometryA, material)
58
+ const particlesB = new Points(geometryB, material)
59
+ scene.add(particlesA)
60
+ scene.add(particlesB)
196
61
 
197
- function animate(timestamp: number) {
198
- const now = timestamp || performance.now()
199
- const dt = Math.min(0.033, (now - lastTime) / 1000)
200
- lastTime = now
201
- const time = now * 0.001
202
- const palette = getActiveColorPreset()
203
-
204
- for (let i = 0; i < params.particleCount; i += 1) {
205
- const i3 = i * 3
206
- let x = positions[i3]
207
- let y = positions[i3 + 1]
208
-
209
- const v = sampleField(x, y, time)
210
- if (!v) {
211
- resetParticle(i)
212
- continue
213
- }
62
+ // Initialize modules
63
+ const particleSystemA = new ParticleSystem(geometryA, params)
64
+ const particleSystemB = new ParticleSystem(geometryB, params)
65
+ particleSystemA.init()
66
+ particleSystemB.init()
214
67
 
215
- x += (v.x * FLOW_SCALE * params.speed + randomRange(-JITTER, JITTER)) * dt
216
- y += (v.y * FLOW_SCALE * params.speed + randomRange(-JITTER, JITTER)) * dt
217
-
218
- const speed = Math.hypot(v.x, v.y)
219
- const glow = Math.min(1, speed * SPEED_TO_GLOW)
220
-
221
- applyColor(i3, glow, palette, false)
222
-
223
- lifetimes[i] -= dt
224
-
225
- if (lifetimes[i] <= 0 || Math.abs(x) > WORLD_EXTENT || Math.abs(y) > WORLD_EXTENT) {
226
- resetParticle(i)
227
- } else {
228
- positions[i3] = x
229
- positions[i3 + 1] = y
230
- }
231
- }
232
-
233
- geometry.attributes.position.needsUpdate = true
234
- geometry.attributes.color.needsUpdate = true
235
-
236
- composer.render()
68
+ let hasFieldA = false
69
+ let hasFieldB = false
237
70
 
238
- if (isRecording) {
239
- const elapsed = (performance.now() - recordingStartTime) / 1000
240
- updateRecordingStatus(elapsed)
241
- if (elapsed >= recordingDuration) {
242
- stopRecording()
243
- }
244
- }
245
-
246
- requestAnimationFrame(animate)
247
- }
71
+ const fieldLoaderA = new FieldLoader((data, transform) => {
72
+ particleSystemA.setFieldData(data, transform)
73
+ hasFieldA = particleSystemA.hasFieldData()
74
+ updateLayout()
75
+ })
248
76
 
249
- function resize() {
250
- const width = window.innerWidth
251
- const height = window.innerHeight
252
- const aspect = width / height
253
- const viewSize = 1.6
77
+ const fieldLoaderB = new FieldLoader((data, transform) => {
78
+ particleSystemB.setFieldData(data, transform)
79
+ hasFieldB = particleSystemB.hasFieldData()
80
+ updateLayout()
81
+ })
254
82
 
255
- camera.left = -viewSize * aspect
256
- camera.right = viewSize * aspect
257
- camera.top = viewSize
258
- camera.bottom = -viewSize
259
- camera.updateProjectionMatrix()
83
+ const controlPanel = new ControlPanel(container, params, material, bloomPass, {
84
+ onParticleCountChange: (count) => {
85
+ particleSystemA.resizeBuffers(count)
86
+ particleSystemB.resizeBuffers(count)
87
+ },
88
+ onLifetimeChange: () => {
89
+ particleSystemA.reseedLifetimes()
90
+ particleSystemB.reseedLifetimes()
91
+ },
92
+ onTrailToggle: (enabled) => updateTrails(enabled, params.trailDecay),
93
+ onTrailDecayChange: (value) => updateTrails(params.trailsEnabled, value),
94
+ onClearFieldA: () => clearField('left'),
95
+ onClearFieldB: () => clearField('right'),
96
+ onColorChange: () => updateColorPresetCache(),
97
+ })
260
98
 
261
- renderer.setSize(width, height, false)
262
- composer.setSize(width, height)
263
- bloomPass.setSize(width, height)
99
+ function updateColorPresetCache() {
100
+ cachedPresetA = COLOR_PRESETS.find((p) => p.key === params.colorPresetA) ?? COLOR_PRESETS[0]
101
+ cachedPresetB = COLOR_PRESETS.find((p) => p.key === params.colorPresetB) ?? COLOR_PRESETS[0]
264
102
  }
265
103
 
266
- function randomRange(min: number, max: number) {
267
- return min + Math.random() * (max - min)
268
- }
104
+ const recordingManager = new RecordingManager(renderer, composer, bloomPass, resize)
269
105
 
106
+ // HUD overlay
270
107
  function createOverlay() {
108
+ if (!container) return
271
109
  const hud = document.createElement('div')
272
110
  hud.className = 'hud'
273
- hud.innerHTML = `<div class="title">luminar</div><div class="subtitle">2D vector field bloom study</div><div class="status">Field: <span id="field-status">default (built-in)</span></div>`
274
- app.appendChild(hud)
275
- fieldStatusEl = document.getElementById('field-status')
111
+ hud.innerHTML = `<div class="title">luminar</div><div class="subtitle">2D vector field bloom study</div><div class="status">Field A: <span id="field-status-a">default (built-in)</span> · Field B: <span id="field-status-b">default (built-in)</span></div>`
112
+ container.appendChild(hud)
113
+ fieldLoaderA.setStatusElement(document.getElementById('field-status-a'))
114
+ fieldLoaderB.setStatusElement(document.getElementById('field-status-b'))
276
115
  }
277
116
 
278
- function createControls() {
279
- const panel = document.createElement('div')
280
- panel.className = 'controls'
281
-
282
- const header = document.createElement('div')
283
- header.className = 'controls__title'
284
- header.textContent = 'Controls'
285
- panel.appendChild(header)
286
-
287
- const colorControl = addSelect(panel, 'Color', COLOR_PRESETS, params.colorPreset, (key) => {
288
- params.colorPreset = key
289
- })
290
-
291
- const speedControl = addSlider(panel, 'Speed', 0.1, 6, 0.1, params.speed, (value) => {
292
- params.speed = value
293
- })
294
-
295
- const advancedToggle = document.createElement('button')
296
- advancedToggle.type = 'button'
297
- advancedToggle.className = 'controls__button'
298
- advancedToggle.textContent = 'Show advanced'
299
-
300
- const advancedSection = document.createElement('div')
301
- advancedSection.className = 'controls__advanced'
302
- advancedSection.style.display = 'none'
303
-
304
- const sizeControl = addSlider(advancedSection, 'Size', 0.5, 4, 0.1, params.size, (value) => {
305
- params.size = value
306
- material.size = value
307
- })
308
-
309
- const particleCountControl = addSlider(advancedSection, 'Particle count', 100, 8000, 100, params.particleCount, (value) => {
310
- params.particleCount = Math.round(value)
311
- resizeParticleBuffers()
312
- })
313
-
314
- const bloomStrengthControl = addSlider(advancedSection, 'Bloom strength', 0.2, 2.5, 0.05, params.bloomStrength, (value) => {
315
- params.bloomStrength = value
316
- updateBloom()
317
- })
318
-
319
- const bloomRadiusControl = addSlider(advancedSection, 'Bloom radius', 0.0, 1.2, 0.02, params.bloomRadius, (value) => {
320
- params.bloomRadius = value
321
- updateBloom()
322
- })
323
-
324
- let lifeMinControl: SliderHandle
325
- let lifeMaxControl: SliderHandle
326
-
327
- lifeMinControl = addSlider(advancedSection, 'Life min (s)', 0.1, 2.0, 0.05, params.lifeMin, (value) => {
328
- params.lifeMin = value
329
- if (params.lifeMin > params.lifeMax) {
330
- params.lifeMax = value
331
- syncSlider(lifeMaxControl, params.lifeMax)
117
+ function setupDragAndDrop() {
118
+ if (!container) return
119
+
120
+ // Create side-specific overlays
121
+ const dropOverlayLeft = document.createElement('div')
122
+ dropOverlayLeft.className = 'drop-overlay drop-overlay--left'
123
+ dropOverlayLeft.textContent = 'Drop to load Field A (left)'
124
+ dropOverlayLeft.style.display = 'none'
125
+ container.appendChild(dropOverlayLeft)
126
+
127
+ const dropOverlayRight = document.createElement('div')
128
+ dropOverlayRight.className = 'drop-overlay drop-overlay--right'
129
+ dropOverlayRight.textContent = 'Drop to load Field B (right)'
130
+ dropOverlayRight.style.display = 'none'
131
+ container.appendChild(dropOverlayRight)
132
+
133
+ const showOverlay = (side: 'left' | 'right') => {
134
+ if (side === 'left') {
135
+ dropOverlayLeft.style.display = 'flex'
136
+ dropOverlayRight.style.display = 'none'
137
+ } else {
138
+ dropOverlayLeft.style.display = 'none'
139
+ dropOverlayRight.style.display = 'flex'
332
140
  }
333
- reseedLifetimes()
334
- })
141
+ }
142
+ const hideOverlay = () => {
143
+ dropOverlayLeft.style.display = 'none'
144
+ dropOverlayRight.style.display = 'none'
145
+ }
335
146
 
336
- lifeMaxControl = addSlider(advancedSection, 'Life max (s)', 0.2, 5.0, 0.05, params.lifeMax, (value) => {
337
- params.lifeMax = value
338
- if (params.lifeMax < params.lifeMin) {
339
- params.lifeMin = value
340
- syncSlider(lifeMinControl, params.lifeMin)
147
+ const handleFiles = async (file: File, target: 'left' | 'right') => {
148
+ try {
149
+ const text = await file.text()
150
+ const rows = parseCsv(text)
151
+ if (!rows.length) {
152
+ if (target === 'left') {
153
+ fieldLoaderA.updateStatus('CSV empty or invalid')
154
+ } else {
155
+ fieldLoaderB.updateStatus('CSV empty or invalid')
156
+ }
157
+ hideOverlay()
158
+ return
159
+ }
160
+ const loader = target === 'left' ? fieldLoaderA : fieldLoaderB
161
+ const { transform, bounds } = loader.computeFieldTransform(rows)
162
+ if (target === 'left') {
163
+ particleSystemA.setFieldData(rows, transform)
164
+ hasFieldA = particleSystemA.hasFieldData()
165
+ } else {
166
+ particleSystemB.setFieldData(rows, transform)
167
+ hasFieldB = particleSystemB.hasFieldData()
168
+ }
169
+ loader.updateStatus(`loaded ${rows.length} vectors (${bounds.width.toFixed(1)}×${bounds.height.toFixed(1)})`)
170
+ updateLayout()
171
+ } catch (error) {
172
+ console.error('Failed to load dropped CSV', error)
173
+ if (target === 'left') {
174
+ fieldLoaderA.updateStatus('CSV load error')
175
+ } else {
176
+ fieldLoaderB.updateStatus('CSV load error')
177
+ }
178
+ } finally {
179
+ hideOverlay()
341
180
  }
342
- reseedLifetimes()
343
- })
181
+ }
344
182
 
345
- const fieldDistControl = addSlider(advancedSection, 'Field border', FIELD_BORDER_MIN, FIELD_BORDER_MAX, 0.01, params.fieldValidDistance, (value) => {
346
- params.fieldValidDistance = value
183
+ window.addEventListener('dragover', (e) => {
184
+ e.preventDefault()
185
+ const target = e.clientX < window.innerWidth * 0.5 ? 'left' : 'right'
186
+ showOverlay(target)
347
187
  })
348
- fieldDistControlHandle = fieldDistControl
349
188
 
350
- advancedToggle.addEventListener('click', () => {
351
- const isHidden = advancedSection.style.display === 'none'
352
- advancedSection.style.display = isHidden ? 'block' : 'none'
353
- advancedToggle.textContent = isHidden ? 'Hide advanced' : 'Show advanced'
189
+ window.addEventListener('dragleave', (e) => {
190
+ e.preventDefault()
191
+ hideOverlay()
354
192
  })
355
193
 
356
- panel.appendChild(advancedToggle)
357
- panel.appendChild(advancedSection)
358
-
359
- // Recording controls
360
- const recordingSection = document.createElement('div')
361
- recordingSection.className = 'controls__section'
362
- recordingSection.innerHTML = '<div class="controls__subtitle">Export (WebM)</div>'
363
-
364
- const resolutionRow = document.createElement('label')
365
- resolutionRow.className = 'controls__row'
366
- const resolutionLabel = document.createElement('span')
367
- resolutionLabel.textContent = 'Resolution'
368
- const resolutionSelect = document.createElement('select')
369
- resolutionSelect.className = 'controls__select'
370
- resolutionSelect.innerHTML = '<option value="current">Current window</option><option value="1080p">1080p (Full HD)</option><option value="1440p">1440p (2K)</option><option value="4k">4K (Ultra HD)</option>'
371
- resolutionSelect.value = recordingResolution
372
- resolutionSelect.addEventListener('change', (e) => {
373
- recordingResolution = (e.target as HTMLSelectElement).value as typeof recordingResolution
374
- })
375
- resolutionRow.appendChild(resolutionLabel)
376
- resolutionRow.appendChild(resolutionSelect)
377
- recordingSection.appendChild(resolutionRow)
378
-
379
- const durationRow = document.createElement('label')
380
- durationRow.className = 'controls__row'
381
- const durationLabel = document.createElement('span')
382
- durationLabel.textContent = 'Duration'
383
- const durationSelect = document.createElement('select')
384
- durationSelect.className = 'controls__select'
385
- durationSelect.innerHTML = '<option value="3">3 seconds</option><option value="5">5 seconds</option><option value="10">10 seconds</option><option value="15">15 seconds</option>'
386
- durationSelect.value = String(recordingDuration)
387
- durationSelect.addEventListener('change', (e) => {
388
- recordingDuration = parseInt((e.target as HTMLSelectElement).value)
389
- })
390
- durationRow.appendChild(durationLabel)
391
- durationRow.appendChild(durationSelect)
392
- recordingSection.appendChild(durationRow)
393
-
394
- recordButton = document.createElement('button')
395
- recordButton.type = 'button'
396
- recordButton.className = 'controls__button controls__button--record'
397
- recordButton.textContent = '⏺ Start recording'
398
- recordButton.addEventListener('click', () => {
399
- if (isRecording) {
400
- stopRecording()
401
- } else {
402
- startRecording()
194
+ window.addEventListener('drop', (e) => {
195
+ e.preventDefault()
196
+ const dt = e.dataTransfer
197
+ if (dt && dt.files && dt.files[0]) {
198
+ const target = e.clientX < window.innerWidth * 0.5 ? 'left' : 'right'
199
+ handleFiles(dt.files[0], target)
403
200
  }
404
201
  })
405
- recordingSection.appendChild(recordButton)
406
-
407
- recordStatus = document.createElement('div')
408
- recordStatus.className = 'controls__status'
409
- recordStatus.style.display = 'none'
410
- recordingSection.appendChild(recordStatus)
411
-
412
- panel.appendChild(recordingSection)
413
-
414
- const resetBtn = document.createElement('button')
415
- resetBtn.type = 'button'
416
- resetBtn.className = 'controls__button'
417
- resetBtn.textContent = 'Reset to defaults'
418
- resetBtn.addEventListener('click', () => {
419
- Object.assign(params, defaultParams)
420
- material.size = params.size
421
- updateBloom()
422
- reseedLifetimes()
423
- syncSlider(sizeControl, params.size)
424
- syncSlider(speedControl, params.speed)
425
- syncSlider(particleCountControl, params.particleCount)
426
- syncSlider(bloomStrengthControl, params.bloomStrength)
427
- syncSlider(bloomRadiusControl, params.bloomRadius)
428
- syncSlider(lifeMinControl, params.lifeMin)
429
- syncSlider(lifeMaxControl, params.lifeMax)
430
- syncSlider(fieldDistControl, params.fieldValidDistance)
431
- syncSelect(colorControl, params.colorPreset)
432
- })
433
- panel.appendChild(resetBtn)
434
-
435
- app.appendChild(panel)
436
202
  }
437
203
 
438
- function addSlider(
439
- panel: HTMLDivElement,
440
- label: string,
441
- min: number,
442
- max: number,
443
- step: number,
444
- value: number,
445
- onChange: (value: number) => void,
446
- ): SliderHandle {
447
- const row = document.createElement('label')
448
- row.className = 'controls__row'
449
-
450
- const text = document.createElement('span')
451
- text.textContent = label
452
-
453
- const input = document.createElement('input')
454
- input.type = 'range'
455
- input.min = String(min)
456
- input.max = String(max)
457
- input.step = String(step)
458
- input.value = String(value)
459
-
460
- const valueTag = document.createElement('span')
461
- valueTag.className = 'controls__value'
462
- valueTag.textContent = formatValue(value, step)
463
-
464
- input.addEventListener('input', (event) => {
465
- const next = parseFloat((event.target as HTMLInputElement).value)
466
- valueTag.textContent = formatValue(next, step)
467
- onChange(next)
468
- })
204
+ function computeViewOffset(): number {
205
+ const aspect = window.innerWidth / window.innerHeight
206
+ const cameraWidth = VIEW_SIZE * aspect * 2
469
207
 
470
- row.appendChild(text)
471
- row.appendChild(input)
472
- row.appendChild(valueTag)
473
- panel.appendChild(row)
208
+ // Visual sweet spot: fields shouldn't spread beyond this on wide windows
209
+ const maxVisualOffset = 1.4
474
210
 
475
- return { input, valueTag }
476
- }
211
+ // Minimum separation
212
+ const minOffset = 1.0
477
213
 
478
- function addSelect(
479
- panel: HTMLDivElement,
480
- label: string,
481
- options: ColorPreset[],
482
- value: string,
483
- onChange: (key: string) => void,
484
- ): HTMLSelectElement {
485
- const row = document.createElement('label')
486
- row.className = 'controls__row'
487
-
488
- const text = document.createElement('span')
489
- text.textContent = label
490
-
491
- const select = document.createElement('select')
492
- select.className = 'controls__select'
493
- for (const option of options) {
494
- const optEl = document.createElement('option')
495
- optEl.value = option.key
496
- optEl.textContent = option.label
497
- select.appendChild(optEl)
498
- }
499
- select.value = value
500
- select.addEventListener('change', (event) => {
501
- const next = (event.target as HTMLSelectElement).value
502
- onChange(next)
503
- })
214
+ // Calculate desired offset based on window width
215
+ // Each field should take ~45% of camera width, leaving ~10% gap
216
+ const desiredOffset = (cameraWidth * 0.45) / 2
504
217
 
505
- row.appendChild(text)
506
- row.appendChild(select)
507
- panel.appendChild(row)
218
+ // Account for controls panel (240px + 18px margin = 258px on right side)
219
+ // Only apply panel constraint if window is narrow
220
+ const panelPixels = 258
221
+ const panelCameraUnits = (panelPixels / window.innerWidth) * cameraWidth
222
+ const availableWidth = cameraWidth - panelCameraUnits - 0.5
223
+ const offsetWithPanelConstraint = availableWidth / 2
508
224
 
509
- return select
510
- }
225
+ // Final offset: respect visual maximum, but reduce if panel constrains space
226
+ const maxOffset = Math.min(maxVisualOffset, offsetWithPanelConstraint)
511
227
 
512
- function updateBloom() {
513
- bloomPass.strength = params.bloomStrength
514
- bloomPass.radius = params.bloomRadius
228
+ return Math.max(minOffset, Math.min(desiredOffset, maxOffset))
515
229
  }
516
230
 
517
- function formatValue(value: number, step: number) {
518
- return step >= 1 ? value.toFixed(0) : value.toFixed(2)
231
+ function clearField(target: 'left' | 'right') {
232
+ const transform = { scale: 1, offsetX: 0, offsetY: 0 }
233
+ if (target === 'left') {
234
+ particleSystemA.setFieldData(null, transform)
235
+ particleSystemA.reseedLifetimes()
236
+ fieldLoaderA.updateStatus('default (cleared)')
237
+ hasFieldA = false
238
+ } else {
239
+ particleSystemB.setFieldData(null, transform)
240
+ particleSystemB.reseedLifetimes()
241
+ fieldLoaderB.updateStatus('default (cleared)')
242
+ hasFieldB = false
243
+ }
244
+ updateLayout()
519
245
  }
520
246
 
521
- function syncSlider(control: { input: HTMLInputElement; valueTag: HTMLSpanElement }, value: number) {
522
- control.input.value = String(value)
523
- control.valueTag.textContent = formatValue(value, parseFloat(control.input.step))
524
- }
247
+ // Resize handler
248
+ function resize() {
249
+ const width = window.innerWidth
250
+ const height = window.innerHeight
251
+ const aspect = width / height
525
252
 
526
- function syncSelect(select: HTMLSelectElement, value: string) {
527
- select.value = value
528
- }
253
+ camera.left = -VIEW_SIZE * aspect
254
+ camera.right = VIEW_SIZE * aspect
255
+ camera.top = VIEW_SIZE
256
+ camera.bottom = -VIEW_SIZE
257
+ camera.updateProjectionMatrix()
529
258
 
530
- function getActiveColorPreset(): ColorPreset {
531
- return COLOR_PRESETS.find((preset) => preset.key === params.colorPreset) ?? COLOR_PRESETS[0]
259
+ renderer.setSize(width, height, false)
260
+ composer.setSize(width, height)
261
+ bloomPass.setSize(width, height)
262
+ updateLayout()
532
263
  }
533
264
 
534
- function applyColor(i3: number, glow: number, palette: ColorPreset, isSpawn: boolean) {
535
- const clampedGlow = Math.min(1, Math.max(0, glow))
536
-
537
- // Preserve legacy look for the default palette
538
- if (palette.key === DEFAULT_COLOR_PRESET) {
539
- if (isSpawn) {
540
- colors[i3] = 0.6 * clampedGlow
541
- colors[i3 + 1] = 0.25 * clampedGlow
542
- colors[i3 + 2] = 0.9 * clampedGlow
265
+ function updateLayout() {
266
+ const count = (hasFieldA ? 1 : 0) + (hasFieldB ? 1 : 0)
267
+ const offset = computeViewOffset()
268
+
269
+ if (count === 2) {
270
+ particlesA.visible = true
271
+ particlesB.visible = true
272
+ particleSystemA.setViewOffset(-offset)
273
+ particleSystemB.setViewOffset(offset)
274
+ } else if (count === 1) {
275
+ if (hasFieldA) {
276
+ particlesA.visible = true
277
+ particlesB.visible = false
278
+ particleSystemA.setViewOffset(0)
543
279
  } else {
544
- colors[i3] = 0.35 + clampedGlow * 0.9
545
- colors[i3 + 1] = 0.18 + clampedGlow * 0.45
546
- colors[i3 + 2] = 0.6 + clampedGlow * 0.35
280
+ particlesA.visible = false
281
+ particlesB.visible = true
282
+ particleSystemB.setViewOffset(0)
547
283
  }
548
- return
549
- }
550
-
551
- // For other palettes, scale their hue by brightness without white mixing
552
- const brightness = isSpawn ? clampedGlow : 0.35 + clampedGlow * 0.65
553
- const [r, g, b] = palette.rgb
554
- colors[i3] = r * brightness
555
- colors[i3 + 1] = g * brightness
556
- colors[i3 + 2] = b * brightness
557
- }
558
-
559
- function reseedLifetimes() {
560
- for (let i = 0; i < params.particleCount; i += 1) {
561
- lifetimes[i] = randomRange(params.lifeMin, params.lifeMax)
284
+ } else {
285
+ particlesA.visible = true
286
+ particlesB.visible = false
287
+ particleSystemA.setViewOffset(0)
288
+ particleSystemB.setViewOffset(0)
562
289
  }
563
290
  }
564
291
 
565
- function resizeParticleBuffers() {
566
- positions = new Float32Array(params.particleCount * 3)
567
- colors = new Float32Array(params.particleCount * 3)
568
- lifetimes = new Float32Array(params.particleCount)
569
- geometry.setAttribute('position', new BufferAttribute(positions, 3))
570
- geometry.setAttribute('color', new BufferAttribute(colors, 3))
571
- initParticles()
292
+ function updateTrails(enabled: boolean, decay: number) {
293
+ params.trailsEnabled = enabled
294
+ params.trailDecay = decay
295
+ afterimagePass.enabled = enabled
296
+ afterimagePass.uniforms['damp'].value = decay
572
297
  }
573
298
 
574
- function startRecording() {
575
- if (isRecording) return
576
-
577
- try {
578
- const canvas = renderer.domElement
579
-
580
- // Determine recording resolution
581
- let recordWidth: number, recordHeight: number
582
- const currentAspect = canvas.width / canvas.height
583
-
584
- if (recordingResolution === 'current') {
585
- recordWidth = canvas.width
586
- recordHeight = canvas.height
587
- } else {
588
- // Save original size and resize for recording
589
- originalCanvasSize = { width: canvas.width, height: canvas.height }
590
-
591
- switch (recordingResolution) {
592
- case '1080p':
593
- recordHeight = 1080
594
- recordWidth = Math.round(recordHeight * currentAspect)
595
- break
596
- case '1440p':
597
- recordHeight = 1440
598
- recordWidth = Math.round(recordHeight * currentAspect)
599
- break
600
- case '4k':
601
- recordHeight = 2160
602
- recordWidth = Math.round(recordHeight * currentAspect)
603
- break
604
- }
605
-
606
- // Resize canvas for recording
607
- renderer.setSize(recordWidth, recordHeight, false)
608
- composer.setSize(recordWidth, recordHeight)
609
- bloomPass.setSize(recordWidth, recordHeight)
610
- }
611
-
612
- const stream = canvas.captureStream(60) // 60 fps
613
-
614
- // Calculate bitrate based on resolution (higher res = higher bitrate)
615
- const pixelCount = recordWidth * recordHeight
616
- const bitrate = Math.min(25000000, Math.max(8000000, pixelCount * 4)) // 4 bits per pixel, capped at 25 Mbps
617
-
618
- recordedChunks = []
619
- mediaRecorder = new MediaRecorder(stream, {
620
- mimeType: 'video/webm;codecs=vp9',
621
- videoBitsPerSecond: bitrate,
622
- })
623
-
624
- mediaRecorder.ondataavailable = (event) => {
625
- if (event.data.size > 0) {
626
- recordedChunks.push(event.data)
627
- }
628
- }
629
-
630
- mediaRecorder.onstop = () => {
631
- // Restore original canvas size if we resized
632
- if (originalCanvasSize) {
633
- renderer.setSize(originalCanvasSize.width, originalCanvasSize.height, false)
634
- composer.setSize(originalCanvasSize.width, originalCanvasSize.height)
635
- bloomPass.setSize(originalCanvasSize.width, originalCanvasSize.height)
636
- originalCanvasSize = null
637
- resize() // Trigger full resize to ensure proper state
638
- }
639
-
640
- const blob = new Blob(recordedChunks, { type: 'video/webm' })
641
- const url = URL.createObjectURL(blob)
642
- const a = document.createElement('a')
643
- a.href = url
644
- a.download = `luminar-${recordWidth}x${recordHeight}-${Date.now()}.webm`
645
- a.click()
646
- URL.revokeObjectURL(url)
647
-
648
- if (recordStatus) {
649
- recordStatus.textContent = 'Recording complete! Download started.'
650
- setTimeout(() => {
651
- if (recordStatus) recordStatus.style.display = 'none'
652
- }, 3000)
653
- }
654
- }
655
-
656
- mediaRecorder.start()
657
- isRecording = true
658
- recordingStartTime = performance.now()
659
-
660
- if (recordButton) {
661
- recordButton.textContent = '⏹ Stop recording'
662
- recordButton.style.opacity = '1'
663
- }
664
- if (recordStatus) {
665
- recordStatus.style.display = 'block'
666
- recordStatus.textContent = `Recording at ${recordWidth}x${recordHeight} (${(bitrate / 1000000).toFixed(0)} Mbps): 0.0s / ${recordingDuration}s`
667
- }
668
- } catch (error) {
669
- console.error('Failed to start recording:', error)
670
- // Restore size if recording failed
671
- if (originalCanvasSize) {
672
- renderer.setSize(originalCanvasSize.width, originalCanvasSize.height, false)
673
- composer.setSize(originalCanvasSize.width, originalCanvasSize.height)
674
- bloomPass.setSize(originalCanvasSize.width, originalCanvasSize.height)
675
- originalCanvasSize = null
676
- resize()
677
- }
678
- if (recordStatus) {
679
- recordStatus.style.display = 'block'
680
- recordStatus.textContent = 'Recording not supported in this browser.'
681
- }
682
- }
683
- }
684
-
685
- function stopRecording() {
686
- if (!isRecording || !mediaRecorder) return
687
-
688
- isRecording = false
689
- mediaRecorder.stop()
690
- mediaRecorder = null
299
+ // Animation loop
300
+ let lastTime = performance.now()
301
+ let cachedPresetA = COLOR_PRESETS.find((p) => p.key === params.colorPresetA) ?? COLOR_PRESETS[0]
302
+ let cachedPresetB = COLOR_PRESETS.find((p) => p.key === params.colorPresetB) ?? COLOR_PRESETS[0]
691
303
 
692
- if (recordButton) {
693
- recordButton.textContent = '▶ Start recording'
694
- recordButton.style.opacity = '1'
695
- }
696
- }
304
+ function animate(timestamp: number) {
305
+ const now = timestamp || performance.now()
306
+ const dt = Math.min(0.033, (now - lastTime) / 1000)
307
+ lastTime = now
697
308
 
698
- function updateRecordingStatus(elapsed: number) {
699
- if (recordStatus) {
700
- const current = elapsed.toFixed(1)
701
- const canvas = renderer.domElement
702
- const bitrate = Math.min(25000000, Math.max(8000000, canvas.width * canvas.height * 4))
703
- recordStatus.textContent = `Recording at ${canvas.width}x${canvas.height} (${(bitrate / 1000000).toFixed(0)} Mbps): ${current}s / ${recordingDuration}s`
704
- }
705
- }
309
+ particleSystemA.update(dt, cachedPresetA)
310
+ particleSystemB.update(dt, cachedPresetB)
706
311
 
707
- async function loadVectorField() {
708
- try {
709
- const res = await fetch('/vector-field.json', { cache: 'no-store' })
710
- if (!res.ok) {
711
- updateFieldStatus('default (built-in)')
712
- return
713
- }
714
- const data = (await res.json()) as VectorDatum[]
715
- if (Array.isArray(data) && data.length > 0) {
716
- // Compute bounding box
717
- let minX = data[0].x
718
- let maxX = data[0].x
719
- let minY = data[0].y
720
- let maxY = data[0].y
721
- for (const d of data) {
722
- if (d.x < minX) minX = d.x
723
- if (d.x > maxX) maxX = d.x
724
- if (d.y < minY) minY = d.y
725
- if (d.y > maxY) maxY = d.y
726
- }
727
- const dataWidth = maxX - minX
728
- const dataHeight = maxY - minY
729
- const dataSize = Math.max(dataWidth, dataHeight)
730
-
731
- // Normalize to fit within [-WORLD_EXTENT, WORLD_EXTENT]
732
- const targetSize = WORLD_EXTENT * 1.8 // leave some margin
733
- fieldTransform.scale = dataSize > 0 ? targetSize / dataSize : 1
734
- fieldTransform.offsetX = -(minX + maxX) * 0.5 * fieldTransform.scale
735
- fieldTransform.offsetY = -(minY + maxY) * 0.5 * fieldTransform.scale
736
-
737
- // Auto-compute field valid distance from average nearest-neighbor spacing
738
- let sumDist = 0
739
- const sampleSize = Math.min(data.length, 200) // sample subset for efficiency
740
- for (let i = 0; i < sampleSize; i += 1) {
741
- const pt = data[i]
742
- let minDist = Number.MAX_VALUE
743
- for (let j = 0; j < data.length; j += 1) {
744
- if (i === j) continue
745
- const dx = data[j].x - pt.x
746
- const dy = data[j].y - pt.y
747
- const dist = Math.sqrt(dx * dx + dy * dy)
748
- if (dist < minDist) minDist = dist
749
- }
750
- sumDist += minDist
751
- }
752
- const avgSpacing = sumDist / sampleSize
753
- const autoThreshold = Math.min(
754
- FIELD_BORDER_MAX,
755
- Math.max(FIELD_BORDER_MIN, avgSpacing * fieldTransform.scale * 0.7),
756
- ) // 70% of avg spacing, clamped to UI range
757
- params.fieldValidDistance = autoThreshold
758
- if (fieldDistControlHandle) {
759
- syncSlider(fieldDistControlHandle, params.fieldValidDistance)
760
- }
761
- console.log('Auto field border:', autoThreshold.toFixed(4), 'avg spacing:', avgSpacing.toFixed(4))
312
+ composer.render()
313
+ recordingManager.update()
762
314
 
763
- fieldData = data
764
- updateFieldStatus(`loaded ${data.length} vectors (${dataWidth.toFixed(1)}×${dataHeight.toFixed(1)})`)
765
- console.log('Field bounds:', { minX, maxX, minY, maxY, scale: fieldTransform.scale })
766
- } else {
767
- updateFieldStatus('default (empty file)')
768
- }
769
- } catch (error) {
770
- console.error('Failed to load vector field', error)
771
- updateFieldStatus('default (load error)')
772
- }
315
+ requestAnimationFrame(animate)
773
316
  }
774
317
 
775
- function updateFieldStatus(label: string) {
776
- if (fieldStatusEl) fieldStatusEl.textContent = label
777
- }
318
+ // Initialize
319
+ createOverlay()
320
+ controlPanel.create()
321
+ recordingManager.createControls(container.querySelector('.controls')!)
322
+ resize()
323
+ window.addEventListener('resize', resize)
324
+ fieldLoaderA.load()
325
+ fieldLoaderB.load()
326
+ setupDragAndDrop()
327
+ animate(0)