@brandonlukas/luminar 0.2.0 → 0.2.2

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 CHANGED
@@ -1,10 +1,24 @@
1
1
  # luminar
2
2
 
3
- Looping particle-flow study inspired by the bloom-heavy look of [lumap](https://github.com/brandonlukas/lumap). It renders a 2D vector field using Three.js with additive particles and an Unreal Bloom pass; flow magnitude controls glow intensity.
3
+ **2D vector field particle flow visualization** with bloom effects, powered by Three.js.
4
4
 
5
- ## Quick start
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
- Visualize a CSV field (columns: x, y, dx, dy; header optional) with zero install:
7
+ ![luminar visualization](./screenshot.png)
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
- ### CSV format
29
+ **Or drag and drop:** Just run `npx @brandonlukas/luminar` and drag CSV files into the browser window!
16
30
 
17
- Your CSV should have four columns: x position, y position, x velocity, y velocity. Headers are optional and will be auto-detected.
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,68 +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,0.0,1.2,0.5
31
- 0.5,0.0,1.1,0.6
32
- 1.0,0.0,0.9,0.7
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
- Whitespace-separated values are also supported.
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
- Or use the old script syntax:
59
+ Visit http://localhost:5173 to see the visualization.
46
60
 
61
+ **Build for production:**
47
62
  ```sh
48
- npm run visualize:csv -- --file path/to/field.csv
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
- npm run build
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
- ## How it works
59
- - Orthographic camera framing a square field with particles advected each frame.
60
- - Vector field defined in `sampleField` inside [src/main.ts](src/main.ts#L98-L110); edit to fit your data or dynamics.
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.
64
-
65
- ## Export as GIF or video
66
- The built-in recording controls let you capture your visualization:
67
- 1. Click **Show advanced** in the controls panel
68
- 2. Scroll to the **Export (WebM)** section
69
- 3. Select resolution (current window, 1080p, 1440p, or 4K)
70
- 4. Select duration (3–15 seconds)
71
- 5. Click **▶ Start recording**
72
- 6. Recording happens in real-time at 60fps
73
- 7. Download starts automatically when complete
74
-
75
- **Notes:**
76
- - Exports as WebM video (VP9 codec, high quality)
77
- - Higher resolutions use higher bitrates (up to 25 Mbps for 4K)
78
- - Convert to GIF or MP4 with ffmpeg: `ffmpeg -i luminar.webm output.gif`
79
- - Recording captures exactly what you see, so adjust controls before starting
80
- - Works in modern browsers with MediaRecorder API support (Chrome, Firefox, Edge)
81
-
82
- ## Tweaks to try
83
- - Increase `PARTICLE_COUNT` or `FLOW_SCALE` in [src/main.ts](src/main.ts#L20-L24) for denser motion.
84
- - Adjust `WORLD_EXTENT` and `JITTER` to change containment and randomness.
85
- - Swap `sampleField` to consume your own `(x, y, dx, dy)` tuples; normalize magnitudes before applying `FLOW_SCALE` for stability.
106
+ ## Parameters
107
+
108
+ Adjust these in the **Controls** panel or modify [src/lib/constants.ts](src/lib/constants.ts):
109
+
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) |
86
122
 
87
123
  ## Notes
88
- - The scene is non-interactive and loops continuously.
89
- - Fonts and overlay styling live in [src/style.css](src/style.css).
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
@@ -4,6 +4,30 @@ import { resolve, dirname } from 'node:path'
4
4
  import { fileURLToPath } from 'node:url'
5
5
  import { spawn } from 'node:child_process'
6
6
 
7
+ // Inline CSV parser to avoid module resolution issues
8
+ function parseCsv(text) {
9
+ const lines = text.split(/\r?\n/).filter(Boolean)
10
+ const rows = []
11
+ let skippedHeader = false
12
+
13
+ for (const line of lines) {
14
+ const parts = line.split(/[,\s]+/).filter(Boolean)
15
+ if (parts.length < 4) continue
16
+
17
+ const [x, y, dx, dy] = parts.map(Number)
18
+ if ([x, y, dx, dy].some((n) => Number.isNaN(n))) {
19
+ if (!skippedHeader && rows.length === 0) {
20
+ skippedHeader = true
21
+ console.log('skipping header line:', line.substring(0, 60))
22
+ }
23
+ continue
24
+ }
25
+ rows.push({ x, y, dx, dy })
26
+ }
27
+
28
+ return rows
29
+ }
30
+
7
31
  const __filename = fileURLToPath(import.meta.url)
8
32
  const __dirname = dirname(__filename)
9
33
  const projectRoot = resolve(__dirname, '..')
@@ -28,26 +52,6 @@ function parseArgs() {
28
52
  return out
29
53
  }
30
54
 
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
55
  function writeFieldJson(rows) {
52
56
  const target = resolve(projectRoot, 'public', 'vector-field.json')
53
57
  writeFileSync(target, JSON.stringify(rows, null, 2), 'utf8')
@@ -70,23 +74,28 @@ function runServer({ port, host, preview }) {
70
74
 
71
75
  function main() {
72
76
  const { file, port, host, preview } = parseArgs()
73
- if (!file) {
74
- console.error('Usage: luminar <file.csv> [--port 5173] [--host 0.0.0.0] [--preview]')
75
- console.error('Example: luminar data.csv')
76
- process.exit(1)
77
- }
78
- const resolved = resolve(process.cwd(), file)
79
- if (!existsSync(resolved)) {
80
- console.error(`File not found: ${resolved}`)
81
- process.exit(1)
82
- }
83
- const text = readFileSync(resolved, 'utf8')
84
- const rows = parseCsv(text)
85
- if (rows.length === 0) {
86
- console.error('Parsed 0 rows; ensure CSV has x,y,dx,dy columns (header optional)')
87
- process.exit(1)
77
+
78
+ if (file) {
79
+ // User provided a CSV file - parse and load it
80
+ const resolved = resolve(process.cwd(), file)
81
+ if (!existsSync(resolved)) {
82
+ console.error(`File not found: ${resolved}`)
83
+ process.exit(1)
84
+ }
85
+ const text = readFileSync(resolved, 'utf8')
86
+ const rows = parseCsv(text)
87
+ if (rows.length === 0) {
88
+ console.error('Parsed 0 rows; ensure CSV has x,y,dx,dy columns (header optional)')
89
+ process.exit(1)
90
+ }
91
+ writeFieldJson(rows)
92
+ console.log(`Loaded ${rows.length} vectors from ${resolved}`)
93
+ } else {
94
+ // No file provided - launch app with default empty state
95
+ console.log('No CSV file provided - launching with default empty state')
96
+ console.log('You can drag & drop CSV files in the webapp once it loads')
88
97
  }
89
- writeFieldJson(rows)
98
+
90
99
  runServer({ port, host, preview })
91
100
  }
92
101
 
@@ -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}