midah commited on
Commit
478ac96
·
1 Parent(s): 30617b0

Add Netlify deployment configuration and update README with deployment instructions

Browse files
README.md CHANGED
@@ -186,10 +186,70 @@ The frontend will open at `http://localhost:3000`
186
 
187
  ### Netlify (React Frontend)
188
 
189
- 1. Deploy frontend to Netlify (set base directory to `frontend`)
190
- 2. Deploy backend to Railway/Render (set root directory to `backend`)
191
- 3. Set `REACT_APP_API_URL` environment variable in Netlify to your backend URL
192
- 4. Update CORS in backend to include your Netlify URL
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
193
 
194
  ## Architecture
195
 
@@ -229,8 +289,14 @@ The application uses:
229
  ## Performance Notes
230
 
231
  - **Full Dataset**: Loads all ~1.86 million models from the dataset
232
- - **Visualization Limit**: Maximum 50,000 points for smooth interaction (configurable via `max_points` API parameter)
233
- - **Level-of-Detail Rendering**: Frontend automatically samples to 10,000 points for 3D visualization while preserving family tree members
 
 
 
 
 
 
234
  - **Embedding Model**: `all-MiniLM-L6-v2` (good balance of quality and speed)
235
  - **Caching**: Embeddings and reduced dimensions are cached to disk for fast startup
236
  - **Optimizations**: Index-based lookups, vectorized operations, response compression, and optimized top-k queries
 
186
 
187
  ### Netlify (React Frontend)
188
 
189
+ The frontend is configured for deployment on Netlify. The `netlify.toml` file in the root directory contains the build configuration.
190
+
191
+ **Steps to Deploy:**
192
+
193
+ 1. **Push your code to GitHub** (if not already):
194
+ ```bash
195
+ git add .
196
+ git commit -m "Prepare for Netlify deployment"
197
+ git push origin main
198
+ ```
199
+
200
+ 2. **Connect to Netlify**:
201
+ - Go to [Netlify](https://app.netlify.com)
202
+ - Click "Add new site" → "Import an existing project"
203
+ - Connect your GitHub repository
204
+ - Netlify will auto-detect the `netlify.toml` configuration
205
+
206
+ 3. **Configure Environment Variables**:
207
+ - In Netlify dashboard, go to Site settings → Environment variables
208
+ - Add `REACT_APP_API_URL` with your backend URL (e.g., `https://your-backend.railway.app`)
209
+ - If using Hugging Face API, add `REACT_APP_HF_TOKEN` (optional)
210
+
211
+ 4. **Deploy Backend Separately**:
212
+ - Netlify doesn't support Python/FastAPI backends
213
+ - Deploy backend to one of these services:
214
+ - **Railway**: Recommended, easy setup
215
+ - **Render**: Free tier available
216
+ - **Fly.io**: Good for Python apps
217
+ - **Heroku**: Paid option
218
+ - Update CORS in `backend/api/main.py` to include your Netlify URL
219
+
220
+ 5. **Build Settings** (auto-detected from `netlify.toml`):
221
+ - Base directory: `frontend`
222
+ - Build command: `npm install && npm run build`
223
+ - Publish directory: `frontend/build`
224
+ - Node version: 18
225
+
226
+ **Backend Deployment (Railway Example):**
227
+
228
+ 1. Create a new project on [Railway](https://railway.app)
229
+ 2. Connect your GitHub repository
230
+ 3. Set root directory to `backend`
231
+ 4. Railway will auto-detect Python and install dependencies
232
+ 5. Add environment variables if needed (HF_TOKEN, etc.)
233
+ 6. Railway will provide a URL like `https://your-app.railway.app`
234
+ 7. Use this URL as `REACT_APP_API_URL` in Netlify
235
+
236
+ **CORS Configuration:**
237
+
238
+ Update `backend/api/main.py` to allow your Netlify domain:
239
+ ```python
240
+ from fastapi.middleware.cors import CORSMiddleware
241
+
242
+ app.add_middleware(
243
+ CORSMiddleware,
244
+ allow_origins=[
245
+ "http://localhost:3000", # Local development
246
+ "https://your-site.netlify.app", # Your Netlify URL
247
+ ],
248
+ allow_credentials=True,
249
+ allow_methods=["*"],
250
+ allow_headers=["*"],
251
+ )
252
+ ```
253
 
254
  ## Architecture
255
 
 
289
  ## Performance Notes
290
 
291
  - **Full Dataset**: Loads all ~1.86 million models from the dataset
292
+ - **Backend Sampling**: Requests up to 500,000 models from backend (configurable via `max_points` API parameter)
293
+ - **Frontend Rendering**:
294
+ - For datasets >400K: Shows 30% of models (up to 200K visible)
295
+ - For datasets 200K-400K: Shows 40% of models
296
+ - For datasets 100K-200K: Shows 50% of models
297
+ - For smaller datasets: Shows all models with adaptive spatial sparsity
298
+ - Uses instanced rendering for datasets >5K points
299
+ - Camera-based frustum culling and adaptive LOD for optimal performance
300
  - **Embedding Model**: `all-MiniLM-L6-v2` (good balance of quality and speed)
301
  - **Caching**: Embeddings and reduced dimensions are cached to disk for fast startup
302
  - **Optimizations**: Index-based lookups, vectorized operations, response compression, and optimized top-k queries
frontend/src/App.tsx CHANGED
@@ -59,6 +59,12 @@ function App() {
59
  const [hoveredModel, setHoveredModel] = useState<ModelPoint | null>(null);
60
  const [tooltipPosition, setTooltipPosition] = useState<{ x: number; y: number } | null>(null);
61
 
 
 
 
 
 
 
62
  const activeFilterCount = (minDownloads > 0 ? 1 : 0) +
63
  (minLikes > 0 ? 1 : 0) +
64
  (searchQuery.length > 0 ? 1 : 0);
@@ -137,6 +143,11 @@ function App() {
137
  if (searchQuery) {
138
  params.append('search_query', searchQuery);
139
  }
 
 
 
 
 
140
 
141
  const url = `${API_BASE}/api/models?${params}`;
142
  const response = await requestManager.fetch(url, {}, cacheKey);
@@ -346,7 +357,8 @@ function App() {
346
  }, []);
347
 
348
  return (
349
- <div className="App">
 
350
  <header className="App-header">
351
  <h1>Anatomy of a Machine Learning Ecosystem: 2 Million Models on Hugging Face</h1>
352
  <p style={{ maxWidth: '900px', margin: '0 auto', lineHeight: '1.6' }}>
@@ -696,6 +708,78 @@ function App() {
696
  )}
697
  </div>
698
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
699
  {/* Quick Filters */}
700
  <div className="sidebar-section">
701
  <h3>Quick Filters</h3>
@@ -967,6 +1051,10 @@ function App() {
967
  sizeBy={sizeBy}
968
  colorScheme={colorScheme}
969
  showLegend={showLegend}
 
 
 
 
970
  onPointClick={(model) => {
971
  setSelectedModel(model);
972
  setIsModalOpen(true);
 
59
  const [hoveredModel, setHoveredModel] = useState<ModelPoint | null>(null);
60
  const [tooltipPosition, setTooltipPosition] = useState<{ x: number; y: number } | null>(null);
61
 
62
+ // Structural visualization options
63
+ const [showNetworkEdges, setShowNetworkEdges] = useState(false);
64
+ const [showStructuralGroups, setShowStructuralGroups] = useState(false);
65
+ const [overviewMode, setOverviewMode] = useState(false);
66
+ const [networkEdgeType, setNetworkEdgeType] = useState<'library' | 'pipeline' | 'combined'>('combined');
67
+
68
  const activeFilterCount = (minDownloads > 0 ? 1 : 0) +
69
  (minLikes > 0 ? 1 : 0) +
70
  (searchQuery.length > 0 ? 1 : 0);
 
143
  if (searchQuery) {
144
  params.append('search_query', searchQuery);
145
  }
146
+
147
+ // Request a large number of models for better representation
148
+ // The backend will use stratified sampling if needed, and frontend will further optimize
149
+ // Set to 500K to get good coverage while allowing backend to optimize
150
+ params.append('max_points', '500000');
151
 
152
  const url = `${API_BASE}/api/models?${params}`;
153
  const response = await requestManager.fetch(url, {}, cacheKey);
 
357
  }, []);
358
 
359
  return (
360
+ <ErrorBoundary>
361
+ <div className="App">
362
  <header className="App-header">
363
  <h1>Anatomy of a Machine Learning Ecosystem: 2 Million Models on Hugging Face</h1>
364
  <p style={{ maxWidth: '900px', margin: '0 auto', lineHeight: '1.6' }}>
 
708
  )}
709
  </div>
710
 
711
+ {/* Structural Visualization Options */}
712
+ {viewMode === '3d' && (
713
+ <div className="sidebar-section" style={{ background: '#f0f8f0', borderColor: '#90ee90' }}>
714
+ <h3>🔗 Structural Visualization</h3>
715
+ <div style={{ fontSize: '0.75rem', color: '#666', marginBottom: '1rem', lineHeight: '1.4' }}>
716
+ Explore relationships and structure in the model ecosystem
717
+ </div>
718
+
719
+ <label style={{ marginBottom: '1rem', display: 'flex', alignItems: 'center', cursor: 'pointer' }}>
720
+ <input
721
+ type="checkbox"
722
+ checked={overviewMode}
723
+ onChange={(e) => setOverviewMode(e.target.checked)}
724
+ style={{ marginRight: '0.5rem', cursor: 'pointer' }}
725
+ />
726
+ <div>
727
+ <span style={{ fontWeight: '500' }}>🔍 Overview Mode</span>
728
+ <div style={{ fontSize: '0.75rem', color: '#666', marginTop: '0.25rem' }}>
729
+ Zoom out to see full ecosystem structure with all relationships visible. Camera will automatically adjust.
730
+ </div>
731
+ </div>
732
+ </label>
733
+
734
+ <label style={{ marginBottom: '1rem', display: 'flex', alignItems: 'center', cursor: 'pointer' }}>
735
+ <input
736
+ type="checkbox"
737
+ checked={showNetworkEdges}
738
+ onChange={(e) => setShowNetworkEdges(e.target.checked)}
739
+ style={{ marginRight: '0.5rem', cursor: 'pointer' }}
740
+ />
741
+ <div>
742
+ <span style={{ fontWeight: '500' }}>🌐 Network Relationships</span>
743
+ <div style={{ fontSize: '0.75rem', color: '#666', marginTop: '0.25rem' }}>
744
+ Show connections between related models (same library, pipeline, or tags). Blue = library, Pink = pipeline.
745
+ </div>
746
+ </div>
747
+ </label>
748
+
749
+ {showNetworkEdges && (
750
+ <div style={{ marginLeft: '1.5rem', marginBottom: '1rem', padding: '0.75rem', background: 'white', borderRadius: '4px', border: '1px solid #d0d0d0' }}>
751
+ <label style={{ fontWeight: '500', display: 'block', marginBottom: '0.5rem', fontSize: '0.9rem' }}>
752
+ Connection Type
753
+ </label>
754
+ <select
755
+ value={networkEdgeType}
756
+ onChange={(e) => setNetworkEdgeType(e.target.value as 'library' | 'pipeline' | 'combined')}
757
+ style={{ width: '100%', padding: '0.5rem', borderRadius: '4px', border: '1px solid #d0d0d0', fontSize: '0.85rem' }}
758
+ >
759
+ <option value="combined">Combined (library + pipeline + tags)</option>
760
+ <option value="library">Library Only</option>
761
+ <option value="pipeline">Pipeline Only</option>
762
+ </select>
763
+ </div>
764
+ )}
765
+
766
+ <label style={{ marginBottom: '1rem', display: 'flex', alignItems: 'center', cursor: 'pointer' }}>
767
+ <input
768
+ type="checkbox"
769
+ checked={showStructuralGroups}
770
+ onChange={(e) => setShowStructuralGroups(e.target.checked)}
771
+ style={{ marginRight: '0.5rem', cursor: 'pointer' }}
772
+ />
773
+ <div>
774
+ <span style={{ fontWeight: '500' }}>📦 Structural Groupings</span>
775
+ <div style={{ fontSize: '0.75rem', color: '#666', marginTop: '0.25rem' }}>
776
+ Highlight clusters and groups with wireframe boundaries. Shows top library and pipeline clusters.
777
+ </div>
778
+ </div>
779
+ </label>
780
+ </div>
781
+ )}
782
+
783
  {/* Quick Filters */}
784
  <div className="sidebar-section">
785
  <h3>Quick Filters</h3>
 
1051
  sizeBy={sizeBy}
1052
  colorScheme={colorScheme}
1053
  showLegend={showLegend}
1054
+ showNetworkEdges={showNetworkEdges}
1055
+ showStructuralGroups={showStructuralGroups}
1056
+ overviewMode={overviewMode}
1057
+ networkEdgeType={networkEdgeType}
1058
  onPointClick={(model) => {
1059
  setSelectedModel(model);
1060
  setIsModalOpen(true);
frontend/src/components/ScatterPlot3D.tsx CHANGED
@@ -24,6 +24,10 @@ interface ScatterPlot3DProps {
24
  sizeBy: string;
25
  colorScheme?: 'viridis' | 'plasma' | 'inferno' | 'magma' | 'coolwarm';
26
  showLegend?: boolean;
 
 
 
 
27
  onPointClick?: (model: ModelPoint) => void;
28
  selectedModelId?: string | null;
29
  onViewChange?: (center: { x: number; y: number; z: number }) => void;
@@ -78,9 +82,9 @@ const Point = memo(function Point({ position, color, size, model, isSelected, is
78
  meshRef.current.material.opacity = baseOpacity * distanceFactor;
79
  }
80
 
81
- // Subtle animation for selected/family members
82
- if (isSelected || isFamilyMember) {
83
- meshRef.current.rotation.y += 0.01;
84
  }
85
 
86
  // Glow effect for selected/hovered points
@@ -132,23 +136,23 @@ const Point = memo(function Point({ position, color, size, model, isSelected, is
132
  )}
133
 
134
  {/* Main point */}
135
- <mesh
136
- ref={meshRef}
137
- position={position}
138
- onClick={onClick}
139
  onPointerOver={(e) => {
140
- setHovered(true);
141
  if (onHover) {
142
  onHover(model, {
143
  x: e.clientX,
144
  y: e.clientY
145
  });
146
  }
147
- }}
148
- onPointerOut={() => {
149
- setHovered(false);
150
- if (onHover) onHover(null);
151
- }}
152
  onPointerMove={(e) => {
153
  if (hovered && onHover) {
154
  onHover(model, {
@@ -160,16 +164,16 @@ const Point = memo(function Point({ position, color, size, model, isSelected, is
160
  frustumCulled={true}
161
  >
162
  <sphereGeometry args={[0.02, 12, 12]} />
163
- <meshStandardMaterial
164
- color={isSelected ? '#ffffff' : isFamilyMember ? '#4a4a4a' : color}
165
- emissive={isSelected ? '#ffffff' : isFamilyMember ? '#6a6a6a' : '#000000'}
166
  emissiveIntensity={isSelected ? 0.6 : isFamilyMember ? 0.2 : 0}
167
  metalness={0.3}
168
  roughness={0.7}
169
  opacity={0.9}
170
- transparent
171
- />
172
- </mesh>
173
  </group>
174
  );
175
  }, (prevProps, nextProps) => {
@@ -235,11 +239,11 @@ function FamilyEdge({ start, end, parentColor, childColor, depth }: FamilyEdgePr
235
  return (
236
  <group>
237
  {/* Main edge with interpolated color - thicker and more visible */}
238
- <Line
239
- points={points}
240
  color={interpolatedColor}
241
  lineWidth={lineWidth}
242
- dashed={false}
243
  transparent
244
  opacity={opacity}
245
  />
@@ -264,6 +268,10 @@ const SceneContent = memo(function SceneContent({
264
  colorBy,
265
  sizeBy,
266
  colorScheme = 'viridis',
 
 
 
 
267
  onPointClick,
268
  selectedModelId,
269
  onHover,
@@ -320,35 +328,44 @@ const SceneContent = memo(function SceneContent({
320
  if (data.length > 10000 && spatialIndex && camera && gl) {
321
  // Use adaptive sampling based on distance from camera
322
  // When moving fast, reduce quality for better performance
323
- const qualityFactor = isInteracting && movementSpeedRef.current > 0.01 ? 0.6 : 1.0;
324
- const maxDistance = 15 * qualityFactor; // Increased view distance
325
 
326
- // Sample based on distance - more aggressive for very large datasets
 
327
  let distanceSampled: ModelPoint[];
328
- if (others.length > 200000) {
329
- // For extremely large datasets (>200K), use more aggressive sampling for sparsity
330
- const sampleRate = qualityFactor * 0.15; // Sample 15% when not interacting (was 30%)
 
 
 
 
 
 
 
 
331
  const step = Math.ceil(1 / sampleRate);
332
  distanceSampled = [];
333
  for (let i = 0; i < others.length; i += step) {
334
  distanceSampled.push(others[i]);
335
  }
336
  } else if (others.length > 100000) {
337
- // For large datasets (100K-200K), sample 20%
338
- const sampleRate = qualityFactor * 0.2;
339
  const step = Math.ceil(1 / sampleRate);
340
  distanceSampled = [];
341
  for (let i = 0; i < others.length; i += step) {
342
  distanceSampled.push(others[i]);
343
  }
344
  } else {
345
- // Use adaptive sampling with reduced rate for more sparsity
346
- distanceSampled = adaptiveSampleByDistance(others, camera, qualityFactor * 0.7, maxDistance);
347
  }
348
 
349
  // Apply frustum culling if camera is available
350
- // Increased limit for instanced rendering (can handle more)
351
- const maxVisible = Math.min(distanceSampled.length, 100000); // Increased from 20K to 100K
352
  let visible: ModelPoint[];
353
  try {
354
  visible = filterVisiblePoints(
@@ -356,7 +373,7 @@ const SceneContent = memo(function SceneContent({
356
  camera,
357
  gl,
358
  maxDistance,
359
- 0.02 // Lower LOD threshold to show more points
360
  );
361
  } catch (e) {
362
  // Fallback if frustum calculation fails
@@ -364,11 +381,12 @@ const SceneContent = memo(function SceneContent({
364
  }
365
 
366
  // Apply spatial sparsity to reduce density and improve navigability
 
367
  const combined = [...important, ...visible];
368
- if (combined.length > 3000) { // Lower threshold for sparsity application
369
  // Calculate adaptive minimum distance based on data spread
370
  const avgDistance = calculateAverageDistance(combined);
371
- const sparsityFactor = getAdaptiveSparsityFactor(combined.length) * 1.5; // Increase sparsity by 50%
372
  const minDistance = avgDistance * sparsityFactor;
373
 
374
  if (minDistance > 0) {
@@ -380,13 +398,14 @@ const SceneContent = memo(function SceneContent({
380
  }
381
 
382
  // For smaller datasets, use simple sampling with sparsity
383
- // Reduced render limit for better sparsity and navigability
384
- const renderLimit = data.length > 100000 ? 100000 : data.length; // Reduced from 200K to 100K
385
  if (data.length <= renderLimit) {
386
  // Still apply sparsity even if under limit for better navigability
387
- if (data.length > 3000) {
 
388
  const avgDistance = calculateAverageDistance(data);
389
- const sparsityFactor = getAdaptiveSparsityFactor(data.length) * 1.5;
390
  const minDistance = avgDistance * sparsityFactor;
391
  if (minDistance > 0) {
392
  return applySpatialSparsity(data, minDistance, importantIds);
@@ -432,7 +451,7 @@ const SceneContent = memo(function SceneContent({
432
  colorScheme: string;
433
  scales: any;
434
  } | null>(null);
435
-
436
  const { xScale, yScale, zScale, colorScale, sizeScale, familyMap } = useMemo(() => {
437
  // Return cached scales if inputs haven't changed
438
  if (scalesCacheRef.current &&
@@ -651,6 +670,193 @@ const SceneContent = memo(function SceneContent({
651
  return edges;
652
  }, [familyTree, xScale, yScale, zScale, colorScheme]);
653
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
654
  return (
655
  <>
656
  <ambientLight intensity={0.5} />
@@ -660,6 +866,64 @@ const SceneContent = memo(function SceneContent({
660
  {/* Grid for orientation - using custom grid to avoid deprecation warnings */}
661
  <gridHelper args={[10, 10, '#6a6a6a', '#4a4a4a']} />
662
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
663
  {/* Family tree edges with gradient and animation */}
664
  {familyEdges.map((edge, i) => (
665
  <FamilyEdge
@@ -690,22 +954,22 @@ const SceneContent = memo(function SceneContent({
690
  />
691
  ) : (
692
  sampledData.map((model) => {
693
- const isFamilyMember = familyMap.has(model.model_id);
694
- const isSelected = selectedModelId === model.model_id;
695
-
696
- return (
697
- <Point
698
- key={model.model_id}
699
- position={[xScale(model.x), yScale(model.y), zScale(model.z)]}
700
- color={colorScale(model)}
701
- size={sizeScale(model)}
702
- model={model}
703
- isSelected={isSelected}
704
- isFamilyMember={isFamilyMember}
705
- onClick={() => onPointClick?.(model)}
706
- onHover={onHover}
707
- />
708
- );
709
  })
710
  )}
711
 
@@ -722,6 +986,10 @@ const SceneContent = memo(function SceneContent({
722
  prevProps.colorScheme === nextProps.colorScheme &&
723
  prevProps.selectedModelId === nextProps.selectedModelId &&
724
  prevProps.isInteracting === nextProps.isInteracting &&
 
 
 
 
725
  (prevProps.familyTree?.length || 0) === (nextProps.familyTree?.length || 0)
726
  );
727
  });
@@ -781,6 +1049,10 @@ export default function ScatterPlot3D({
781
  sizeBy,
782
  colorScheme = 'viridis',
783
  showLegend = true,
 
 
 
 
784
  onPointClick,
785
  selectedModelId,
786
  onViewChange,
@@ -891,7 +1163,7 @@ export default function ScatterPlot3D({
891
  enableZoom={true}
892
  enableRotate={true}
893
  minDistance={1}
894
- maxDistance={10}
895
  enableDamping={true}
896
  dampingFactor={0.05}
897
  />
@@ -902,6 +1174,10 @@ export default function ScatterPlot3D({
902
  colorBy={colorBy}
903
  sizeBy={sizeBy}
904
  colorScheme={colorScheme}
 
 
 
 
905
  onPointClick={onPointClick}
906
  selectedModelId={selectedModelId}
907
  onHover={onHover}
@@ -924,6 +1200,37 @@ export default function ScatterPlot3D({
924
  >
925
  <strong>3D Navigation:</strong> Click + drag to rotate | Scroll to zoom | Right-click + drag to pan
926
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
927
  </div>
928
  );
929
  }
 
24
  sizeBy: string;
25
  colorScheme?: 'viridis' | 'plasma' | 'inferno' | 'magma' | 'coolwarm';
26
  showLegend?: boolean;
27
+ showNetworkEdges?: boolean;
28
+ showStructuralGroups?: boolean;
29
+ overviewMode?: boolean;
30
+ networkEdgeType?: 'library' | 'pipeline' | 'combined';
31
  onPointClick?: (model: ModelPoint) => void;
32
  selectedModelId?: string | null;
33
  onViewChange?: (center: { x: number; y: number; z: number }) => void;
 
82
  meshRef.current.material.opacity = baseOpacity * distanceFactor;
83
  }
84
 
85
+ // Subtle animation for selected/family members
86
+ if (isSelected || isFamilyMember) {
87
+ meshRef.current.rotation.y += 0.01;
88
  }
89
 
90
  // Glow effect for selected/hovered points
 
136
  )}
137
 
138
  {/* Main point */}
139
+ <mesh
140
+ ref={meshRef}
141
+ position={position}
142
+ onClick={onClick}
143
  onPointerOver={(e) => {
144
+ setHovered(true);
145
  if (onHover) {
146
  onHover(model, {
147
  x: e.clientX,
148
  y: e.clientY
149
  });
150
  }
151
+ }}
152
+ onPointerOut={() => {
153
+ setHovered(false);
154
+ if (onHover) onHover(null);
155
+ }}
156
  onPointerMove={(e) => {
157
  if (hovered && onHover) {
158
  onHover(model, {
 
164
  frustumCulled={true}
165
  >
166
  <sphereGeometry args={[0.02, 12, 12]} />
167
+ <meshStandardMaterial
168
+ color={isSelected ? '#ffffff' : isFamilyMember ? '#4a4a4a' : color}
169
+ emissive={isSelected ? '#ffffff' : isFamilyMember ? '#6a6a6a' : '#000000'}
170
  emissiveIntensity={isSelected ? 0.6 : isFamilyMember ? 0.2 : 0}
171
  metalness={0.3}
172
  roughness={0.7}
173
  opacity={0.9}
174
+ transparent
175
+ />
176
+ </mesh>
177
  </group>
178
  );
179
  }, (prevProps, nextProps) => {
 
239
  return (
240
  <group>
241
  {/* Main edge with interpolated color - thicker and more visible */}
242
+ <Line
243
+ points={points}
244
  color={interpolatedColor}
245
  lineWidth={lineWidth}
246
+ dashed={false}
247
  transparent
248
  opacity={opacity}
249
  />
 
268
  colorBy,
269
  sizeBy,
270
  colorScheme = 'viridis',
271
+ showNetworkEdges = false,
272
+ showStructuralGroups = false,
273
+ overviewMode = false,
274
+ networkEdgeType = 'combined',
275
  onPointClick,
276
  selectedModelId,
277
  onHover,
 
328
  if (data.length > 10000 && spatialIndex && camera && gl) {
329
  // Use adaptive sampling based on distance from camera
330
  // When moving fast, reduce quality for better performance
331
+ const qualityFactor = isInteracting && movementSpeedRef.current > 0.01 ? 0.7 : 1.0; // Increased from 0.6
332
+ const maxDistance = 20 * qualityFactor; // Increased view distance from 15 to 20
333
 
334
+ // Improved sampling strategy to show more models while maintaining performance
335
+ // Use higher sample rates to better represent the full dataset
336
  let distanceSampled: ModelPoint[];
337
+ if (others.length > 400000) {
338
+ // For extremely large datasets (>400K), sample 30% (increased from 15%)
339
+ const sampleRate = qualityFactor * 0.3;
340
+ const step = Math.ceil(1 / sampleRate);
341
+ distanceSampled = [];
342
+ for (let i = 0; i < others.length; i += step) {
343
+ distanceSampled.push(others[i]);
344
+ }
345
+ } else if (others.length > 200000) {
346
+ // For very large datasets (200K-400K), sample 40% (increased from 15%)
347
+ const sampleRate = qualityFactor * 0.4;
348
  const step = Math.ceil(1 / sampleRate);
349
  distanceSampled = [];
350
  for (let i = 0; i < others.length; i += step) {
351
  distanceSampled.push(others[i]);
352
  }
353
  } else if (others.length > 100000) {
354
+ // For large datasets (100K-200K), sample 50% (increased from 20%)
355
+ const sampleRate = qualityFactor * 0.5;
356
  const step = Math.ceil(1 / sampleRate);
357
  distanceSampled = [];
358
  for (let i = 0; i < others.length; i += step) {
359
  distanceSampled.push(others[i]);
360
  }
361
  } else {
362
+ // Use adaptive sampling with higher rate for better representation
363
+ distanceSampled = adaptiveSampleByDistance(others, camera, qualityFactor * 0.85, maxDistance); // Increased from 0.7
364
  }
365
 
366
  // Apply frustum culling if camera is available
367
+ // Increased limit for instanced rendering (can handle more with better sampling)
368
+ const maxVisible = Math.min(distanceSampled.length, 200000); // Increased from 100K to 200K
369
  let visible: ModelPoint[];
370
  try {
371
  visible = filterVisiblePoints(
 
373
  camera,
374
  gl,
375
  maxDistance,
376
+ 0.01 // Lower LOD threshold to show more points (was 0.02)
377
  );
378
  } catch (e) {
379
  // Fallback if frustum calculation fails
 
381
  }
382
 
383
  // Apply spatial sparsity to reduce density and improve navigability
384
+ // But be less aggressive to show more models
385
  const combined = [...important, ...visible];
386
+ if (combined.length > 5000) { // Increased threshold from 3000
387
  // Calculate adaptive minimum distance based on data spread
388
  const avgDistance = calculateAverageDistance(combined);
389
+ const sparsityFactor = getAdaptiveSparsityFactor(combined.length) * 1.2; // Reduced from 1.5 to show more
390
  const minDistance = avgDistance * sparsityFactor;
391
 
392
  if (minDistance > 0) {
 
398
  }
399
 
400
  // For smaller datasets, use simple sampling with sparsity
401
+ // Increased render limit to show more models
402
+ const renderLimit = data.length > 200000 ? 200000 : data.length; // Increased from 100K to 200K
403
  if (data.length <= renderLimit) {
404
  // Still apply sparsity even if under limit for better navigability
405
+ // But be less aggressive to show more models
406
+ if (data.length > 5000) { // Increased threshold from 3000
407
  const avgDistance = calculateAverageDistance(data);
408
+ const sparsityFactor = getAdaptiveSparsityFactor(data.length) * 1.2; // Reduced from 1.5
409
  const minDistance = avgDistance * sparsityFactor;
410
  if (minDistance > 0) {
411
  return applySpatialSparsity(data, minDistance, importantIds);
 
451
  colorScheme: string;
452
  scales: any;
453
  } | null>(null);
454
+
455
  const { xScale, yScale, zScale, colorScale, sizeScale, familyMap } = useMemo(() => {
456
  // Return cached scales if inputs haven't changed
457
  if (scalesCacheRef.current &&
 
670
  return edges;
671
  }, [familyTree, xScale, yScale, zScale, colorScheme]);
672
 
673
+ // Build network edges (co-occurrence relationships)
674
+ const networkEdges = useMemo(() => {
675
+ if (!showNetworkEdges || sampledData.length === 0) return [];
676
+
677
+ const edges: Array<{
678
+ start: [number, number, number];
679
+ end: [number, number, number];
680
+ weight: number;
681
+ type: string;
682
+ }> = [];
683
+
684
+ // Create a map for quick lookups
685
+ const modelMap = new Map(sampledData.map(m => [m.model_id, m]));
686
+
687
+ // Group models by library, pipeline, or both
688
+ const groups = new Map<string, ModelPoint[]>();
689
+
690
+ sampledData.forEach(model => {
691
+ if (networkEdgeType === 'library' || networkEdgeType === 'combined') {
692
+ const key = `lib:${model.library_name || 'unknown'}`;
693
+ if (!groups.has(key)) groups.set(key, []);
694
+ groups.get(key)!.push(model);
695
+ }
696
+ if (networkEdgeType === 'pipeline' || networkEdgeType === 'combined') {
697
+ const key = `pipe:${model.pipeline_tag || 'unknown'}`;
698
+ if (!groups.has(key)) groups.set(key, []);
699
+ groups.get(key)!.push(model);
700
+ }
701
+ });
702
+
703
+ // Create edges between models in the same group
704
+ // Limit to avoid performance issues - only connect nearby models
705
+ const maxConnectionsPerModel = overviewMode ? 5 : 3;
706
+ const maxDistance = overviewMode ? 0.5 : 0.3; // Distance threshold in normalized space
707
+
708
+ groups.forEach((models, groupKey) => {
709
+ if (models.length < 2) return;
710
+
711
+ // For large groups, sample connections
712
+ const modelsToConnect = models.length > 50
713
+ ? models.filter((_, i) => i % Math.ceil(models.length / 50) === 0)
714
+ : models;
715
+
716
+ for (let i = 0; i < modelsToConnect.length; i++) {
717
+ const model1 = modelsToConnect[i];
718
+ let connections = 0;
719
+
720
+ for (let j = i + 1; j < modelsToConnect.length && connections < maxConnectionsPerModel; j++) {
721
+ const model2 = modelsToConnect[j];
722
+
723
+ // Calculate distance in normalized space
724
+ const dx = model1.x - model2.x;
725
+ const dy = model1.y - model2.y;
726
+ const dz = model1.z - model2.z;
727
+ const distance = Math.sqrt(dx * dx + dy * dy + dz * dz);
728
+
729
+ if (distance < maxDistance) {
730
+ edges.push({
731
+ start: [xScale(model1.x), yScale(model1.y), zScale(model1.z)],
732
+ end: [xScale(model2.x), yScale(model2.y), zScale(model2.z)],
733
+ weight: 1 - (distance / maxDistance), // Higher weight for closer models
734
+ type: groupKey.startsWith('lib:') ? 'library' : 'pipeline',
735
+ });
736
+ connections++;
737
+ }
738
+ }
739
+ }
740
+ });
741
+
742
+ return edges;
743
+ }, [showNetworkEdges, sampledData, networkEdgeType, overviewMode, xScale, yScale, zScale]);
744
+
745
+ // Build structural groupings (library/pipeline clusters)
746
+ const structuralGroups = useMemo(() => {
747
+ if (!showStructuralGroups || sampledData.length === 0) return [];
748
+
749
+ const groups: Array<{
750
+ models: ModelPoint[];
751
+ type: 'library' | 'pipeline';
752
+ name: string;
753
+ color: string;
754
+ center: [number, number, number];
755
+ }> = [];
756
+
757
+ // Group by library
758
+ const libraryGroups = new Map<string, ModelPoint[]>();
759
+ sampledData.forEach(model => {
760
+ const lib = model.library_name || 'unknown';
761
+ if (!libraryGroups.has(lib)) libraryGroups.set(lib, []);
762
+ libraryGroups.get(lib)!.push(model);
763
+ });
764
+
765
+ // Group by pipeline
766
+ const pipelineGroups = new Map<string, ModelPoint[]>();
767
+ sampledData.forEach(model => {
768
+ const pipe = model.pipeline_tag || 'unknown';
769
+ if (!pipelineGroups.has(pipe)) pipelineGroups.set(pipe, []);
770
+ pipelineGroups.get(pipe)!.push(model);
771
+ });
772
+
773
+ // Only show groups with multiple models and reasonable size
774
+ const minGroupSize = overviewMode ? 3 : 5;
775
+ const maxGroups = overviewMode ? 20 : 10;
776
+
777
+ // Process library groups
778
+ const sortedLibGroups = Array.from(libraryGroups.entries())
779
+ .filter(([_, models]) => models.length >= minGroupSize)
780
+ .sort((a, b) => b[1].length - a[1].length)
781
+ .slice(0, maxGroups);
782
+
783
+ sortedLibGroups.forEach(([name, models]) => {
784
+ // Calculate center
785
+ const centerX = models.reduce((sum, m) => sum + m.x, 0) / models.length;
786
+ const centerY = models.reduce((sum, m) => sum + m.y, 0) / models.length;
787
+ const centerZ = models.reduce((sum, m) => sum + m.z, 0) / models.length;
788
+
789
+ groups.push({
790
+ models,
791
+ type: 'library',
792
+ name,
793
+ color: '#4a90e2',
794
+ center: [xScale(centerX), yScale(centerY), zScale(centerZ)],
795
+ });
796
+ });
797
+
798
+ // Process pipeline groups
799
+ const sortedPipeGroups = Array.from(pipelineGroups.entries())
800
+ .filter(([_, models]) => models.length >= minGroupSize)
801
+ .sort((a, b) => b[1].length - a[1].length)
802
+ .slice(0, maxGroups);
803
+
804
+ sortedPipeGroups.forEach(([name, models]) => {
805
+ const centerX = models.reduce((sum, m) => sum + m.x, 0) / models.length;
806
+ const centerY = models.reduce((sum, m) => sum + m.y, 0) / models.length;
807
+ const centerZ = models.reduce((sum, m) => sum + m.z, 0) / models.length;
808
+
809
+ groups.push({
810
+ models,
811
+ type: 'pipeline',
812
+ name,
813
+ color: '#e24a90',
814
+ center: [xScale(centerX), yScale(centerY), zScale(centerZ)],
815
+ });
816
+ });
817
+
818
+ return groups;
819
+ }, [showStructuralGroups, sampledData, overviewMode, xScale, yScale, zScale]);
820
+
821
+ // Adjust camera for overview mode
822
+ useEffect(() => {
823
+ if (!camera) return;
824
+
825
+ const currentPos = new THREE.Vector3();
826
+ camera.getWorldPosition(currentPos);
827
+ const distance = currentPos.length();
828
+
829
+ if (overviewMode) {
830
+ // Move camera further back to see more of the scene
831
+ const newDistance = Math.max(distance, 8); // Minimum distance for overview
832
+
833
+ if (newDistance > distance) {
834
+ // Smoothly animate camera back
835
+ const targetPos = currentPos.clone().normalize().multiplyScalar(newDistance);
836
+ const startPos = currentPos.clone();
837
+ const duration = 1000; // 1 second
838
+ const startTime = Date.now();
839
+
840
+ const animate = () => {
841
+ const elapsed = Date.now() - startTime;
842
+ const progress = Math.min(elapsed / duration, 1);
843
+ const eased = 1 - Math.pow(1 - progress, 3); // Ease out cubic
844
+
845
+ const pos = startPos.clone().lerp(targetPos, eased);
846
+ camera.position.copy(pos);
847
+
848
+ if (progress < 1) {
849
+ requestAnimationFrame(animate);
850
+ }
851
+ };
852
+
853
+ animate();
854
+ }
855
+ }
856
+ // Note: When overview mode is disabled, user can manually zoom back in
857
+ // We don't force camera position to avoid disrupting user's navigation
858
+ }, [overviewMode, camera]);
859
+
860
  return (
861
  <>
862
  <ambientLight intensity={0.5} />
 
866
  {/* Grid for orientation - using custom grid to avoid deprecation warnings */}
867
  <gridHelper args={[10, 10, '#6a6a6a', '#4a4a4a']} />
868
 
869
+ {/* Network edges (co-occurrence relationships) */}
870
+ {networkEdges.length > 0 && (
871
+ <group>
872
+ {networkEdges.slice(0, overviewMode ? networkEdges.length : Math.min(networkEdges.length, 5000)).map((edge, i) => (
873
+ <Line
874
+ key={`network-${i}`}
875
+ points={[new THREE.Vector3(...edge.start), new THREE.Vector3(...edge.end)]}
876
+ color={edge.type === 'library' ? '#4a90e2' : '#e24a90'}
877
+ lineWidth={overviewMode ? 1.5 : 1}
878
+ transparent
879
+ opacity={overviewMode ? 0.2 * edge.weight : 0.3 * edge.weight}
880
+ dashed={false}
881
+ />
882
+ ))}
883
+ </group>
884
+ )}
885
+
886
+ {/* Structural groupings - show cluster centers and boundaries */}
887
+ {structuralGroups.map((group, i) => {
888
+ // Calculate bounding sphere radius from center
889
+ let maxRadius = 0;
890
+ group.models.forEach(model => {
891
+ const modelPos = [
892
+ xScale(model.x),
893
+ yScale(model.y),
894
+ zScale(model.z)
895
+ ];
896
+ const dx = modelPos[0] - group.center[0];
897
+ const dy = modelPos[1] - group.center[1];
898
+ const dz = modelPos[2] - group.center[2];
899
+ const distance = Math.sqrt(dx * dx + dy * dy + dz * dz);
900
+ maxRadius = Math.max(maxRadius, distance);
901
+ });
902
+
903
+ // Add some padding
904
+ const radius = Math.max(maxRadius * 1.2, 0.2);
905
+
906
+ return (
907
+ <group key={`group-${group.type}-${i}`}>
908
+ {/* Group center marker */}
909
+ <mesh position={group.center}>
910
+ <sphereGeometry args={[0.05, 8, 8]} />
911
+ <meshBasicMaterial color={group.color} transparent opacity={0.6} />
912
+ </mesh>
913
+ {/* Bounding sphere (wireframe) */}
914
+ <mesh position={group.center}>
915
+ <sphereGeometry args={[radius, 16, 16]} />
916
+ <meshBasicMaterial
917
+ color={group.color}
918
+ wireframe
919
+ transparent
920
+ opacity={0.15}
921
+ />
922
+ </mesh>
923
+ </group>
924
+ );
925
+ })}
926
+
927
  {/* Family tree edges with gradient and animation */}
928
  {familyEdges.map((edge, i) => (
929
  <FamilyEdge
 
954
  />
955
  ) : (
956
  sampledData.map((model) => {
957
+ const isFamilyMember = familyMap.has(model.model_id);
958
+ const isSelected = selectedModelId === model.model_id;
959
+
960
+ return (
961
+ <Point
962
+ key={model.model_id}
963
+ position={[xScale(model.x), yScale(model.y), zScale(model.z)]}
964
+ color={colorScale(model)}
965
+ size={sizeScale(model)}
966
+ model={model}
967
+ isSelected={isSelected}
968
+ isFamilyMember={isFamilyMember}
969
+ onClick={() => onPointClick?.(model)}
970
+ onHover={onHover}
971
+ />
972
+ );
973
  })
974
  )}
975
 
 
986
  prevProps.colorScheme === nextProps.colorScheme &&
987
  prevProps.selectedModelId === nextProps.selectedModelId &&
988
  prevProps.isInteracting === nextProps.isInteracting &&
989
+ prevProps.showNetworkEdges === nextProps.showNetworkEdges &&
990
+ prevProps.showStructuralGroups === nextProps.showStructuralGroups &&
991
+ prevProps.overviewMode === nextProps.overviewMode &&
992
+ prevProps.networkEdgeType === nextProps.networkEdgeType &&
993
  (prevProps.familyTree?.length || 0) === (nextProps.familyTree?.length || 0)
994
  );
995
  });
 
1049
  sizeBy,
1050
  colorScheme = 'viridis',
1051
  showLegend = true,
1052
+ showNetworkEdges = false,
1053
+ showStructuralGroups = false,
1054
+ overviewMode = false,
1055
+ networkEdgeType = 'combined',
1056
  onPointClick,
1057
  selectedModelId,
1058
  onViewChange,
 
1163
  enableZoom={true}
1164
  enableRotate={true}
1165
  minDistance={1}
1166
+ maxDistance={overviewMode ? 15 : 10}
1167
  enableDamping={true}
1168
  dampingFactor={0.05}
1169
  />
 
1174
  colorBy={colorBy}
1175
  sizeBy={sizeBy}
1176
  colorScheme={colorScheme}
1177
+ showNetworkEdges={showNetworkEdges}
1178
+ showStructuralGroups={showStructuralGroups}
1179
+ overviewMode={overviewMode}
1180
+ networkEdgeType={networkEdgeType}
1181
  onPointClick={onPointClick}
1182
  selectedModelId={selectedModelId}
1183
  onHover={onHover}
 
1200
  >
1201
  <strong>3D Navigation:</strong> Click + drag to rotate | Scroll to zoom | Right-click + drag to pan
1202
  </div>
1203
+ {(overviewMode || showNetworkEdges || showStructuralGroups) && (
1204
+ <div
1205
+ style={{
1206
+ position: 'absolute',
1207
+ top: 10,
1208
+ right: 10,
1209
+ fontSize: '11px',
1210
+ color: '#2c5f2d',
1211
+ backgroundColor: 'rgba(240, 248, 240, 0.95)',
1212
+ padding: '6px 10px',
1213
+ borderRadius: '4px',
1214
+ border: '1px solid #90ee90',
1215
+ fontFamily: "'Vend Sans', sans-serif",
1216
+ display: 'flex',
1217
+ flexDirection: 'column',
1218
+ gap: '4px',
1219
+ }}
1220
+ >
1221
+ {overviewMode && (
1222
+ <div style={{ fontWeight: '600' }}>🔍 Overview Mode Active</div>
1223
+ )}
1224
+ {showNetworkEdges && (
1225
+ <div style={{ fontSize: '10px' }}>
1226
+ Network: {networkEdgeType === 'combined' ? 'Library + Pipeline' : networkEdgeType}
1227
+ </div>
1228
+ )}
1229
+ {showStructuralGroups && (
1230
+ <div style={{ fontSize: '10px' }}>Structural Groups: Library & Pipeline clusters</div>
1231
+ )}
1232
+ </div>
1233
+ )}
1234
  </div>
1235
  );
1236
  }
netlify.toml CHANGED
@@ -5,23 +5,21 @@
5
 
6
  [build.environment]
7
  NODE_VERSION = "18"
 
 
8
 
9
- # Redirect all routes to index.html for React Router
10
  [[redirects]]
11
  from = "/*"
12
  to = "/index.html"
13
  status = 200
14
 
15
- # Netlify Functions (if using serverless backend)
16
- [functions]
17
- directory = "netlify-functions"
18
- node_bundler = "esbuild"
19
-
20
- # Headers for API routes
21
  [[headers]]
22
- for = "/.netlify/functions/*"
23
  [headers.values]
24
- Access-Control-Allow-Origin = "*"
25
- Access-Control-Allow-Headers = "Content-Type"
26
- Access-Control-Allow-Methods = "GET, POST, OPTIONS"
 
27
 
 
5
 
6
  [build.environment]
7
  NODE_VERSION = "18"
8
+ # Set this to your backend URL (Railway, Render, etc.)
9
+ # REACT_APP_API_URL = "https://your-backend-url.railway.app"
10
 
11
+ # Redirect all routes to index.html for React Router (SPA routing)
12
  [[redirects]]
13
  from = "/*"
14
  to = "/index.html"
15
  status = 200
16
 
17
+ # Security headers
 
 
 
 
 
18
  [[headers]]
19
+ for = "/*"
20
  [headers.values]
21
+ X-Frame-Options = "DENY"
22
+ X-XSS-Protection = "1; mode=block"
23
+ X-Content-Type-Options = "nosniff"
24
+ Referrer-Policy = "strict-origin-when-cross-origin"
25