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