From 13b6adba538f5c64c60c80c55f2a325053371b62 Mon Sep 17 00:00:00 2001 From: Weiyu-Kong <1625827540@qq.com> Date: Tue, 10 Feb 2026 22:22:20 +0800 Subject: [PATCH 01/13] chore: update requirements.txt to remove version constraints --- requirements.txt | 67 ++++++++++++++++++++++++------------------------ 1 file changed, 34 insertions(+), 33 deletions(-) diff --git a/requirements.txt b/requirements.txt index ef1b1207..094a68e9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,33 +1,34 @@ -cycler==0.11.0 -Flask==2.2.5 -Flask_Cors==4.0.0 -fonttools==4.38.0 -importlib-metadata==6.7.0 -Jinja2==3.1.2 -joblib==1.3.2 -kiwisolver==1.4.5 -kmapper==2.0.1 -llvmlite==0.39.1 -MarkupSafe==2.1.3 -matplotlib==3.5.2 -networkx==2.6.3 -numba==0.56.4 -numpy==1.21.6 -packaging==23.2 -pandas==1.3.5 -Pillow==9.2.0 -pymilvus==2.1.0 -pynndescent==0.5.11 -pyparsing==3.1.1 -python-dateutil==2.8.2 -scikit-learn==1.0.2 -scipy==1.7.3 -six==1.16.0 -tensorflow==2.7.0 -threadpoolctl==3.1.0 -torch==1.13.1 -tqdm==4.66.1 -typing_extensions==4.7.1 -umap==0.1.1 -umap-learn==0.5.3 -zipp==3.15.0 \ No newline at end of file +cycler +Flask +Flask_Cors +fonttools +importlib-metadata +Jinja2 +joblib +kiwisolver +kmapper +llvmlite +MarkupSafe +matplotlib +networkx +numba +numpy +packaging +pandas +Pillow +pymilvus +pynndescent +pyparsing +python-dateutil +scikit-learn +scipy +six +tensorflow +threadpoolctl +torch +torchvision +transformers +tqdm +typing_extensions +umap-learn +zipp \ No newline at end of file From 5bfb2af50bb9955c97b2c2624b36009390e60352 Mon Sep 17 00:00:00 2001 From: Ignacia Baeza Date: Mon, 16 Feb 2026 17:23:31 -0300 Subject: [PATCH 02/13] time travelling visualizer task --- .vscode/launch.json | 2 +- docs/README.md | 380 +++++++++++++++++++++++++ tool/benchmark.py | 244 ++++++++++++++++ tool/server/server.py | 428 +++++++++++++++++------------ tool/server/server_utils.py | 55 ++++ web/package-lock.json | 419 ++++++++++++++++++---------- web/package.json | 2 +- web/src/communication/backend.ts | 19 ++ web/src/component/chart.tsx | 43 ++- web/src/component/main-block.tsx | 36 ++- web/src/component/sample-panel.tsx | 12 +- web/src/state/state.unified.ts | 17 +- web/src/views/plotView.tsx | 113 ++++---- 13 files changed, 1383 insertions(+), 387 deletions(-) create mode 100644 docs/README.md create mode 100644 tool/benchmark.py diff --git a/.vscode/launch.json b/.vscode/launch.json index 66ccb356..8f5b8302 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -25,7 +25,7 @@ "type": "extensionHost", "request": "launch", "args": [ - "--extensionDevelopmentPath=${workspaceFolder}", + "--extensionDevelopmentPath=${workspaceFolder}/extension", "--disable-extensions", ], "outFiles": [ diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 00000000..28c3a6fa --- /dev/null +++ b/docs/README.md @@ -0,0 +1,380 @@ +# Performance Optimization: Load Visualization + +This document describes the performance optimizations made to the "Load Visualization" flow in the Time Travelling Visualizer. The goal was to reduce the loading time when a user clicks "Load Result" in the VS Code extension, which was extremely slow for large datasets. + + +## Context + +When a user clicked "Load Result", the system loaded data for every epoch sequentially. For each epoch, it: + +1. Fetched the 2D projection coordinates +2. Fetched the model's predictions +3. Fetched the background image +4. **Computed k-nearest neighbors (k-NN) for ALL points in high-dimensional space** +5. **Computed k-nearest neighbors for ALL points in projection space** + +Steps 4 and 5 were the bottleneck. For a dataset with N=5000 points and 20 epochs: +- Each k-NN call computes distances between all pairs of points — O(N²) per epoch +- This ran sequentially: 20 epochs × 2 neighbor types × O(N²) = extremely slow +- Most of this work was wasted — users typically only hover over a handful of points + +Additionally, the epochs loaded one at a time (sequential), meaning each epoch had to finish before the next could start. + +--- + +## Architecture Context + +The project has three layers: + +1. **Extension** (`extension/src/`): Hosts the webview, sends commands like `loadVisualization` +2. **Web Frontend** (`web/src/`): React app that renders the scatter plot, handles user interaction +3. **Backend** (`tool/server/`): Flask API that reads data from disk and performs computations like k-NN + +The "Load Visualization" flow: +1. User clicks "Load Result" in VS Code +2. Extension sends `loadVisualization` message to the webview +3. `plotView.tsx` receives it, makes HTTP calls to the Flask backend for each epoch +4. Data is stored in the Zustand global store (`state.unified.ts`) +5. `chart.tsx` renders the scatter plot from the store data + +--- + +## Solution + +### Approach 1: Lazy Neighbor Loading + +**Concept**: Previously the neighbors where calculated for every point of every epoch before the visualization started. The lazy loading approach calculates the neighbors only when a point is hovered + +#### 1.1 — Removed neighbors from EpochData type + +**File**: `web/src/state/state.unified.ts` + +The `EpochData` type originally contained `originalNeighbors` and `projectionNeighbors` arrays (one entry per point per epoch). These were commented out since neighbors are no longer loaded per epoch: + +```typescript +export type EpochData = { + projection: number[][]; + prediction: number[]; + predProbability: number[][]; + // originalNeighbors: number[][]; ← removed + // projectionNeighbors: number[][]; ← removed + background: string; +}; +``` + +#### 1.2 — Added `neighborCache` and `visId` to global store + +**File**: `web/src/state/state.unified.ts` + +Two new fields were added to `BaseMutableGlobalStore`: + +- **`visId: string`** — Stores the visualization ID globally so its possible to make api calls outside plotView. + +- **`neighborCache** — A dictionary with keys `"epoch-pointIndex"` to save already fetched points. + + + +#### 1.3 — On-demand neighbor fetching in chart.tsx + +**File**: `web/src/component/chart.tsx` + +A new `useEffect` hook triggers when the user hovers a point: + +1. Checks if neighbor overlays are enabled (`revealOriginalNeighbors` or `revealProjectionNeighbors`) + +2. Builds a cache key: `"${epoch}-${hoveredIndex}"` + +3. If the key exists in `neighborCache`, it means the point is already cached + +4. Otherwise, calls `BackendAPI.getNeighborsForSample(contentPath, visId, epoch, hoveredIndex)` + +5. Stores the result in `neighborCache` + +6. Includes a `cancelled` flag for cleanup if the hover changes before the fetch completes + + +The `neighborOverlayProps` useMemo was also updated to read from `neighborCache[cacheKey]`. + +#### 1.4 — Updated sample-panel.tsx to read from cache + +**File**: `web/src/component/sample-panel.tsx` + +The neighbor lists in the detail panel (shown when hovering a point) were updated from: +``` +allEpochData[epoch]?.originalNeighbors[hoveredIndex]?.map(...) +``` +to: +``` +neighborCache[`${epoch}-${hoveredIndex}`]?.originalNeighbors?.map(...) +``` + +This applies to three places: +- The HIGH-DIM neighbor list + +- The PROJECTION neighbor list + +- The `isCorrect` check (highlights projection neighbors that are also high-dim neighbors) + +The `?.` optional chaining is important because the cache entry doesn't exist yet when the user first hovers — it appears after the async fetch completes and triggers a re-render. + +#### 1.5 — New frontend API function + +**File**: `web/src/communication/backend.ts` + +Added `getNeighborsForSample()` function that calls the api endpoint to get the neighbors original and projection for a single point. + +#### 1.6 — New backend endpoint + +**File**: `tool/server/server.py` + +Added `POST /getNeighborsForSample` endpoint: + +- Accepts: `content_path`, `vis_id`, `epoch`, `sample_index` + +- Calls `calculate_neighbors_for_point()` and `calculate_projection_neighbors_for_point()` + +- Returns: `{ originalNeighbors: [...], projectionNeighbors: [...] }` + +#### 1.7 — Per-point k-NN functions + +**File**: `tool/server/server_utils.py` + +Two new functions: + +**`calculate_neighbors_for_point(content_path, vis_id, epoch, point_index, max_neighbors=10)`** +- Loads high-dimensional embeddings from `epochs/epoch_N/embeddings.npy` +- Builds a k-NN index with sklearn's `NearestNeighbors` +- Queries **only the single point** at `point_index` +- Returns up to 10 neighbor indices + +**`calculate_projection_neighbors_for_point(content_path, vis_id, epoch, point_index, max_neighbors=10)`** +- Same approach but uses 2D projection coordinates from `visualize/{vis_id}/epochs/epoch_N/projection.npy` + +Note: `calculate_neighbors_for_point` takes `vis_id` as a parameter for API consistency but doesn't use it — high-dimensional embeddings don't depend on the visualization method. `calculate_projection_neighbors_for_point` does use `vis_id` because different visualization methods produce different projections. + +**Why this is fast**: Building the k-NN index (.fit()) on 5000 points takes ~5–20ms. Querying 1 point is nearly instant. The old approach queried all 5000 points, which was the actual bottleneck. + +--- + +### Strategy 2: Parallel Epoch Batching + +**Concept**: Instead of loading epochs sequentially, load them in parallel batches. + +#### 2.1 — Batch loading loop in plotView.tsx + +**File**: `web/src/views/plotView.tsx` + +The old sequential loop: +``` +for each epoch: + await fetchProjection(epoch) + await fetchPrediction(epoch) + await fetchBackground(epoch) + await fetchOriginalNeighbors(epoch) ← removed + await fetchProjectionNeighbors(epoch) ← removed +``` + +Was replaced with a parallel batched approach: + +```typescript +const BATCH_SIZE = 5; + +const loadSingleEpoch = async (epochNum) => { + // All requests for one epoch fire in parallel + const results = await Promise.all([ + fetchEpochProjection(...), + getAttributeResource('prediction'), + getBackground(...) + ]); + return { epochNum, epochData }; +}; + +// Process in batches of 5 +for (let i = 0; i < epochs.length; i += BATCH_SIZE) { + const batch = epochs.slice(i, i + BATCH_SIZE); + const batchResults = await Promise.all(batch.map(loadSingleEpoch)); + // Update store after each batch (progressive loading) + setValue('allEpochData', { ...allEpochDataTemp }); + setProgress((completedCount / epochs.length) * 100); +} +``` + +This means: +- Within each epoch: projection + prediction + background fire simultaneously +- Across epochs: 5 epochs load at the same time per batch +- After each batch: the store updates so the UI can show partial results immediately + +#### 2.2 — Enabled threaded Flask server + +**File**: `tool/server/server.py` + +Added `threaded=True` to `app.run()`: +```python +app.run(host=host, port=port, threaded=True) +``` + +Without this, Flask processes requests one at a time, negating the benefit of parallel frontend requests. With `threaded=True`, each request gets its own thread and multiple requests are handled concurrently. + +#### 2.3 — `visId` stored during load + +**File**: `web/src/views/plotView.tsx` + +Added `setValue('visId', visualizationID)` at the start of `handleLoadVisualization` so the visualization ID is available globally for lazy neighbor fetching later. + +--- + +### Extra: Loading Progress Overlay + +**File**: `web/src/component/main-block.tsx` + +A new `LoadingOverlay` component was added that renders on top of the chart during loading: + +```typescript +function LoadingOverlay({ progress, totalEpochs }) { + if (progress <= 0 || progress >= 100) return null; + const loadedEpochs = Math.round((progress / 100) * totalEpochs); + return ( +
+
Loading epoch {loadedEpochs} / {totalEpochs}
+
/* progress bar */
+
{Math.round(progress)}%
+
+ ); +} +``` + +It shows: +- **"Loading epoch 5 / 20"** — text counter +- **A blue progress bar** — fills left-to-right with CSS transition +- **Percentage** — e.g. "25%" + +It automatically appears when `progress > 0` and disappears when `progress >= 100`. The existing timeline node coloring (which turns nodes blue as they load) continues to work alongside this overlay. + +The `progress` state is updated in `plotView.tsx` after each batch completes: +```typescript +setProgress((completedCount / epochs.length) * 100); +``` + +--- + +### Extra: Performance Benchmark Script + +**File**: `tool/benchmark.py` + +A standalone Python script that measures the old vs new approach by making actual HTTP calls to the Flask backend. + +**Usage**: +```bash +python tool/benchmark.py --content_path /path/to/dataset --vis_id TimeVis_1 +``` + +**Optional arguments**: +- `--hover_points N` — number of hover events to simulate (default: 5) +- `--max_epochs N` — limit epochs to benchmark (default: all) + +**What it measures**: + +| Phase | OLD approach | NEW approach | +|---|---|---| +| Loading | Sequential: projection + prediction + background + ALL-point neighbors × each epoch | Parallel batches of 5: projection + prediction + background only | +| Neighbors | Included in load time (all points, all epochs) | On-demand: single-point fetch per hover event | + +**Output**: Prints per-epoch/batch times, then a summary: +``` +SUMMARY + OLD total load: 45.23s + NEW total load: 4.12s + ⚡ Loading speedup: 11.0x faster + ⚡ Time saved: 41.1s + ⚡ Hover latency: 85ms (imperceptible to user) +``` + +The script requires the Flask backend to be running (`python tool/server/server.py`). + +--- + +### Bug Fix: Extension Development Path + +**File**: `.vscode/launch.json` + +Changed: +``` +--extensionDevelopmentPath=${workspaceFolder} +``` +to: +``` +--extensionDevelopmentPath=${workspaceFolder}/extension +``` + +The old path pointed to the workspace root, causing VS Code to scan all subfolders and find `web/package.json` — which it tried to load as a VS Code extension, failing because it lacked the required `engines` field. The fix points directly to the `extension/` folder. + +--- + +## File-by-File Reference + +| File | Changes | Strategy | +|---|---|---| +| `web/src/state/state.unified.ts` | Commented out neighbors from `EpochData`, added `visId`, `neighborCache` | Lazy neighbors | +| `web/src/views/plotView.tsx` | Stored `visId`, replaced sequential loop with parallel batches of 5, removed neighbor calls | Parallel + Lazy | +| `web/src/component/chart.tsx` | Added on-demand neighbor fetch `useEffect`, reads from `neighborCache` | Lazy neighbors | +| `web/src/component/sample-panel.tsx` | Reads neighbor lists from `neighborCache` instead of `allEpochData` | Lazy neighbors | +| `web/src/communication/backend.ts` | Added `getNeighborsForSample()` API function | Lazy neighbors | +| `tool/server/server.py` | Added `/getNeighborsForSample` endpoint, `threaded=True` | Lazy + Parallel | +| `tool/server/server_utils.py` | Added `calculate_neighbors_for_point()`, `calculate_projection_neighbors_for_point()` | Lazy neighbors | +| `web/src/component/main-block.tsx` | Added `LoadingOverlay` component | Progress bar | +| `tool/benchmark.py` | New benchmark script comparing old vs new approach | Benchmark | +| `.vscode/launch.json` | Fixed `extensionDevelopmentPath` to point to `extension/` | Bug fix | + +--- + +## How to Run the Benchmark + +1. Start the backend server: + ```bash + cd tool/server + python server.py + ``` + +2. Run the benchmark: + ```bash + python tool/benchmark.py --content_path /path/to/your/dataset --vis_id YourVisId + ``` + +3. Optional: limit to fewer epochs for a quick test: + ```bash + python tool/benchmark.py --content_path /path/to/dataset --vis_id TimeVis_1 --max_epochs 5 + ``` + +To find your `vis_id`, look at the folder names inside your dataset's `visualize/` directory: +```bash +ls /path/to/your/dataset/visualize/ +``` + +--- + +## Concepts and Foundations + +### Embeddings +High-dimensional vectors (e.g., 128 or 512 dimensions) that a neural network produces for each data point. Points that the model considers similar have embeddings that are close together in this space. + +### Epochs +Each epoch is one full pass through the training data. The model's embeddings change at each epoch as it learns. The visualizer shows how these embeddings evolve over time. + +### Dimensionality Reduction (Projection) +Algorithms like DVI, TimeVis, or UMAP reduce high-dimensional embeddings (e.g., 128D) down to 2D coordinates so they can be plotted on a scatter chart. Each visualization method (`vis_id`) produces a different 2D layout. + +### k-Nearest Neighbors (k-NN) +For any given point, k-NN finds the k closest points by distance. The visualizer uses two types: +- **Original neighbors**: k-NN in the high-dimensional embedding space (the "true" neighbors) +- **Projection neighbors**: k-NN in the 2D projected space + +Comparing these two reveals whether the 2D projection preserves the true neighborhood structure. Projection neighbors shown in green are also original neighbors (good projection); those in red are not (distortion). + +### Lazy Loading +A pattern where data is only fetched when it's actually needed, rather than upfront. In this case, neighbors are fetched only when the user hovers a point, not for all points at load time. + +### Parallel Batching +Instead of processing items one at a time (sequential), multiple items are processed simultaneously (parallel). `Promise.all` in JavaScript and `ThreadPoolExecutor` in Python are used to achieve this. The batch size (5) balances parallelism with not overwhelming the server. + +### Caching +Storing previously computed results so they don't need to be recomputed. The `neighborCache` stores neighbors keyed by `"epoch-pointIndex"`. If the user hovers the same point again, the cached result is returned instantly without any API call. diff --git a/tool/benchmark.py b/tool/benchmark.py new file mode 100644 index 00000000..0ee4dec5 --- /dev/null +++ b/tool/benchmark.py @@ -0,0 +1,244 @@ +""" +Performance Benchmark: Old (Sequential + Neighbors) vs New (Parallel + Lazy) Loading + +This script compares the two loading strategies by timing actual HTTP requests +to the Flask backend. Run the backend server first: + python tool/server/server.py + +Usage: + python tool/benchmark.py --content_path /path/to/dataset --vis_id your_vis_id + +The script measures: + 1. OLD approach: sequential epoch loading + neighbor computation for all points + 2. NEW approach: parallel batch loading (no neighbors) + on-demand single-point neighbor fetch +""" + +import argparse +import time +import requests +import concurrent.futures +import json +import sys + +SERVER_URL = "http://localhost:5050" +BATCH_SIZE = 5 # matches the frontend batch size + + +def timed(fn): + """Run fn() and return (result, elapsed_seconds).""" + start = time.perf_counter() + result = fn() + elapsed = time.perf_counter() - start + return result, elapsed + + +def get_available_epochs(content_path): + """Fetch list of available epochs from the backend.""" + resp = requests.get(f"{SERVER_URL}/getTrainingProcessInfo", params={"content_path": content_path}) + resp.raise_for_status() + data = resp.json() + return data["available_epochs"] + + +def fetch_projection(content_path, vis_id, epoch): + resp = requests.post(f"{SERVER_URL}/updateProjection", json={ + "content_path": content_path, "vis_id": vis_id, "epoch": str(epoch) + }) + resp.raise_for_status() + return resp.json() + + +def fetch_prediction(content_path, epoch): + resp = requests.post(f"{SERVER_URL}/getAttributes", json={ + "content_path": content_path, "epoch": str(epoch), "attributes": ["prediction"] + }) + resp.raise_for_status() + return resp.json() + + +def fetch_background(content_path, vis_id, epoch): + resp = requests.post(f"{SERVER_URL}/getBackground", json={ + "content_path": content_path, "vis_id": vis_id, "epoch": str(epoch) + }) + resp.raise_for_status() + return resp.json() + + +def fetch_original_neighbors_all(content_path, epoch): + """OLD: compute neighbors for ALL points at this epoch.""" + resp = requests.post(f"{SERVER_URL}/getOriginalNeighbors", json={ + "content_path": content_path, "epoch": str(epoch) + }) + resp.raise_for_status() + return resp.json() + + +def fetch_projection_neighbors_all(content_path, vis_id, epoch): + """OLD: compute projection neighbors for ALL points at this epoch.""" + resp = requests.post(f"{SERVER_URL}/getProjectionNeighbors", json={ + "content_path": content_path, "vis_id": vis_id, "epoch": str(epoch) + }) + resp.raise_for_status() + return resp.json() + + +def fetch_neighbors_single_point(content_path, vis_id, epoch, sample_index): + """NEW: compute neighbors for ONE point.""" + resp = requests.post(f"{SERVER_URL}/getNeighborsForSample", json={ + "content_path": content_path, "vis_id": vis_id, + "epoch": epoch, "sample_index": sample_index + }) + resp.raise_for_status() + return resp.json() + + +# ─── OLD APPROACH: Sequential + All Neighbors ─────────────────────────── + +def benchmark_old(content_path, vis_id, epochs): + """Simulate the old loading: sequential epochs, each with neighbor computation.""" + print("\n" + "=" * 60) + print("OLD APPROACH: Sequential loading + All-point neighbors") + print("=" * 60) + + total_start = time.perf_counter() + epoch_times = [] + + for epoch in epochs: + epoch_start = time.perf_counter() + + # Load data sequentially (projection, prediction, background) + fetch_projection(content_path, vis_id, epoch) + fetch_prediction(content_path, epoch) + fetch_background(content_path, vis_id, epoch) + + # Compute neighbors for ALL points (the bottleneck) + fetch_original_neighbors_all(content_path, epoch) + fetch_projection_neighbors_all(content_path, vis_id, epoch) + + epoch_elapsed = time.perf_counter() - epoch_start + epoch_times.append(epoch_elapsed) + print(f" Epoch {epoch:>3d}: {epoch_elapsed:.2f}s") + + total_elapsed = time.perf_counter() - total_start + avg_epoch = sum(epoch_times) / len(epoch_times) + + print(f"\n Total: {total_elapsed:.2f}s") + print(f" Avg/epoch: {avg_epoch:.2f}s") + return total_elapsed, epoch_times + + +# ─── NEW APPROACH: Parallel Batches + Lazy Neighbors ───────────────────── + +def benchmark_new(content_path, vis_id, epochs, hover_points=5): + """Simulate the new loading: parallel batches, then on-demand neighbor fetch.""" + print("\n" + "=" * 60) + print("NEW APPROACH: Parallel batch loading + On-demand neighbors") + print("=" * 60) + + # Phase 1: Load epoch data in parallel batches + phase1_start = time.perf_counter() + batch_times = [] + + for i in range(0, len(epochs), BATCH_SIZE): + batch = epochs[i:i + BATCH_SIZE] + batch_start = time.perf_counter() + + with concurrent.futures.ThreadPoolExecutor(max_workers=BATCH_SIZE) as executor: + futures = [] + for epoch in batch: + # Each epoch fires 3 requests in parallel + futures.append(executor.submit(fetch_projection, content_path, vis_id, epoch)) + futures.append(executor.submit(fetch_prediction, content_path, epoch)) + futures.append(executor.submit(fetch_background, content_path, vis_id, epoch)) + + concurrent.futures.wait(futures) + + batch_elapsed = time.perf_counter() - batch_start + batch_times.append(batch_elapsed) + print(f" Batch {i // BATCH_SIZE + 1} (epochs {batch[0]}-{batch[-1]}): {batch_elapsed:.2f}s") + + phase1_elapsed = time.perf_counter() - phase1_start + + # Phase 2: Simulate hovering over a few points (on-demand neighbor fetch) + phase2_start = time.perf_counter() + hover_times = [] + + test_epoch = epochs[0] + print(f"\n Simulating {hover_points} hover events at epoch {test_epoch}...") + + for point_idx in range(hover_points): + _, t = timed(lambda idx=point_idx: fetch_neighbors_single_point( + content_path, vis_id, test_epoch, idx + )) + hover_times.append(t) + print(f" Point {point_idx}: {t * 1000:.0f}ms") + + phase2_elapsed = time.perf_counter() - phase2_start + avg_hover = (sum(hover_times) / len(hover_times)) * 1000 # in ms + + total_elapsed = phase1_elapsed # loading time is what the user waits for + print(f"\n Loading total: {phase1_elapsed:.2f}s") + print(f" Avg hover time: {avg_hover:.0f}ms") + return phase1_elapsed, phase2_elapsed, batch_times, hover_times + + +# ─── SUMMARY ───────────────────────────────────────────────────────────── + +def print_summary(old_total, new_loading, new_hover_times, num_epochs, hover_points): + avg_hover_ms = (sum(new_hover_times) / len(new_hover_times)) * 1000 + + print("\n" + "=" * 60) + print("SUMMARY") + print("=" * 60) + print(f" Epochs: {num_epochs}") + print(f" Hover points: {hover_points}") + print(f"") + print(f" OLD total load: {old_total:.2f}s") + print(f" NEW total load: {new_loading:.2f}s") + print(f" NEW avg hover: {avg_hover_ms:.0f}ms") + print(f"") + + speedup = old_total / new_loading if new_loading > 0 else float('inf') + saved = old_total - new_loading + print(f" ⚡ Loading speedup: {speedup:.1f}x faster") + print(f" ⚡ Time saved: {saved:.1f}s") + print(f" ⚡ Hover latency: {avg_hover_ms:.0f}ms (imperceptible to user)") + print("=" * 60) + + +def main(): + parser = argparse.ArgumentParser(description="Benchmark old vs new loading approach") + parser.add_argument("--content_path", required=True, help="Path to the dataset directory") + parser.add_argument("--vis_id", required=True, help="Visualization ID") + parser.add_argument("--hover_points", type=int, default=5, help="Number of points to simulate hovering (default: 5)") + parser.add_argument("--max_epochs", type=int, default=None, help="Limit number of epochs to benchmark (default: all)") + args = parser.parse_args() + + # Verify server is running + try: + requests.get(f"{SERVER_URL}/", timeout=3) + except requests.ConnectionError: + print(f"ERROR: Cannot connect to backend at {SERVER_URL}") + print("Start the server first: python tool/server/server.py") + sys.exit(1) + + # Get available epochs + epochs = get_available_epochs(args.content_path) + if args.max_epochs: + epochs = epochs[:args.max_epochs] + + print(f"Benchmarking with {len(epochs)} epochs at: {args.content_path}") + print(f"Visualization ID: {args.vis_id}") + + # Run benchmarks + old_total, old_epoch_times = benchmark_old(args.content_path, args.vis_id, epochs) + new_loading, new_hover_total, new_batch_times, new_hover_times = benchmark_new( + args.content_path, args.vis_id, epochs, args.hover_points + ) + + # Print comparison + print_summary(old_total, new_loading, new_hover_times, len(epochs), args.hover_points) + + +if __name__ == "__main__": + main() diff --git a/tool/server/server.py b/tool/server/server.py index 076243e4..b6c6913b 100644 --- a/tool/server/server.py +++ b/tool/server/server.py @@ -1,29 +1,33 @@ import os import sys -# from llm_agent import call_llm_agent -from run_visualization import visualize_run +import numpy as np -from flask import request, Flask, jsonify, make_response, send_file,send_from_directory +from flask import Flask, jsonify, make_response, request, send_file, send_from_directory from flask_cors import CORS, cross_origin -sys.path.append('.') -sys.path.append('..') -sys.path.append('../..') -sys.path.append('../visualize') +# from llm_agent import call_llm_agent +from run_visualization import visualize_run + +sys.path.append(".") +sys.path.append("..") +sys.path.append("../..") +sys.path.append("../visualize") -from server_utils import * +import server_utils +#from server_utils import * # flask for API server app = Flask(__name__) cors = CORS(app, supports_credentials=True) -app.config['CORS_HEADERS'] = 'Content-Type' +app.config["CORS_HEADERS"] = "Content-Type" # Check for "--dev" argument is_dev_mode = "--dev" in sys.argv + @app.route("/", methods=["GET", "POST"]) def GUI(): - return send_from_directory('../frontend', 'index.html') + return send_from_directory("../frontend", "index.html") """ @@ -35,47 +39,51 @@ def GUI(): color_list (list): list of colors label_text_list (list): list of label text """ -@app.route('/getTrainingProcessInfo', methods=["GET"]) + + +@app.route("/getTrainingProcessInfo", methods=["GET"]) @cross_origin() def get_training_process_info(): - content_path = request.args.get('content_path') - - epochs_dir = os.path.join(content_path, 'epochs') + content_path = request.args.get("content_path") + + epochs_dir = os.path.join(content_path, "epochs") available_epochs = [] if os.path.exists(epochs_dir) and os.path.isdir(epochs_dir): try: for item in os.listdir(epochs_dir): - if item.startswith('epoch_'): + if item.startswith("epoch_"): full_path = os.path.join(epochs_dir, item) if os.path.isdir(full_path): - epoch_num_str = item[len('epoch_'):] + epoch_num_str = item[len("epoch_") :] if epoch_num_str.isdigit(): available_epochs.append(int(epoch_num_str)) - + available_epochs.sort() except Exception as e: print(f"Error scanning epochs directory: {e}") available_epochs = [] - config = read_file_as_json(os.path.join(content_path, 'dataset', 'info.json')) - - if config == None or 'classes' not in config: + config = server_utils.read_file_as_json(os.path.join(content_path, "dataset", "info.json")) + + if config == None or "classes" not in config: # infer from labels.npy - label_file = os.path.join(content_path, 'dataset', 'labels.npy') + label_file = os.path.join(content_path, "dataset", "labels.npy") labels = np.load(label_file, allow_pickle=True) class_num = len(np.unique(labels)) - color_list = get_coloring_list(class_num) + color_list = server_utils.get_coloring_list(class_num) label_text_list = [str(i) for i in range(class_num)] else: - color_list = get_coloring_list(len(config['classes'])) - label_text_list = config['classes'] - - result = jsonify({ - 'color_list': color_list, - 'label_text_list': label_text_list, - 'available_epochs': available_epochs - }) + color_list = server_utils.get_coloring_list(len(config["classes"])) + label_text_list = config["classes"] + + result = jsonify( + { + "color_list": color_list, + "label_text_list": label_text_list, + "available_epochs": available_epochs, + } + ) return make_response(result, 200) @@ -91,19 +99,23 @@ def get_training_process_info(): project (list) label_list (list): label list of samples in projection """ -@app.route('/updateProjection', methods = ["POST"]) + + +@app.route("/updateProjection", methods=["POST"]) @cross_origin() def update_projection(): req = request.get_json() - content_path = req['content_path'] - vis_id = req['vis_id'] - epoch = int(req['epoch']) + content_path = req["content_path"] + vis_id = req["vis_id"] + epoch = int(req["epoch"]) - projection = load_projection(content_path, vis_id, epoch) + projection = server_utils.load_projection(content_path, vis_id, epoch) - result = jsonify({ - 'projection': projection, - }) + result = jsonify( + { + "projection": projection, + } + ) return make_response(result, 200) @@ -118,20 +130,23 @@ def update_projection(): Response: None """ -@app.route('/startVisualizing', methods = ["POST"]) + + +@app.route("/startVisualizing", methods=["POST"]) def start_visualizing(): req = request.get_json() - content_path = req['content_path'] - vis_method = req['vis_method'] - vis_id = req['vis_id'] - data_type = req['data_type'] - task_type = req['task_type'] - vis_config = req['vis_config'] - + content_path = req["content_path"] + vis_method = req["vis_method"] + vis_id = req["vis_id"] + data_type = req["data_type"] + task_type = req["task_type"] + vis_config = req["vis_config"] + visualize_run(content_path, vis_method, vis_id, data_type, task_type, vis_config) - + return make_response({}, 200) + """ Api: get text data of all samples @@ -140,36 +155,40 @@ def start_visualizing(): Response: text_list (lsit of str) """ -@app.route('/getAllText', methods = ["POST"]) + + +@app.route("/getAllText", methods=["POST"]) def get_all_text(): req = request.get_json() - content_path = req['content_path'] + content_path = req["content_path"] - text_list = get_all_texts(content_path) + text_list = server_utils.get_all_texts(content_path) if text_list is None: - return make_response(jsonify({'error_message': "getting all texts failed"}), 400) + return make_response( + jsonify({"error_message": "getting all texts failed"}), 400 + ) - result = jsonify({ - 'text_list': text_list - }) + result = jsonify({"text_list": text_list}) return make_response(result, 200) -@app.route('/getAlignment', methods = ["POST"]) + +@app.route("/getAlignment", methods=["POST"]) def get_alignment(): req = request.get_json() - content_path = req['content_path'] + content_path = req["content_path"] - alignment = get_alignment_data(content_path) + alignment = server_utils.get_alignment_data(content_path) if alignment is None: - return make_response(jsonify({'error_message': "getting alignment failed"}), 400) + return make_response( + jsonify({"error_message": "getting alignment failed"}), 400 + ) - result = jsonify({ - 'alignment': alignment - }) + result = jsonify({"alignment": alignment}) return make_response(result, 200) + """ Api: get selected attributes of the dataset @@ -182,17 +201,19 @@ def get_alignment(): attribute2 (object) ... """ -@app.route('/getAttributes', methods = ["POST"]) + + +@app.route("/getAttributes", methods=["POST"]) @cross_origin() def get_attributes(): req = request.get_json() - content_path = req['content_path'] - epoch = req['epoch'] - attributes = req['attributes'] + content_path = req["content_path"] + epoch = req["epoch"] + attributes = req["attributes"] result = {} for attribute in attributes: - result[attribute] = load_single_attribute(content_path, epoch, attribute) + result[attribute] = server_utils.load_single_attribute(content_path, epoch, attribute) result = jsonify(result) return make_response(result, 200) @@ -209,23 +230,23 @@ def get_attributes(): Response: indices (list of int): indeices of samples that satisfy the filter """ -@app.route('/getSimpleFilterResult', methods = ["POST"]) + + +@app.route("/getSimpleFilterResult", methods=["POST"]) @cross_origin() def get_simple_filter_result(): req = request.get_json() - content_path = req['content_path'] - epoch = int(req['epoch']) - filters = req['filters'] + content_path = req["content_path"] + epoch = int(req["epoch"]) + filters = req["filters"] - config = read_file_as_json(os.path.join(content_path, 'config.json')) - indices, error_message = get_filter_result(config, content_path, epoch, filters) + config = server_utils.read_file_as_json(os.path.join(content_path, "config.json")) + indices, error_message = server_utils.get_filter_result(config, content_path, epoch, filters) if indices is None: - return make_response(jsonify({'error_message': error_message}), 400) + return make_response(jsonify({"error_message": error_message}), 400) - result = jsonify({ - 'indices': indices - }) + result = jsonify({"indices": indices}) return make_response(result, 200) @@ -240,23 +261,26 @@ def get_simple_filter_result(): scale (list of float) Response: background_image_base64 (str): base64 encoded im -""" -@app.route('/getBackground', methods = ["POST"]) +""" + + +@app.route("/getBackground", methods=["POST"]) @cross_origin() def get_background(): req = request.get_json() - content_path = req['content_path'] - vis_id = req['vis_id'] - epoch = int(req['epoch']) - + content_path = req["content_path"] + vis_id = req["vis_id"] + epoch = int(req["epoch"]) + try: - base64_image = load_background(content_path, vis_id, epoch) - result = jsonify({ - 'background_image_base64': base64_image - }) + base64_image = server_utils.load_background(content_path, vis_id, epoch) + result = jsonify({"background_image_base64": base64_image}) return make_response(result, 200) except Exception as e: - return make_response(jsonify({'error_message': 'Error in loading background'}), 400) + return make_response( + jsonify({"error_message": "Error in loading background"}), 400 + ) + """ Api: get image data of one sample @@ -267,26 +291,24 @@ def get_background(): Response: image_base64 (str): base64 encoded image """ -@app.route('/getImageData', methods = ["POST"]) + + +@app.route("/getImageData", methods=["POST"]) @cross_origin() def get_image_data(): req = request.get_json() - content_path = req['content_path'] - if('index' not in req): - return make_response(jsonify({'image_base64': ''}), 200) - - index = req['index'] + content_path = req["content_path"] + if "index" not in req: + return make_response(jsonify({"image_base64": ""}), 200) + + index = req["index"] try: - base64_image = load_one_image(content_path, index) - result = jsonify({ - 'image_base64': base64_image - }) + base64_image = server_utils.load_one_image(content_path, index) + result = jsonify({"image_base64": base64_image}) return make_response(result, 200) except Exception as e: - result = jsonify({ - 'image_base64': '' - }) + result = jsonify({"image_base64": ""}) return make_response(result, 200) @@ -299,26 +321,24 @@ def get_image_data(): Response: text (str): text data """ -@app.route('/getTextData', methods = ["POST"]) + + +@app.route("/getTextData", methods=["POST"]) @cross_origin() def get_text_data(): req = request.get_json() - content_path = req['content_path'] - if('index' not in req): - return make_response(jsonify({'text': ''}), 200) - - index = req['index'] + content_path = req["content_path"] + if "index" not in req: + return make_response(jsonify({"text": ""}), 200) + + index = req["index"] try: - text = load_one_text(content_path, index) - result = jsonify({ - 'text': text - }) + text = server_utils.load_one_text(content_path, index) + result = jsonify({"text": text}) return make_response(result, 200) except Exception as e: - result = jsonify({ - 'text': '' - }) + result = jsonify({"text": ""}) return make_response(result, 200) @@ -331,22 +351,29 @@ def get_text_data(): Response: neighbors (array[][]) """ -@app.route('/getOriginalNeighbors', methods = ["POST"]) + + +@app.route("/getOriginalNeighbors", methods=["POST"]) @cross_origin() def get_original_neighbors(): req = request.get_json() - content_path = req['content_path'] - epoch = int(req['epoch']) - + content_path = req["content_path"] + epoch = int(req["epoch"]) + try: - neighbors = calculate_high_dimensional_neighbors(content_path, epoch) - result = jsonify({ - 'neighbors': neighbors, - }) + neighbors = server_utils.calculate_high_dimensional_neighbors(content_path, epoch) + result = jsonify( + { + "neighbors": neighbors, + } + ) return make_response(result, 200) except Exception as e: print(e) - return make_response(jsonify({'error_message': 'Error in calculating neighbors'}), 400) + return make_response( + jsonify({"error_message": "Error in calculating neighbors"}), 400 + ) + """ Api: get projection neighbors of one sample @@ -358,87 +385,146 @@ def get_original_neighbors(): Response: neighbors (array[][]) """ -@app.route('/getProjectionNeighbors', methods = ["POST"]) + + +@app.route("/getProjectionNeighbors", methods=["POST"]) @cross_origin() def get_projection_neighbors(): req = request.get_json() - content_path = req['content_path'] - vis_id = req['vis_id'] - epoch = int(req['epoch']) - + content_path = req["content_path"] + vis_id = req["vis_id"] + epoch = int(req["epoch"]) + try: - neighbors = calculate_projection_neighbors(content_path, vis_id, epoch) - result = jsonify({ - 'neighbors': neighbors, - }) + neighbors = server_utils.calculate_projection_neighbors(content_path, vis_id, epoch) + result = jsonify( + { + "neighbors": neighbors, + } + ) return make_response(result, 200) except Exception as e: print(e) - return make_response(jsonify({'error_message': 'Error in calculating neighbors'}), 400) + return make_response( + jsonify({"error_message": "Error in calculating neighbors"}), 400 + ) + + + """ + Api: get projection neighbors of one point - -@app.route('/getVisualizeMetrics', methods = ["POST"]) +Request: + content_path (str) + vis_id (str) + epoch (str) +Response: + neighbors (array[][]) + projection_neighbors (array[][]) + """ + +@app.route("/getNeighborsForSample", methods=["POST"]) +@cross_origin() +def get_neighbors_for_sample(): + req = request.get_json() + content_path = req["content_path"] + vis_id = req["vis_id"] + epoch = int(req["epoch"]) + sample_index = int(req["sample_index"]) + + try: + original_neighbors = server_utils.calculate_neighbors_for_point( + content_path, vis_id, epoch, sample_index + ) + projection_neighbors = server_utils.calculate_projection_neighbors_for_point( + content_path, vis_id, epoch, sample_index + ) + result = jsonify( + {"originalNeighbors": original_neighbors, "projectionNeighbors": projection_neighbors} + ) + return make_response(result, 200) + except Exception as e: + print(e) + return make_response( + jsonify({"error_message": "Error in calculating neighbors for sample"}), 400 + ) + + +@app.route("/getVisualizeMetrics", methods=["POST"]) @cross_origin() def get_visualize_metrics(): req = request.get_json() - content_path = req['content_path'] - vis_id = req['vis_id'] - epoch = int(req['epoch']) - + content_path = req["content_path"] + vis_id = req["vis_id"] + epoch = int(req["epoch"]) + try: - metrics = calculate_visualize_metrics(content_path, vis_id, epoch) + metrics = server_utils.calculate_visualize_metrics(content_path, vis_id, epoch) result = jsonify(metrics) return make_response(result, 200) except Exception as e: print(e) - return make_response(jsonify({'error_message': 'Error in calculating metrics'}), 400) + return make_response( + jsonify({"error_message": "Error in calculating metrics"}), 400 + ) -@app.route('/getInfluenceSamples', methods=["POST"]) +@app.route("/getInfluenceSamples", methods=["POST"]) @cross_origin() def get_influence_samples(): req = request.get_json() - content_path = req['content_path'] - epoch = int(req['epoch']) - training_event = req['training_event'] - num_samples = int(req['num_samples']) + content_path = req["content_path"] + epoch = int(req["epoch"]) + training_event = req["training_event"] + num_samples = int(req["num_samples"]) try: - if training_event['type'] == 'InconsistentMovement': + if training_event["type"] == "InconsistentMovement": # attribution of closeness or separation between a pair of samples print("Tracing InconsistentMovement") - influence_samples = movement_attribution(content_path, epoch, training_event, num_samples) - else: + influence_samples = server_utils.movement_attribution( + content_path, epoch, training_event, num_samples + ) + else: # atribution of a particular prediction print("Tracing PredictionError") - influence_samples = prediction_attribution(content_path, epoch, training_event, num_samples) - - result = jsonify({ - "influence_samples": influence_samples, - }) + influence_samples = server_utils.prediction_attribution( + content_path, epoch, training_event, num_samples + ) + + result = jsonify( + { + "influence_samples": influence_samples, + } + ) return make_response(result, 200) except Exception as e: print(e) - return make_response(jsonify({'error_message': 'Error in calculating influence samples'}), 400) + return make_response( + jsonify({"error_message": "Error in calculating influence samples"}), 400 + ) -@app.route('/calculateTrainingEvents', methods=["POST"]) +@app.route("/calculateTrainingEvents", methods=["POST"]) @cross_origin() def calculate_training_events(): req = request.get_json() - content_path = req['content_path'] - epoch = int(req['epoch']) - event_types = req['event_types'] + content_path = req["content_path"] + epoch = int(req["epoch"]) + event_types = req["event_types"] try: - training_events = compute_training_events(content_path, epoch, event_types) - result = jsonify({ - "training_events": training_events, - }) + training_events = server_utils.compute_training_events(content_path, epoch, event_types) + result = jsonify( + { + "training_events": training_events, + } + ) return make_response(result, 200) except Exception as e: print(e) - return make_response(jsonify({'error_message': 'Error in calculating training events'}), 400) + return make_response( + jsonify({"error_message": "Error in calculating training events"}), 400 + ) def check_port_inuse(port, host): @@ -455,26 +541,28 @@ def check_port_inuse(port, host): if s: s.close() + # for contrast if __name__ == "__main__": - host = '0.0.0.0' + host = "0.0.0.0" port = 5050 while check_port_inuse(port, host): port = port + 1 if not is_dev_mode: - app.run(host=host, port=port) + # added threaded=True to handle multiple requests to the backend (for the batches) + app.run(host=host, port=port, threaded=True) else: - from livereload import Server from flask_debugtoolbar import DebugToolbarExtension + from livereload import Server app.debug = True - app.config['SECRET_KEY'] = 'a-random-secret-key' + app.config["SECRET_KEY"] = "a-random-secret-key" toolbar = DebugToolbarExtension(app) server = Server(app.wsgi_app) - server.watch('../frontend/**/*.css') - server.watch('../frontend/**/*.html') - server.watch('../frontend/**/*.js') + server.watch("../frontend/**/*.css") + server.watch("../frontend/**/*.html") + server.watch("../frontend/**/*.js") server.serve(host=host, port=port) diff --git a/tool/server/server_utils.py b/tool/server/server_utils.py index 59cd329c..57b774b6 100644 --- a/tool/server/server_utils.py +++ b/tool/server/server_utils.py @@ -289,6 +289,61 @@ def calculate_projection_neighbors(content_path, vis_id, epoch, max_neighbors=10 return neighbors +def calculate_neighbors_for_point(content_path, vis_id, epoch, point_index, max_neighbors=10): + """ calculate knn for a specific point, return the neighbor indices + + Args: + content_path (str): path to the content directory + vis_id (str): visualization ID + epoch (int): epoch number + point_index (int): index of the point + max_neighbors (int, optional): max number of neighbors, defaults to 10. + Returns: + list: list of neighbor indices + """ + feature_ls = load_single_attribute(content_path, epoch, 'representation') + features = np.array(feature_ls) + neighbors = NearestNeighbors(n_neighbors=max_neighbors + 1, algorithm='auto').fit(features) + + sample_feature = features[point_index].reshape(1, -1) + distances, indices = neighbors.kneighbors(sample_feature) + + neighbors_ls = list() + + for nbr in range(1, max_neighbors + 1): + neighbor_idx = indices[0][nbr] + neighbors_ls.append(int(neighbor_idx)) + + return neighbors_ls + +def calculate_projection_neighbors_for_point(content_path, vis_id, epoch, point_index, max_neighbors=10): + """ + calculate knn for a specific point in the projection space, return the neighbor indices. + + Args: + content_path (str): path to the content directory + vis_id (str): visualization ID + epoch (int): epoch number + point_index (int): index of the point + max_neighbors (int, optional): max number of neighbors, defaults to 10. + + Returns: + list: list of neighbor indices + """ + projection_ls = load_projection(content_path, vis_id, epoch) + projection = np.array(projection_ls) + neighbors = NearestNeighbors(n_neighbors=max_neighbors + 1, algorithm='auto').fit(projection) + + sample_projection = projection[point_index].reshape(1, -1) + distances, indices = neighbors.kneighbors(sample_projection) + + neighbors_ls = list() + + for nbr in range(1, max_neighbors + 1): + neighbor_idx = indices[0][nbr] + neighbors_ls.append(int(neighbor_idx)) + + return neighbors_ls # Func: Load a single attribute from a file based on the configuration and epoch def load_single_attribute(content_path, epoch, attribute): diff --git a/web/package-lock.json b/web/package-lock.json index cbcf09c8..fb081202 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -43,7 +43,7 @@ "serve-static": "^1.16.2", "typescript": "~5.6.2", "typescript-eslint": "^8.10.0", - "vite": "^5.4.9" + "vite": "^7.3.1" } }, "node_modules/@ant-design/colors": { @@ -472,9 +472,9 @@ "license": "MIT" }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", "cpu": [ "ppc64" ], @@ -485,13 +485,13 @@ "aix" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", "cpu": [ "arm" ], @@ -502,13 +502,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", "cpu": [ "arm64" ], @@ -519,13 +519,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", "cpu": [ "x64" ], @@ -536,13 +536,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", "cpu": [ "arm64" ], @@ -553,13 +553,13 @@ "darwin" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", "cpu": [ "x64" ], @@ -570,13 +570,13 @@ "darwin" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", "cpu": [ "arm64" ], @@ -587,13 +587,13 @@ "freebsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", "cpu": [ "x64" ], @@ -604,13 +604,13 @@ "freebsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", "cpu": [ "arm" ], @@ -621,13 +621,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", "cpu": [ "arm64" ], @@ -638,13 +638,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", "cpu": [ "ia32" ], @@ -655,13 +655,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", "cpu": [ "loong64" ], @@ -672,13 +672,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", "cpu": [ "mips64el" ], @@ -689,13 +689,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", "cpu": [ "ppc64" ], @@ -706,13 +706,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", "cpu": [ "riscv64" ], @@ -723,13 +723,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", "cpu": [ "s390x" ], @@ -740,13 +740,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", "cpu": [ "x64" ], @@ -757,13 +757,30 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", "cpu": [ "x64" ], @@ -774,13 +791,30 @@ "netbsd" ], "engines": { - "node": ">=12" + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", "cpu": [ "x64" ], @@ -791,13 +825,30 @@ "openbsd" ], "engines": { - "node": ">=12" + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", "cpu": [ "x64" ], @@ -808,13 +859,13 @@ "sunos" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", "cpu": [ "arm64" ], @@ -825,13 +876,13 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", "cpu": [ "ia32" ], @@ -842,13 +893,13 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", "cpu": [ "x64" ], @@ -859,7 +910,7 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@eslint-community/eslint-utils": { @@ -1082,9 +1133,9 @@ } }, "node_modules/@isaacs/brace-expansion": { - "version": "5.0.0", - "resolved": "https://registry.npmmirror.com/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", - "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.1.tgz", + "integrity": "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==", "license": "MIT", "peer": true, "dependencies": { @@ -3238,13 +3289,13 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.13.2", - "resolved": "https://registry.npmmirror.com/axios/-/axios-1.13.2.tgz", - "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", + "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, @@ -4550,9 +4601,9 @@ } }, "node_modules/esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -4560,32 +4611,35 @@ "esbuild": "bin/esbuild" }, "engines": { - "node": ">=12" + "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" } }, "node_modules/escalade": { @@ -6609,9 +6663,9 @@ } }, "node_modules/qs": { - "version": "6.14.1", - "resolved": "https://registry.npmmirror.com/qs/-/qs-6.14.1.tgz", - "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -8625,21 +8679,24 @@ } }, "node_modules/vite": { - "version": "5.4.21", - "resolved": "https://registry.npmmirror.com/vite/-/vite-5.4.21.tgz", - "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.43", - "rollup": "^4.20.0" + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" }, "bin": { "vite": "bin/vite.js" }, "engines": { - "node": "^18.0.0 || >=20.0.0" + "node": "^20.19.0 || >=22.12.0" }, "funding": { "url": "https://github.com/vitejs/vite?sponsor=1" @@ -8648,19 +8705,25 @@ "fsevents": "~2.3.3" }, "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", - "less": "*", + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.4.0" + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" }, "peerDependenciesMeta": { "@types/node": { "optional": true }, + "jiti": { + "optional": true + }, "less": { "optional": true }, @@ -8681,9 +8744,75 @@ }, "terser": { "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true } } }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vite/node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/webgl-constants": { "version": "1.1.1", "resolved": "https://registry.npmmirror.com/webgl-constants/-/webgl-constants-1.1.1.tgz", diff --git a/web/package.json b/web/package.json index 3e352d9c..e61fb678 100644 --- a/web/package.json +++ b/web/package.json @@ -45,7 +45,7 @@ "serve-static": "^1.16.2", "typescript": "~5.6.2", "typescript-eslint": "^8.10.0", - "vite": "^5.4.9" + "vite": "^7.3.1" }, "pnpm": { "patchedDependencies": { diff --git a/web/src/communication/backend.ts b/web/src/communication/backend.ts index 5fbad593..402b2ea6 100644 --- a/web/src/communication/backend.ts +++ b/web/src/communication/backend.ts @@ -224,4 +224,23 @@ export function calculateTrainingEvents( export function testConnection(message: string, options?: NetworkOptions) { const data = { message }; return basicPostWithJsonResponse('/testConnection', data, options); +} + +// new function to fetch all necessary data for a specific epoch in one request +export function getNeighborsForSample( + contentPath: string, + visId: string, + epoch: number, + sampleIndex: number, + options?: NetworkOptions +) { + const data = { + "content_path": contentPath, + "vis_id": visId, + "epoch": epoch, + "sample_index": sampleIndex + }; + + // request to the backend to fetch both original and projection neighbors for the hovered sample + return basicPostWithJsonResponse('/getNeighborsForSample', data, options); } \ No newline at end of file diff --git a/web/src/component/chart.tsx b/web/src/component/chart.tsx index 0aaabf23..bdd99e56 100644 --- a/web/src/component/chart.tsx +++ b/web/src/component/chart.tsx @@ -3,6 +3,7 @@ import { memo, useEffect, useMemo, useRef, useState } from 'react'; import { EmbeddingView, type EmbeddingViewProps, type DataPoint, type ViewportState } from 'embedding-atlas/react'; import { useDefaultStore } from "../state/state.unified"; import { transferArray2Color } from './utils'; +import * as BackendAPI from '../communication/backend'; type EmbeddingData = NonNullable; @@ -29,6 +30,11 @@ export const ChartComponent = memo(() => { const { availableEpochs } = useDefaultStore(["availableEpochs"]); const { showTrail } = useDefaultStore(["showTrail"]); const { setSelectedIndices } = useDefaultStore(["setSelectedIndices"]); + // added for on demand calls and cache + const { contentPath, visId, neighborCache, setValue } = useDefaultStore(["contentPath", "visId", "neighborCache", "setValue"]); + + // for on demand neighbor fetching + const { hoveredIndex } = useDefaultStore(["hoveredIndex"]); const epochData = allEpochData[epoch]; @@ -37,6 +43,34 @@ export const ChartComponent = memo(() => { // selection can be added later when needed let [viewportState, setViewportState] = useState(null); + // fetch on demand when user hovers a point + useEffect(() => { + if (hoveredIndex === undefined || !contentPath || !visId || !epoch) return; + if (!revealOriginalNeighbors && !revealProjectionNeighbors) return; + + const cacheKey = `${epoch}-${hoveredIndex}`; + if (neighborCache[cacheKey]) return; // already in cache + + let cancelled = false; + + // fetch neighbors for hovered point + BackendAPI.getNeighborsForSample(contentPath, visId, epoch, hoveredIndex) + .then((result: any) => { + if (cancelled) return; + setValue('neighborCache', { + ...neighborCache, + [cacheKey]: { + originalNeighbors: result.originalNeighbors || result.neighbors || [], + projectionNeighbors: result.projectionNeighbors || result.projection_neighbors || [], + } + }); + }) + .catch((err: any) => console.warn('Failed to fetch neighbors:', err)); + + // cleanup function to cancel if hoveredIndex changes before fetch completes + return () => { cancelled = true; }; + }, [hoveredIndex, epoch, contentPath, visId, revealOriginalNeighbors, revealProjectionNeighbors]); + // observe container size change useEffect(() => { const node = atlasRef.current; @@ -193,9 +227,12 @@ export const ChartComponent = memo(() => { if (!prepared || !epochData) return { center: null, original: [], projection: [], dataX: new Float32Array(0), dataY: new Float32Array(0), pointSize, revealOriginalNeighbors, revealProjectionNeighbors } as any; const idsByPos = prepared.dataPoints.map((p) => p.identifier as number); if (!tooltip) return { center: null, original: [], projection: [], dataX: prepared.simpleData.x as Float32Array, dataY: prepared.simpleData.y as Float32Array, pointSize, revealOriginalNeighbors, revealProjectionNeighbors, idsByPos, showLabel, showIndex, labelDict, textData, inherentLabelData, viewportState, showTrail, availableEpochs, allEpochData, currentEpoch: epoch, setSelectedIndices, selectedIndices } as any; + // now read from cache const hoverId = tooltip.identifier as number; - const orig = (epochData.originalNeighbors?.[hoverId] ?? []).filter((nid) => posMap.has(nid)); - const proj = (epochData.projectionNeighbors?.[hoverId] ?? []).filter((nid) => posMap.has(nid)); + const cacheKey = `${epoch}-${hoverId}`; + const cached = neighborCache[cacheKey]; + const orig = (cached?.originalNeighbors ?? []).filter((nid: number) => posMap.has(nid)); + const proj = (cached?.projectionNeighbors ?? []).filter((nid: number) => posMap.has(nid)); return { center: tooltip, original: orig, @@ -219,7 +256,7 @@ export const ChartComponent = memo(() => { setSelectedIndices, selectedIndices, }; - }, [prepared, epochData, tooltip, posMap, pointSize, revealOriginalNeighbors, revealProjectionNeighbors, showLabel, showIndex, labelDict, textData, inherentLabelData, viewportState, showTrail, availableEpochs, allEpochData, epoch, trailRefresh, selectedIndices]); + }, [prepared, epochData, tooltip, posMap, pointSize, revealOriginalNeighbors, revealProjectionNeighbors, showLabel, showIndex, labelDict, textData, inherentLabelData, viewportState, showTrail, availableEpochs, allEpochData, epoch, trailRefresh, selectedIndices, neighborCache]); class NeighborOverlay { private el: HTMLDivElement | null = null; diff --git a/web/src/component/main-block.tsx b/web/src/component/main-block.tsx index 13dcfbc9..15ce34c2 100644 --- a/web/src/component/main-block.tsx +++ b/web/src/component/main-block.tsx @@ -229,6 +229,37 @@ function Timeline({ epoch, epochs, progress, onSwitchEpoch }: { epoch: number, e ); }; +function LoadingOverlay({ progress, totalEpochs }: { progress: number; totalEpochs: number }) { + if (progress <= 0 || progress >= 100) return null; + + const loadedEpochs = Math.round((progress / 100) * totalEpochs); + + return ( +
+
+ Loading epoch {loadedEpochs} / {totalEpochs} +
+
+
+
+
+ {Math.round(progress)}% +
+
+ ); +} + export function MainBlock() { const { epoch, setEpoch } = useDefaultStore(['epoch', 'setEpoch']); const { availableEpochs } = useDefaultStore(['availableEpochs']); @@ -237,7 +268,10 @@ export function MainBlock() { // only consider single container for now return (
- +
+ + +