Add Netlify deployment configuration and update README with deployment instructions
Browse files- README.md +72 -6
- frontend/src/App.tsx +89 -1
- frontend/src/components/ScatterPlot3D.tsx +366 -59
- netlify.toml +9 -11
README.md
CHANGED
|
@@ -186,10 +186,70 @@ The frontend will open at `http://localhost:3000`
|
|
| 186 |
|
| 187 |
### Netlify (React Frontend)
|
| 188 |
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
- **
|
| 233 |
-
- **
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
<
|
|
|
|
| 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 |
-
|
| 82 |
-
|
| 83 |
-
|
| 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 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
onPointerOver={(e) => {
|
| 140 |
-
|
| 141 |
if (onHover) {
|
| 142 |
onHover(model, {
|
| 143 |
x: e.clientX,
|
| 144 |
y: e.clientY
|
| 145 |
});
|
| 146 |
}
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 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 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
emissiveIntensity={isSelected ? 0.6 : isFamilyMember ? 0.2 : 0}
|
| 167 |
metalness={0.3}
|
| 168 |
roughness={0.7}
|
| 169 |
opacity={0.9}
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 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 |
-
|
| 239 |
-
|
| 240 |
color={interpolatedColor}
|
| 241 |
lineWidth={lineWidth}
|
| 242 |
-
|
| 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.
|
| 324 |
-
const maxDistance =
|
| 325 |
|
| 326 |
-
//
|
|
|
|
| 327 |
let distanceSampled: ModelPoint[];
|
| 328 |
-
if (others.length >
|
| 329 |
-
// For extremely large datasets (>
|
| 330 |
-
const sampleRate = qualityFactor * 0.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 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
|
| 346 |
-
distanceSampled = adaptiveSampleByDistance(others, camera, qualityFactor * 0.
|
| 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,
|
| 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.
|
| 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 >
|
| 369 |
// Calculate adaptive minimum distance based on data spread
|
| 370 |
const avgDistance = calculateAverageDistance(combined);
|
| 371 |
-
const sparsityFactor = getAdaptiveSparsityFactor(combined.length) * 1.
|
| 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 |
-
//
|
| 384 |
-
const renderLimit = data.length >
|
| 385 |
if (data.length <= renderLimit) {
|
| 386 |
// Still apply sparsity even if under limit for better navigability
|
| 387 |
-
|
|
|
|
| 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 |
-
|
| 694 |
-
|
| 695 |
-
|
| 696 |
-
|
| 697 |
-
|
| 698 |
-
|
| 699 |
-
|
| 700 |
-
|
| 701 |
-
|
| 702 |
-
|
| 703 |
-
|
| 704 |
-
|
| 705 |
-
|
| 706 |
-
|
| 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 |
-
#
|
| 16 |
-
[functions]
|
| 17 |
-
directory = "netlify-functions"
|
| 18 |
-
node_bundler = "esbuild"
|
| 19 |
-
|
| 20 |
-
# Headers for API routes
|
| 21 |
[[headers]]
|
| 22 |
-
for = "
|
| 23 |
[headers.values]
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
|
|
|
| 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 |
|