@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.
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,479 +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
56
 
106
- const particles = new Points(geometry, material)
107
- scene.add(particles)
57
+ const particlesA = new Points(geometryA, material)
58
+ const particlesB = new Points(geometryB, material)
59
+ scene.add(particlesA)
60
+ scene.add(particlesB)
108
61
 
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
62
+ // Initialize modules
63
+ const particleSystemA = new ParticleSystem(geometryA, params)
64
+ const particleSystemB = new ParticleSystem(geometryB, params)
65
+ particleSystemA.init()
66
+ particleSystemB.init()
113
67
 
114
- initParticles()
115
- resize()
116
- window.addEventListener('resize', resize)
117
- let lastTime = performance.now()
118
- animate(0)
119
- createOverlay()
120
- createControls()
121
- reseedLifetimes()
122
- loadVectorField()
123
-
124
- function initParticles() {
125
- for (let i = 0; i < params.particleCount; i += 1) {
126
- resetParticle(i)
127
- }
128
- geometry.attributes.position.needsUpdate = true
129
- geometry.attributes.color.needsUpdate = true
130
- }
131
-
132
- function resetParticle(i: number) {
133
- const i3 = i * 3
134
- const palette = getActiveColorPreset()
135
-
136
- // If field data exists, spawn particles near random data points (within valid region)
137
- if (fieldData && fieldData.length > 0) {
138
- const randomPoint = fieldData[Math.floor(Math.random() * fieldData.length)]
139
- const jitterRange = params.fieldValidDistance * 0.3
140
- const dataX = randomPoint.x + randomRange(-jitterRange, jitterRange) / fieldTransform.scale
141
- const dataY = randomPoint.y + randomRange(-jitterRange, jitterRange) / fieldTransform.scale
142
- positions[i3] = dataX * fieldTransform.scale + fieldTransform.offsetX
143
- positions[i3 + 1] = dataY * fieldTransform.scale + fieldTransform.offsetY
144
- } else {
145
- // Fallback: uniform spawning across entire world
146
- positions[i3] = randomRange(-WORLD_EXTENT, WORLD_EXTENT)
147
- positions[i3 + 1] = randomRange(-WORLD_EXTENT, WORLD_EXTENT)
148
- }
149
- positions[i3 + 2] = 0
150
-
151
- lifetimes[i] = randomRange(params.lifeMin, params.lifeMax)
152
-
153
- const glow = 0.4 + Math.random() * 0.2
154
- applyColor(i3, glow, palette, true)
155
- }
156
-
157
- function sampleField(x: number, y: number, _time: number): VectorSample | null {
158
- if (fieldData && fieldData.length > 0) {
159
- // Transform particle position to data coordinates
160
- const dataX = (x - fieldTransform.offsetX) / fieldTransform.scale
161
- const dataY = (y - fieldTransform.offsetY) / fieldTransform.scale
162
-
163
- let nearest = fieldData[0]
164
- let bestDist = Number.MAX_VALUE
165
- for (let i = 0; i < fieldData.length; i += 1) {
166
- const d = fieldData[i]
167
- const dx = d.x - dataX
168
- const dy = d.y - dataY
169
- const dist = dx * dx + dy * dy
170
- if (dist < bestDist) {
171
- bestDist = dist
172
- nearest = d
173
- }
174
- }
175
- const threshold = params.fieldValidDistance / fieldTransform.scale
176
- if (Math.sqrt(bestDist) > threshold) {
177
- return null
178
- }
179
- // Scale the vector components by the same factor
180
- return { x: nearest.dx * fieldTransform.scale, y: nearest.dy * fieldTransform.scale }
181
- }
182
-
183
- // Fallback: uniform rightward flow
184
- return { x: 1, y: 0 }
185
- }
186
-
187
- function animate(timestamp: number) {
188
- const now = timestamp || performance.now()
189
- const dt = Math.min(0.033, (now - lastTime) / 1000)
190
- lastTime = now
191
- const time = now * 0.001
192
- const palette = getActiveColorPreset()
193
-
194
- for (let i = 0; i < params.particleCount; i += 1) {
195
- const i3 = i * 3
196
- let x = positions[i3]
197
- let y = positions[i3 + 1]
198
-
199
- const v = sampleField(x, y, time)
200
- if (!v) {
201
- resetParticle(i)
202
- continue
203
- }
68
+ let hasFieldA = false
69
+ let hasFieldB = false
204
70
 
205
- x += (v.x * FLOW_SCALE * params.speed + randomRange(-JITTER, JITTER)) * dt
206
- y += (v.y * FLOW_SCALE * params.speed + randomRange(-JITTER, JITTER)) * dt
207
-
208
- const speed = Math.hypot(v.x, v.y)
209
- const glow = Math.min(1, speed * SPEED_TO_GLOW)
210
-
211
- applyColor(i3, glow, palette, false)
212
-
213
- lifetimes[i] -= dt
214
-
215
- if (lifetimes[i] <= 0 || Math.abs(x) > WORLD_EXTENT || Math.abs(y) > WORLD_EXTENT) {
216
- resetParticle(i)
217
- } else {
218
- positions[i3] = x
219
- positions[i3 + 1] = y
220
- }
221
- }
222
-
223
- geometry.attributes.position.needsUpdate = true
224
- geometry.attributes.color.needsUpdate = true
225
-
226
- composer.render()
227
- requestAnimationFrame(animate)
228
- }
71
+ const fieldLoaderA = new FieldLoader((data, transform) => {
72
+ particleSystemA.setFieldData(data, transform)
73
+ hasFieldA = particleSystemA.hasFieldData()
74
+ updateLayout()
75
+ })
229
76
 
230
- function resize() {
231
- const width = window.innerWidth
232
- const height = window.innerHeight
233
- const aspect = width / height
234
- const viewSize = 1.6
77
+ const fieldLoaderB = new FieldLoader((data, transform) => {
78
+ particleSystemB.setFieldData(data, transform)
79
+ hasFieldB = particleSystemB.hasFieldData()
80
+ updateLayout()
81
+ })
235
82
 
236
- camera.left = -viewSize * aspect
237
- camera.right = viewSize * aspect
238
- camera.top = viewSize
239
- camera.bottom = -viewSize
240
- 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
+ })
241
98
 
242
- renderer.setSize(width, height, false)
243
- composer.setSize(width, height)
244
- 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]
245
102
  }
246
103
 
247
- function randomRange(min: number, max: number) {
248
- return min + Math.random() * (max - min)
249
- }
104
+ const recordingManager = new RecordingManager(renderer, composer, bloomPass, resize)
250
105
 
106
+ // HUD overlay
251
107
  function createOverlay() {
108
+ if (!container) return
252
109
  const hud = document.createElement('div')
253
110
  hud.className = 'hud'
254
- 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>`
255
- app.appendChild(hud)
256
- 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'))
257
115
  }
258
116
 
259
- function createControls() {
260
- const panel = document.createElement('div')
261
- panel.className = 'controls'
262
-
263
- const header = document.createElement('div')
264
- header.className = 'controls__title'
265
- header.textContent = 'Controls'
266
- panel.appendChild(header)
267
-
268
- const colorControl = addSelect(panel, 'Color', COLOR_PRESETS, params.colorPreset, (key) => {
269
- params.colorPreset = key
270
- })
271
-
272
- const speedControl = addSlider(panel, 'Speed', 0.1, 6, 0.1, params.speed, (value) => {
273
- params.speed = value
274
- })
275
-
276
- const advancedToggle = document.createElement('button')
277
- advancedToggle.type = 'button'
278
- advancedToggle.className = 'controls__button'
279
- advancedToggle.textContent = 'Show advanced'
280
-
281
- const advancedSection = document.createElement('div')
282
- advancedSection.className = 'controls__advanced'
283
- advancedSection.style.display = 'none'
284
-
285
- const sizeControl = addSlider(advancedSection, 'Size', 0.5, 4, 0.1, params.size, (value) => {
286
- params.size = value
287
- material.size = value
288
- })
289
-
290
- const particleCountControl = addSlider(advancedSection, 'Particle count', 100, 8000, 100, params.particleCount, (value) => {
291
- params.particleCount = Math.round(value)
292
- resizeParticleBuffers()
293
- })
294
-
295
- const bloomStrengthControl = addSlider(advancedSection, 'Bloom strength', 0.2, 2.5, 0.05, params.bloomStrength, (value) => {
296
- params.bloomStrength = value
297
- updateBloom()
298
- })
299
-
300
- const bloomRadiusControl = addSlider(advancedSection, 'Bloom radius', 0.0, 1.2, 0.02, params.bloomRadius, (value) => {
301
- params.bloomRadius = value
302
- updateBloom()
303
- })
304
-
305
- let lifeMinControl: SliderHandle
306
- let lifeMaxControl: SliderHandle
307
-
308
- lifeMinControl = addSlider(advancedSection, 'Life min (s)', 0.1, 2.0, 0.05, params.lifeMin, (value) => {
309
- params.lifeMin = value
310
- if (params.lifeMin > params.lifeMax) {
311
- params.lifeMax = value
312
- 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'
313
140
  }
314
- reseedLifetimes()
315
- })
141
+ }
142
+ const hideOverlay = () => {
143
+ dropOverlayLeft.style.display = 'none'
144
+ dropOverlayRight.style.display = 'none'
145
+ }
316
146
 
317
- lifeMaxControl = addSlider(advancedSection, 'Life max (s)', 0.2, 5.0, 0.05, params.lifeMax, (value) => {
318
- params.lifeMax = value
319
- if (params.lifeMax < params.lifeMin) {
320
- params.lifeMin = value
321
- 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()
322
180
  }
323
- reseedLifetimes()
324
- })
181
+ }
325
182
 
326
- const fieldDistControl = addSlider(advancedSection, 'Field border', FIELD_BORDER_MIN, FIELD_BORDER_MAX, 0.01, params.fieldValidDistance, (value) => {
327
- 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)
328
187
  })
329
- fieldDistControlHandle = fieldDistControl
330
188
 
331
- advancedToggle.addEventListener('click', () => {
332
- const isHidden = advancedSection.style.display === 'none'
333
- advancedSection.style.display = isHidden ? 'block' : 'none'
334
- advancedToggle.textContent = isHidden ? 'Hide advanced' : 'Show advanced'
189
+ window.addEventListener('dragleave', (e) => {
190
+ e.preventDefault()
191
+ hideOverlay()
335
192
  })
336
193
 
337
- panel.appendChild(advancedToggle)
338
- panel.appendChild(advancedSection)
339
-
340
- const resetBtn = document.createElement('button')
341
- resetBtn.type = 'button'
342
- resetBtn.className = 'controls__button'
343
- resetBtn.textContent = 'Reset to defaults'
344
- resetBtn.addEventListener('click', () => {
345
- Object.assign(params, defaultParams)
346
- material.size = params.size
347
- updateBloom()
348
- reseedLifetimes()
349
- syncSlider(sizeControl, params.size)
350
- syncSlider(speedControl, params.speed)
351
- syncSlider(particleCountControl, params.particleCount)
352
- syncSlider(bloomStrengthControl, params.bloomStrength)
353
- syncSlider(bloomRadiusControl, params.bloomRadius)
354
- syncSlider(lifeMinControl, params.lifeMin)
355
- syncSlider(lifeMaxControl, params.lifeMax)
356
- syncSlider(fieldDistControl, params.fieldValidDistance)
357
- syncSelect(colorControl, params.colorPreset)
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)
200
+ }
358
201
  })
359
- panel.appendChild(resetBtn)
360
-
361
- app.appendChild(panel)
362
202
  }
363
203
 
364
- function addSlider(
365
- panel: HTMLDivElement,
366
- label: string,
367
- min: number,
368
- max: number,
369
- step: number,
370
- value: number,
371
- onChange: (value: number) => void,
372
- ): SliderHandle {
373
- const row = document.createElement('label')
374
- row.className = 'controls__row'
375
-
376
- const text = document.createElement('span')
377
- text.textContent = label
378
-
379
- const input = document.createElement('input')
380
- input.type = 'range'
381
- input.min = String(min)
382
- input.max = String(max)
383
- input.step = String(step)
384
- input.value = String(value)
385
-
386
- const valueTag = document.createElement('span')
387
- valueTag.className = 'controls__value'
388
- valueTag.textContent = formatValue(value, step)
389
-
390
- input.addEventListener('input', (event) => {
391
- const next = parseFloat((event.target as HTMLInputElement).value)
392
- valueTag.textContent = formatValue(next, step)
393
- onChange(next)
394
- })
204
+ function computeViewOffset(): number {
205
+ const aspect = window.innerWidth / window.innerHeight
206
+ const cameraWidth = VIEW_SIZE * aspect * 2
395
207
 
396
- row.appendChild(text)
397
- row.appendChild(input)
398
- row.appendChild(valueTag)
399
- panel.appendChild(row)
208
+ // Visual sweet spot: fields shouldn't spread beyond this on wide windows
209
+ const maxVisualOffset = 1.4
400
210
 
401
- return { input, valueTag }
402
- }
211
+ // Minimum separation
212
+ const minOffset = 1.0
403
213
 
404
- function addSelect(
405
- panel: HTMLDivElement,
406
- label: string,
407
- options: ColorPreset[],
408
- value: string,
409
- onChange: (key: string) => void,
410
- ): HTMLSelectElement {
411
- const row = document.createElement('label')
412
- row.className = 'controls__row'
413
-
414
- const text = document.createElement('span')
415
- text.textContent = label
416
-
417
- const select = document.createElement('select')
418
- select.className = 'controls__select'
419
- for (const option of options) {
420
- const optEl = document.createElement('option')
421
- optEl.value = option.key
422
- optEl.textContent = option.label
423
- select.appendChild(optEl)
424
- }
425
- select.value = value
426
- select.addEventListener('change', (event) => {
427
- const next = (event.target as HTMLSelectElement).value
428
- onChange(next)
429
- })
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
430
217
 
431
- row.appendChild(text)
432
- row.appendChild(select)
433
- 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
434
224
 
435
- return select
436
- }
225
+ // Final offset: respect visual maximum, but reduce if panel constrains space
226
+ const maxOffset = Math.min(maxVisualOffset, offsetWithPanelConstraint)
437
227
 
438
- function updateBloom() {
439
- bloomPass.strength = params.bloomStrength
440
- bloomPass.radius = params.bloomRadius
228
+ return Math.max(minOffset, Math.min(desiredOffset, maxOffset))
441
229
  }
442
230
 
443
- function formatValue(value: number, step: number) {
444
- 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()
445
245
  }
446
246
 
447
- function syncSlider(control: { input: HTMLInputElement; valueTag: HTMLSpanElement }, value: number) {
448
- control.input.value = String(value)
449
- control.valueTag.textContent = formatValue(value, parseFloat(control.input.step))
450
- }
247
+ // Resize handler
248
+ function resize() {
249
+ const width = window.innerWidth
250
+ const height = window.innerHeight
251
+ const aspect = width / height
451
252
 
452
- function syncSelect(select: HTMLSelectElement, value: string) {
453
- select.value = value
454
- }
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()
455
258
 
456
- function getActiveColorPreset(): ColorPreset {
457
- 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()
458
263
  }
459
264
 
460
- function applyColor(i3: number, glow: number, palette: ColorPreset, isSpawn: boolean) {
461
- const clampedGlow = Math.min(1, Math.max(0, glow))
462
-
463
- // Preserve legacy look for the default palette
464
- if (palette.key === DEFAULT_COLOR_PRESET) {
465
- if (isSpawn) {
466
- colors[i3] = 0.6 * clampedGlow
467
- colors[i3 + 1] = 0.25 * clampedGlow
468
- 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)
469
279
  } else {
470
- colors[i3] = 0.35 + clampedGlow * 0.9
471
- colors[i3 + 1] = 0.18 + clampedGlow * 0.45
472
- colors[i3 + 2] = 0.6 + clampedGlow * 0.35
280
+ particlesA.visible = false
281
+ particlesB.visible = true
282
+ particleSystemB.setViewOffset(0)
473
283
  }
474
- return
284
+ } else {
285
+ particlesA.visible = true
286
+ particlesB.visible = false
287
+ particleSystemA.setViewOffset(0)
288
+ particleSystemB.setViewOffset(0)
475
289
  }
476
-
477
- // For other palettes, scale their hue by brightness without white mixing
478
- const brightness = isSpawn ? clampedGlow : 0.35 + clampedGlow * 0.65
479
- const [r, g, b] = palette.rgb
480
- colors[i3] = r * brightness
481
- colors[i3 + 1] = g * brightness
482
- colors[i3 + 2] = b * brightness
483
290
  }
484
291
 
485
- function reseedLifetimes() {
486
- for (let i = 0; i < params.particleCount; i += 1) {
487
- lifetimes[i] = randomRange(params.lifeMin, params.lifeMax)
488
- }
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
489
297
  }
490
298
 
491
- function resizeParticleBuffers() {
492
- positions = new Float32Array(params.particleCount * 3)
493
- colors = new Float32Array(params.particleCount * 3)
494
- lifetimes = new Float32Array(params.particleCount)
495
- geometry.setAttribute('position', new BufferAttribute(positions, 3))
496
- geometry.setAttribute('color', new BufferAttribute(colors, 3))
497
- initParticles()
498
- }
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]
499
303
 
500
- async function loadVectorField() {
501
- try {
502
- const res = await fetch('/vector-field.json', { cache: 'no-store' })
503
- if (!res.ok) {
504
- updateFieldStatus('default (built-in)')
505
- return
506
- }
507
- const data = (await res.json()) as VectorDatum[]
508
- if (Array.isArray(data) && data.length > 0) {
509
- // Compute bounding box
510
- let minX = data[0].x
511
- let maxX = data[0].x
512
- let minY = data[0].y
513
- let maxY = data[0].y
514
- for (const d of data) {
515
- if (d.x < minX) minX = d.x
516
- if (d.x > maxX) maxX = d.x
517
- if (d.y < minY) minY = d.y
518
- if (d.y > maxY) maxY = d.y
519
- }
520
- const dataWidth = maxX - minX
521
- const dataHeight = maxY - minY
522
- const dataSize = Math.max(dataWidth, dataHeight)
523
-
524
- // Normalize to fit within [-WORLD_EXTENT, WORLD_EXTENT]
525
- const targetSize = WORLD_EXTENT * 1.8 // leave some margin
526
- fieldTransform.scale = dataSize > 0 ? targetSize / dataSize : 1
527
- fieldTransform.offsetX = -(minX + maxX) * 0.5 * fieldTransform.scale
528
- fieldTransform.offsetY = -(minY + maxY) * 0.5 * fieldTransform.scale
529
-
530
- // Auto-compute field valid distance from average nearest-neighbor spacing
531
- let sumDist = 0
532
- const sampleSize = Math.min(data.length, 200) // sample subset for efficiency
533
- for (let i = 0; i < sampleSize; i += 1) {
534
- const pt = data[i]
535
- let minDist = Number.MAX_VALUE
536
- for (let j = 0; j < data.length; j += 1) {
537
- if (i === j) continue
538
- const dx = data[j].x - pt.x
539
- const dy = data[j].y - pt.y
540
- const dist = Math.sqrt(dx * dx + dy * dy)
541
- if (dist < minDist) minDist = dist
542
- }
543
- sumDist += minDist
544
- }
545
- const avgSpacing = sumDist / sampleSize
546
- const autoThreshold = Math.min(
547
- FIELD_BORDER_MAX,
548
- Math.max(FIELD_BORDER_MIN, avgSpacing * fieldTransform.scale * 0.7),
549
- ) // 70% of avg spacing, clamped to UI range
550
- params.fieldValidDistance = autoThreshold
551
- if (fieldDistControlHandle) {
552
- syncSlider(fieldDistControlHandle, params.fieldValidDistance)
553
- }
554
- console.log('Auto field border:', autoThreshold.toFixed(4), 'avg spacing:', avgSpacing.toFixed(4))
304
+ function animate(timestamp: number) {
305
+ const now = timestamp || performance.now()
306
+ const dt = Math.min(0.033, (now - lastTime) / 1000)
307
+ lastTime = now
555
308
 
556
- fieldData = data
557
- updateFieldStatus(`loaded ${data.length} vectors (${dataWidth.toFixed(1)}×${dataHeight.toFixed(1)})`)
558
- console.log('Field bounds:', { minX, maxX, minY, maxY, scale: fieldTransform.scale })
559
- } else {
560
- updateFieldStatus('default (empty file)')
561
- }
562
- } catch (error) {
563
- console.error('Failed to load vector field', error)
564
- updateFieldStatus('default (load error)')
565
- }
566
- }
309
+ particleSystemA.update(dt, cachedPresetA)
310
+ particleSystemB.update(dt, cachedPresetB)
567
311
 
568
- function updateFieldStatus(label: string) {
569
- if (fieldStatusEl) fieldStatusEl.textContent = label
312
+ composer.render()
313
+ recordingManager.update()
314
+
315
+ requestAnimationFrame(animate)
570
316
  }
317
+
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)