@brandonlukas/luminar 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 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
package/src/main.ts ADDED
@@ -0,0 +1,570 @@
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'
14
+ import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js'
15
+ import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js'
16
+ 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)
58
+
59
+ const container = document.querySelector<HTMLDivElement>('#app')
60
+ if (!container) throw new Error('Missing #app container')
61
+
62
+ const app = container
63
+
64
+ app.innerHTML = ''
65
+ const renderer = new WebGLRenderer({ antialias: false, alpha: true })
66
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
67
+ app.appendChild(renderer.domElement)
68
+
69
+ const scene = new Scene()
70
+ scene.background = new Color(0x02040a)
71
+
72
+ const camera = new OrthographicCamera(-1, 1, 1, -1, 0.1, 10)
73
+ camera.position.z = 2
74
+
75
+ const composer = new EffectComposer(renderer)
76
+ const renderPass = new RenderPass(scene, camera)
77
+ const bloomPass = new UnrealBloomPass(new Vector2(1, 1), params.bloomStrength, 0.82, params.bloomRadius)
78
+ updateBloom()
79
+ composer.addPass(renderPass)
80
+ composer.addPass(bloomPass)
81
+
82
+ const geometry = new BufferGeometry()
83
+ geometry.setAttribute('position', new BufferAttribute(positions, 3))
84
+ geometry.setAttribute('color', new BufferAttribute(colors, 3))
85
+
86
+ const material = new PointsMaterial({
87
+ size: params.size,
88
+ sizeAttenuation: true,
89
+ vertexColors: true,
90
+ transparent: true,
91
+ opacity: 0.9,
92
+ blending: AdditiveBlending,
93
+ depthWrite: false,
94
+ })
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
+ 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
+ }
204
+
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
+ }
229
+
230
+ function resize() {
231
+ const width = window.innerWidth
232
+ const height = window.innerHeight
233
+ const aspect = width / height
234
+ const viewSize = 1.6
235
+
236
+ camera.left = -viewSize * aspect
237
+ camera.right = viewSize * aspect
238
+ camera.top = viewSize
239
+ camera.bottom = -viewSize
240
+ camera.updateProjectionMatrix()
241
+
242
+ renderer.setSize(width, height, false)
243
+ composer.setSize(width, height)
244
+ bloomPass.setSize(width, height)
245
+ }
246
+
247
+ function randomRange(min: number, max: number) {
248
+ return min + Math.random() * (max - min)
249
+ }
250
+
251
+ function createOverlay() {
252
+ const hud = document.createElement('div')
253
+ 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')
257
+ }
258
+
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)
313
+ }
314
+ reseedLifetimes()
315
+ })
316
+
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)
322
+ }
323
+ reseedLifetimes()
324
+ })
325
+
326
+ const fieldDistControl = addSlider(advancedSection, 'Field border', FIELD_BORDER_MIN, FIELD_BORDER_MAX, 0.01, params.fieldValidDistance, (value) => {
327
+ params.fieldValidDistance = value
328
+ })
329
+ fieldDistControlHandle = fieldDistControl
330
+
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'
335
+ })
336
+
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)
358
+ })
359
+ panel.appendChild(resetBtn)
360
+
361
+ app.appendChild(panel)
362
+ }
363
+
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
+ })
395
+
396
+ row.appendChild(text)
397
+ row.appendChild(input)
398
+ row.appendChild(valueTag)
399
+ panel.appendChild(row)
400
+
401
+ return { input, valueTag }
402
+ }
403
+
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
+ })
430
+
431
+ row.appendChild(text)
432
+ row.appendChild(select)
433
+ panel.appendChild(row)
434
+
435
+ return select
436
+ }
437
+
438
+ function updateBloom() {
439
+ bloomPass.strength = params.bloomStrength
440
+ bloomPass.radius = params.bloomRadius
441
+ }
442
+
443
+ function formatValue(value: number, step: number) {
444
+ return step >= 1 ? value.toFixed(0) : value.toFixed(2)
445
+ }
446
+
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
+ }
451
+
452
+ function syncSelect(select: HTMLSelectElement, value: string) {
453
+ select.value = value
454
+ }
455
+
456
+ function getActiveColorPreset(): ColorPreset {
457
+ return COLOR_PRESETS.find((preset) => preset.key === params.colorPreset) ?? COLOR_PRESETS[0]
458
+ }
459
+
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
469
+ } 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
473
+ }
474
+ return
475
+ }
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
+ }
484
+
485
+ function reseedLifetimes() {
486
+ for (let i = 0; i < params.particleCount; i += 1) {
487
+ lifetimes[i] = randomRange(params.lifeMin, params.lifeMax)
488
+ }
489
+ }
490
+
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
+ }
499
+
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))
555
+
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
+ }
567
+
568
+ function updateFieldStatus(label: string) {
569
+ if (fieldStatusEl) fieldStatusEl.textContent = label
570
+ }
package/src/style.css ADDED
@@ -0,0 +1,136 @@
1
+ @import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;600&display=swap');
2
+
3
+ :root {
4
+ font-family: 'Space Grotesk', 'Segoe UI', system-ui, sans-serif;
5
+ color: #e8f0ff;
6
+ background-color: #02040a;
7
+ }
8
+
9
+ * {
10
+ box-sizing: border-box;
11
+ }
12
+
13
+ body {
14
+ margin: 0;
15
+ min-height: 100vh;
16
+ background: radial-gradient(circle at 20% 20%, rgba(80, 140, 255, 0.12), transparent 35%),
17
+ radial-gradient(circle at 80% 10%, rgba(180, 100, 255, 0.12), transparent 30%),
18
+ radial-gradient(circle at 50% 80%, rgba(60, 200, 180, 0.14), transparent 32%),
19
+ #02040a;
20
+ overflow: hidden;
21
+ }
22
+
23
+ #app {
24
+ position: fixed;
25
+ inset: 0;
26
+ overflow: hidden;
27
+ }
28
+
29
+ canvas {
30
+ display: block;
31
+ width: 100%;
32
+ height: 100%;
33
+ filter: saturate(1.05);
34
+ }
35
+
36
+ .hud {
37
+ position: absolute;
38
+ top: 18px;
39
+ left: 18px;
40
+ color: #dfe8ff;
41
+ letter-spacing: 0.08em;
42
+ text-transform: uppercase;
43
+ pointer-events: none;
44
+ mix-blend-mode: screen;
45
+ text-shadow: 0 0 12px rgba(110, 170, 255, 0.3);
46
+ }
47
+
48
+ .hud .title {
49
+ font-weight: 600;
50
+ font-size: 16px;
51
+ }
52
+
53
+ .hud .subtitle {
54
+ font-weight: 400;
55
+ font-size: 12px;
56
+ opacity: 0.7;
57
+ margin-top: 4px;
58
+ letter-spacing: 0.04em;
59
+ }
60
+
61
+ .hud .status {
62
+ font-weight: 400;
63
+ font-size: 11px;
64
+ opacity: 0.8;
65
+ margin-top: 6px;
66
+ letter-spacing: 0.03em;
67
+ color: #9fb7ff;
68
+ }
69
+
70
+ .controls {
71
+ position: absolute;
72
+ top: 18px;
73
+ right: 18px;
74
+ width: 240px;
75
+ padding: 14px 14px 10px;
76
+ background: rgba(6, 10, 22, 0.7);
77
+ border: 1px solid rgba(120, 180, 255, 0.25);
78
+ border-radius: 12px;
79
+ box-shadow: 0 12px 30px rgba(0, 0, 0, 0.35), 0 0 24px rgba(100, 160, 255, 0.12);
80
+ backdrop-filter: blur(10px);
81
+ color: #dfe8ff;
82
+ pointer-events: auto;
83
+ }
84
+
85
+ .controls__title {
86
+ font-size: 12px;
87
+ text-transform: uppercase;
88
+ letter-spacing: 0.1em;
89
+ margin-bottom: 10px;
90
+ color: #9fb7ff;
91
+ }
92
+
93
+ .controls__row {
94
+ display: grid;
95
+ grid-template-columns: 1fr 1fr auto;
96
+ align-items: center;
97
+ gap: 8px;
98
+ font-size: 12px;
99
+ margin-bottom: 8px;
100
+ color: #e8f0ff;
101
+ }
102
+
103
+ .controls__row input[type='range'] {
104
+ width: 100%;
105
+ accent-color: #7cc4ff;
106
+ }
107
+
108
+ .controls__value {
109
+ font-variant-numeric: tabular-nums;
110
+ color: #9fb7ff;
111
+ min-width: 44px;
112
+ text-align: right;
113
+ }
114
+
115
+ .controls__button {
116
+ width: 100%;
117
+ margin-top: 6px;
118
+ padding: 8px 10px;
119
+ border: 1px solid rgba(120, 180, 255, 0.35);
120
+ border-radius: 10px;
121
+ background: linear-gradient(120deg, rgba(120, 180, 255, 0.16), rgba(80, 120, 200, 0.1));
122
+ color: #dfe8ff;
123
+ font-size: 12px;
124
+ letter-spacing: 0.04em;
125
+ cursor: pointer;
126
+ transition: border-color 120ms ease, transform 120ms ease, box-shadow 200ms ease;
127
+ }
128
+
129
+ .controls__button:hover {
130
+ border-color: rgba(140, 200, 255, 0.7);
131
+ box-shadow: 0 0 18px rgba(120, 180, 255, 0.25);
132
+ }
133
+
134
+ .controls__button:active {
135
+ transform: translateY(1px);
136
+ }