@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.
- package/LICENSE +21 -0
- package/README.md +49 -0
- package/bin/luminar.mjs +93 -0
- package/dist/assets/index-BTqubGOw.js +4151 -0
- package/dist/assets/index-CCp_I16V.css +1 -0
- package/dist/index.html +17 -0
- package/dist/vector-field.json +5072 -0
- package/dist/vite.svg +1 -0
- package/index.html +16 -0
- package/package.json +50 -0
- package/public/vector-field.json +5072 -0
- package/public/vite.svg +1 -0
- package/src/main.ts +570 -0
- package/src/style.css +136 -0
package/public/vite.svg
ADDED
|
@@ -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
|
+
}
|