@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/README.md +86 -32
- 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/dist/vector-field.json +3692 -3380
- package/package.json +1 -1
- package/public/vector-field.json +3692 -3380
- 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 +243 -486
- 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 +130 -0
- package/dist/assets/index-BTqubGOw.js +0 -4151
- package/dist/assets/index-CCp_I16V.css +0 -1
package/README.md
CHANGED
|
@@ -1,10 +1,24 @@
|
|
|
1
1
|
# luminar
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**2D vector field particle flow visualization** with bloom effects, powered by Three.js.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
A particle-flow visualization inspired by the bloom-heavy aesthetic of [lumap](https://github.com/brandonlukas/lumap). Features dual-field rendering, spatial grid optimization for large datasets, real-time controls, and WebM recording.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+

|
|
8
|
+
|
|
9
|
+
## Features
|
|
10
|
+
|
|
11
|
+
- 🎨 **Dual vector fields** - Load and visualize two CSV vector fields side-by-side with independent colors
|
|
12
|
+
- 🚀 **High performance** - Spatial grid optimization handles 5000+ field points at 60 FPS
|
|
13
|
+
- 🎬 **Built-in recording** - Export as WebM video up to 4K resolution
|
|
14
|
+
- 🎛️ **Real-time controls** - Adjust particle count, speed, bloom, colors, trails, and more
|
|
15
|
+
- 📂 **Drag & drop** - Drop CSV files directly into the browser to load fields
|
|
16
|
+
- 🌈 **8 color presets** - From luminous violet to electric lime
|
|
17
|
+
- ✨ **Bloom & trails** - Unreal Bloom post-processing with optional motion trails
|
|
18
|
+
|
|
19
|
+
## Quick Start
|
|
20
|
+
|
|
21
|
+
Visualize a CSV vector field with zero install:
|
|
8
22
|
|
|
9
23
|
```sh
|
|
10
24
|
npx @brandonlukas/luminar path/to/field.csv
|
|
@@ -12,9 +26,11 @@ npx @brandonlukas/luminar path/to/field.csv
|
|
|
12
26
|
|
|
13
27
|
Optional flags: `--port 5173`, `--host 0.0.0.0`, `--preview` (uses production build)
|
|
14
28
|
|
|
15
|
-
|
|
29
|
+
**Or drag and drop:** Just run `npx @brandonlukas/luminar` and drag CSV files into the browser window!
|
|
16
30
|
|
|
17
|
-
|
|
31
|
+
## CSV Format
|
|
32
|
+
|
|
33
|
+
Your CSV should have four columns: `x`, `y`, `dx`, `dy` (position x, position y, velocity x, velocity y). Headers are optional and auto-detected.
|
|
18
34
|
|
|
19
35
|
**Example with header:**
|
|
20
36
|
```csv
|
|
@@ -22,51 +38,89 @@ x,y,dx,dy
|
|
|
22
38
|
0.0,0.0,1.2,0.5
|
|
23
39
|
0.5,0.0,1.1,0.6
|
|
24
40
|
1.0,0.0,0.9,0.7
|
|
25
|
-
0.0,0.5,1.3,0.4
|
|
26
41
|
```
|
|
27
42
|
|
|
28
|
-
**Example without header:**
|
|
43
|
+
**Example without header (whitespace-separated also supported):**
|
|
29
44
|
```csv
|
|
30
|
-
0.0
|
|
31
|
-
0.5
|
|
32
|
-
1.0
|
|
33
|
-
0.0,0.5,1.3,0.4
|
|
45
|
+
0.0 0.0 1.2 0.5
|
|
46
|
+
0.5 0.0 1.1 0.6
|
|
47
|
+
1.0 0.0 0.9 0.7
|
|
34
48
|
```
|
|
35
49
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
### Local development
|
|
50
|
+
## Local Development
|
|
39
51
|
|
|
40
52
|
```sh
|
|
53
|
+
git clone https://github.com/brandonlukas/luminar.git
|
|
54
|
+
cd luminar
|
|
41
55
|
npm install
|
|
42
56
|
npm run dev
|
|
43
57
|
```
|
|
44
58
|
|
|
45
|
-
|
|
59
|
+
Visit http://localhost:5173 to see the visualization.
|
|
46
60
|
|
|
61
|
+
**Build for production:**
|
|
47
62
|
```sh
|
|
48
|
-
npm run
|
|
63
|
+
npm run build
|
|
64
|
+
npm run preview # test production build locally
|
|
49
65
|
```
|
|
50
|
-
The parser auto-detects and skips header rows if present. Use `--preview` to run against the built bundle instead of dev, and `--host 0.0.0.0` to expose on your network.
|
|
51
|
-
|
|
52
|
-
Build for production:
|
|
53
66
|
|
|
67
|
+
## Architecture
|
|
68
|
+
|
|
69
|
+
### Performance
|
|
70
|
+
- **Spatial grid optimization**: O(1) field lookups handle 5000+ field points at 60 FPS
|
|
71
|
+
- **Frustum culling**: Three.js automatically culls off-screen particles
|
|
72
|
+
- **Squared distance comparisons**: Eliminates expensive sqrt operations (~300k/sec)
|
|
73
|
+
- **Cached calculations**: Loop variables computed once per frame, not per particle
|
|
74
|
+
|
|
75
|
+
### Rendering Pipeline
|
|
76
|
+
1. **Particle system**: Float32Array buffers for positions, colors, lifetimes
|
|
77
|
+
2. **Field sampling**: Spatial grid data structure for nearest-neighbor lookups
|
|
78
|
+
3. **Camera**: Orthographic projection with responsive viewport offset for dual fields
|
|
79
|
+
4. **Post-processing**: UnrealBloomPass with additive blending for luminous effects
|
|
80
|
+
|
|
81
|
+
### Key Files
|
|
82
|
+
- [src/modules/particle-system.ts](src/modules/particle-system.ts) - Core particle physics and field sampling
|
|
83
|
+
- [src/modules/field-loader.ts](src/modules/field-loader.ts) - CSV parsing and coordinate transformation
|
|
84
|
+
- [src/modules/controls.ts](src/modules/controls.ts) - UI control panel with real-time parameter updates
|
|
85
|
+
- [src/modules/recording.ts](src/modules/recording.ts) - WebM video recording with MediaRecorder API
|
|
86
|
+
- [src/lib/constants.ts](src/lib/constants.ts) - Tunable parameters (FLOW_SCALE, WORLD_EXTENT, etc.)
|
|
87
|
+
|
|
88
|
+
## Recording Video
|
|
89
|
+
|
|
90
|
+
1. Click **Controls** button (top-right)
|
|
91
|
+
2. Scroll to **Export (WebM)** section
|
|
92
|
+
3. Choose resolution: Current window, 1080p, 1440p, or 4K
|
|
93
|
+
4. Choose duration: 3s, 5s, 10s, or 15s
|
|
94
|
+
5. Click **⏺ Start recording**
|
|
95
|
+
6. WebM file downloads automatically when complete
|
|
96
|
+
|
|
97
|
+
**Convert to GIF or MP4:**
|
|
54
98
|
```sh
|
|
55
|
-
|
|
99
|
+
# GIF (30fps, 720p width)
|
|
100
|
+
ffmpeg -i luminar.webm -vf "fps=30,scale=720:-1:flags=lanczos" -loop 0 luminar.gif
|
|
101
|
+
|
|
102
|
+
# MP4 (h264)
|
|
103
|
+
ffmpeg -i luminar.webm -c:v libx264 -preset slow -crf 18 luminar.mp4
|
|
56
104
|
```
|
|
57
105
|
|
|
58
|
-
##
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
- Glow intensity maps to local speed; particles respawn when leaving the world bounds.
|
|
62
|
-
- Unreal Bloom and additive blending preserve the luminous, hazy aesthetic.
|
|
63
|
-
- On-canvas controls (top-right) adjust size, bloom strength, and bloom radius in real time.
|
|
106
|
+
## Parameters
|
|
107
|
+
|
|
108
|
+
Adjust these in the **Controls** panel or modify [src/lib/constants.ts](src/lib/constants.ts):
|
|
64
109
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
110
|
+
| Parameter | Default | Description |
|
|
111
|
+
| -------------------- | -------- | ----------------------------------- |
|
|
112
|
+
| `particleCount` | 5000 | Number of particles per field |
|
|
113
|
+
| `speed` | 6.0 | Global velocity multiplier |
|
|
114
|
+
| `size` | 2.0 | Particle size in pixels |
|
|
115
|
+
| `bloomStrength` | 1.2 | Intensity of bloom effect |
|
|
116
|
+
| `bloomRadius` | 0.35 | Spread of bloom glow |
|
|
117
|
+
| `lifeMin/Max` | 0.5-1.4s | Particle lifetime range |
|
|
118
|
+
| `fieldValidDistance` | 0.05 | Max distance for field sampling |
|
|
119
|
+
| `noiseStrength` | 0.0 | Turbulence intensity (0 = disabled) |
|
|
120
|
+
| `trailsEnabled` | false | Motion blur effect |
|
|
121
|
+
| `trailDecay` | 0.9 | Trail fade rate (when enabled) |
|
|
69
122
|
|
|
70
123
|
## Notes
|
|
71
|
-
- The scene
|
|
72
|
-
- Fonts and overlay styling
|
|
124
|
+
- The scene loops continuously with no user interaction beyond controls
|
|
125
|
+
- Fonts and overlay styling: [src/style.css](src/style.css)
|
|
126
|
+
- Spatial grid auto-calibrates cell size based on field data density
|
package/bin/luminar.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import { readFileSync, writeFileSync, existsSync } from 'node:fs'
|
|
|
3
3
|
import { resolve, dirname } from 'node:path'
|
|
4
4
|
import { fileURLToPath } from 'node:url'
|
|
5
5
|
import { spawn } from 'node:child_process'
|
|
6
|
+
import { parseCsv } from '../dist/lib/csv-parser.js'
|
|
6
7
|
|
|
7
8
|
const __filename = fileURLToPath(import.meta.url)
|
|
8
9
|
const __dirname = dirname(__filename)
|
|
@@ -28,26 +29,6 @@ function parseArgs() {
|
|
|
28
29
|
return out
|
|
29
30
|
}
|
|
30
31
|
|
|
31
|
-
function parseCsv(text) {
|
|
32
|
-
const lines = text.split(/\r?\n/).filter(Boolean)
|
|
33
|
-
const rows = []
|
|
34
|
-
let skippedHeader = false
|
|
35
|
-
for (const line of lines) {
|
|
36
|
-
const parts = line.split(/[,\s]+/).filter(Boolean)
|
|
37
|
-
if (parts.length < 4) continue
|
|
38
|
-
const [x, y, dx, dy] = parts.map(Number)
|
|
39
|
-
if ([x, y, dx, dy].some((n) => Number.isNaN(n))) {
|
|
40
|
-
if (!skippedHeader && rows.length === 0) {
|
|
41
|
-
skippedHeader = true
|
|
42
|
-
console.log('skipping header line:', line.substring(0, 60))
|
|
43
|
-
}
|
|
44
|
-
continue
|
|
45
|
-
}
|
|
46
|
-
rows.push({ x, y, dx, dy })
|
|
47
|
-
}
|
|
48
|
-
return rows
|
|
49
|
-
}
|
|
50
|
-
|
|
51
32
|
function writeFieldJson(rows) {
|
|
52
33
|
const target = resolve(projectRoot, 'public', 'vector-field.json')
|
|
53
34
|
writeFileSync(target, JSON.stringify(rows, null, 2), 'utf8')
|
|
@@ -70,23 +51,28 @@ function runServer({ port, host, preview }) {
|
|
|
70
51
|
|
|
71
52
|
function main() {
|
|
72
53
|
const { file, port, host, preview } = parseArgs()
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
process.
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
54
|
+
|
|
55
|
+
if (file) {
|
|
56
|
+
// User provided a CSV file - parse and load it
|
|
57
|
+
const resolved = resolve(process.cwd(), file)
|
|
58
|
+
if (!existsSync(resolved)) {
|
|
59
|
+
console.error(`File not found: ${resolved}`)
|
|
60
|
+
process.exit(1)
|
|
61
|
+
}
|
|
62
|
+
const text = readFileSync(resolved, 'utf8')
|
|
63
|
+
const rows = parseCsv(text)
|
|
64
|
+
if (rows.length === 0) {
|
|
65
|
+
console.error('Parsed 0 rows; ensure CSV has x,y,dx,dy columns (header optional)')
|
|
66
|
+
process.exit(1)
|
|
67
|
+
}
|
|
68
|
+
writeFieldJson(rows)
|
|
69
|
+
console.log(`Loaded ${rows.length} vectors from ${resolved}`)
|
|
70
|
+
} else {
|
|
71
|
+
// No file provided - launch app with default empty state
|
|
72
|
+
console.log('No CSV file provided - launching with default empty state')
|
|
73
|
+
console.log('You can drag & drop CSV files in the webapp once it loads')
|
|
88
74
|
}
|
|
89
|
-
|
|
75
|
+
|
|
90
76
|
runServer({ port, host, preview })
|
|
91
77
|
}
|
|
92
78
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
@import "https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;600&display=swap";:root{color:#e8f0ff;background-color:#02040a;font-family:Space Grotesk,Segoe UI,system-ui,sans-serif}*{box-sizing:border-box}body{background:radial-gradient(circle at 20% 20%,#508cff1f,#0000 35%),radial-gradient(circle at 80% 10%,#b464ff1f,#0000 30%),radial-gradient(circle at 50% 80%,#3cc8b424,#0000 32%),#02040a;min-height:100vh;margin:0;overflow:hidden}#app{position:fixed;inset:0;overflow:hidden}canvas{filter:saturate(1.05);width:100%;height:100%;display:block}.hud{color:#dfe8ff;letter-spacing:.08em;text-transform:uppercase;pointer-events:none;mix-blend-mode:screen;text-shadow:0 0 12px #6eaaff4d;position:absolute;top:18px;left:18px}.hud .title{font-size:16px;font-weight:600}.hud .subtitle{opacity:.7;letter-spacing:.04em;margin-top:4px;font-size:12px;font-weight:400}.hud .status{opacity:.8;letter-spacing:.03em;color:#9fb7ff;margin-top:6px;font-size:11px;font-weight:400}.controls{-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);color:#dfe8ff;pointer-events:auto;background:#060a16b3;border:1px solid #78b4ff40;border-radius:12px;width:240px;padding:14px 14px 10px;position:absolute;top:18px;right:18px;box-shadow:0 12px 30px #00000059,0 0 24px #64a0ff1f}.controls__title{text-transform:uppercase;letter-spacing:.1em;color:#9fb7ff;margin-bottom:10px;font-size:12px}.controls__header{justify-content:space-between;align-items:baseline;margin-bottom:10px;display:flex}.controls__toggle{color:#9fb7ff;cursor:pointer;background:linear-gradient(120deg,#78b4ff1f,#5078c814);border:1px solid #78b4ff4d;border-radius:6px;margin:0 0 0 8px;padding:6px 10px;font-size:14px;font-weight:600;line-height:1;transition:all .12s;display:inline-block}.controls__toggle:hover{color:#dfe8ff;background:linear-gradient(120deg,#78b4ff2e,#5078c81f);border-color:#8cc8ff99;box-shadow:0 0 12px #78b4ff33}.controls__toggle:active{transform:translateY(1px)}.controls--collapsed{width:auto!important;padding:10px 12px!important}.controls--collapsed .controls__header{margin-bottom:0}.controls--collapsed>:not(.controls__header){display:none!important}.controls__row{color:#e8f0ff;grid-template-columns:1fr 1fr auto;align-items:center;gap:8px;margin-bottom:8px;font-size:12px;display:grid}.controls__row input[type=range]{accent-color:#7cc4ff;width:100%}.controls__value{font-variant-numeric:tabular-nums;color:#9fb7ff;text-align:right;min-width:44px}.controls__button{color:#dfe8ff;letter-spacing:.04em;cursor:pointer;background:linear-gradient(120deg,#78b4ff29,#5078c81a);border:1px solid #78b4ff59;border-radius:10px;width:100%;margin-top:6px;padding:8px 10px;font-size:12px;transition:border-color .12s,transform .12s,box-shadow .2s}.controls__button:hover{border-color:#8cc8ffb3;box-shadow:0 0 18px #78b4ff40}.controls__button:active{transform:translateY(1px)}.controls__button--record{background:linear-gradient(120deg,#ff507838,#c8507824);border-color:#ff789666}.controls__button--record:hover{border-color:#ff8caacc;box-shadow:0 0 18px #ff78964d}.controls__section{border-top:1px solid #78b4ff26;margin-top:12px;padding-top:12px}.controls__subtitle{text-transform:uppercase;letter-spacing:.1em;color:#9fb7ff;opacity:.85;margin-bottom:8px;font-size:11px}.controls__status{color:#b3d4ff;text-align:center;background:#64b4ff1a;border:1px solid #78b4ff33;border-radius:6px;margin-top:8px;padding:6px 8px;font-size:11px}.controls__select{color:#dfe8ff;cursor:pointer;background:#141e3280;border:1px solid #78b4ff40;border-radius:6px;width:100%;padding:4px 6px;font-size:11px}.controls__select:hover{border-color:#8cc8ff80}.controls__advanced{margin-top:8px}.drop-overlay{color:#d7e2ff;letter-spacing:.02em;z-index:20;pointer-events:none;-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px);background:#508cff33;justify-content:center;align-items:center;width:50%;font-size:18px;font-weight:600;display:flex;position:fixed;inset:0}.drop-overlay--left{border-right:3px solid #78b4ff66;left:0;right:auto}.drop-overlay--right{border-left:3px solid #78b4ff66;left:auto;right:0}
|