Fix: add utils/data files and update gitignore
Browse files- .dockerignore +3 -4
- .gitignore +2 -1
- Dockerfile +19 -23
- frontend/src/utils/data/binaryFilter.ts +152 -0
- frontend/src/utils/data/enhancedCache.ts +220 -0
- frontend/src/utils/data/indexedDB.ts +262 -0
- netlify.toml +0 -4
.dockerignore
CHANGED
|
@@ -32,10 +32,9 @@ frontend/.env.development.local
|
|
| 32 |
frontend/.env.test.local
|
| 33 |
frontend/.env.production.local
|
| 34 |
|
| 35 |
-
#
|
| 36 |
-
cache/*.pkl
|
| 37 |
-
cache/*.npy
|
| 38 |
-
!cache/.gitkeep
|
| 39 |
|
| 40 |
# Documentation
|
| 41 |
*.md
|
|
|
|
| 32 |
frontend/.env.test.local
|
| 33 |
frontend/.env.production.local
|
| 34 |
|
| 35 |
+
# Keep cache files for fast startup (precomputed UMAP)
|
| 36 |
+
# cache/*.pkl # INCLUDED for HF Spaces deployment
|
| 37 |
+
# cache/*.npy # INCLUDED for HF Spaces deployment
|
|
|
|
| 38 |
|
| 39 |
# Documentation
|
| 40 |
*.md
|
.gitignore
CHANGED
|
@@ -34,5 +34,6 @@ flagged/
|
|
| 34 |
# Data
|
| 35 |
*.parquet
|
| 36 |
*.csv
|
| 37 |
-
data/
|
|
|
|
| 38 |
|
|
|
|
| 34 |
# Data
|
| 35 |
*.parquet
|
| 36 |
*.csv
|
| 37 |
+
/data/
|
| 38 |
+
!frontend/src/utils/data/
|
| 39 |
|
Dockerfile
CHANGED
|
@@ -1,37 +1,33 @@
|
|
| 1 |
-
#
|
| 2 |
FROM python:3.11-slim
|
| 3 |
|
| 4 |
-
#
|
|
|
|
| 5 |
WORKDIR /app
|
| 6 |
|
| 7 |
-
# Install
|
| 8 |
-
RUN apt-get update && apt-get install -y \
|
| 9 |
-
gcc \
|
| 10 |
-
|
| 11 |
-
&& rm -rf /var/lib/apt/lists/*
|
| 12 |
|
| 13 |
-
# Copy requirements first for better caching
|
| 14 |
COPY requirements.txt .
|
| 15 |
-
|
| 16 |
|
| 17 |
-
#
|
| 18 |
-
|
| 19 |
-
pip install --no-cache-dir -r backend/requirements.txt
|
| 20 |
|
| 21 |
-
#
|
| 22 |
-
COPY
|
|
|
|
| 23 |
|
| 24 |
-
#
|
| 25 |
-
|
| 26 |
|
| 27 |
-
# Expose port (will be overridden by PORT env var in cloud platforms)
|
| 28 |
-
EXPOSE 8000
|
| 29 |
-
|
| 30 |
-
# Set environment variables
|
| 31 |
ENV PYTHONUNBUFFERED=1
|
| 32 |
-
ENV PORT=
|
|
|
|
| 33 |
|
| 34 |
-
# Run the application
|
| 35 |
WORKDIR /app/backend
|
| 36 |
-
|
| 37 |
|
|
|
|
|
|
| 1 |
+
# Hugging Face Spaces Docker deployment
|
| 2 |
FROM python:3.11-slim
|
| 3 |
|
| 4 |
+
# Create non-root user (required by HF Spaces)
|
| 5 |
+
RUN useradd -m -u 1000 user
|
| 6 |
WORKDIR /app
|
| 7 |
|
| 8 |
+
# Install dependencies
|
| 9 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 10 |
+
gcc g++ && \
|
| 11 |
+
rm -rf /var/lib/apt/lists/*
|
|
|
|
| 12 |
|
|
|
|
| 13 |
COPY requirements.txt .
|
| 14 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 15 |
|
| 16 |
+
# Copy application
|
| 17 |
+
COPY --chown=user backend/ /app/backend/
|
|
|
|
| 18 |
|
| 19 |
+
# Bundle precomputed data for instant startup
|
| 20 |
+
COPY --chown=user precomputed_data/ /app/precomputed_data/
|
| 21 |
+
COPY --chown=user cache/ /app/cache/
|
| 22 |
|
| 23 |
+
# Switch to non-root user
|
| 24 |
+
USER user
|
| 25 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
ENV PYTHONUNBUFFERED=1
|
| 27 |
+
ENV PORT=7860
|
| 28 |
+
ENV ALLOW_ALL_ORIGINS=true
|
| 29 |
|
|
|
|
| 30 |
WORKDIR /app/backend
|
| 31 |
+
EXPOSE 7860
|
| 32 |
|
| 33 |
+
CMD ["uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "7860"]
|
frontend/src/utils/data/binaryFilter.ts
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Client-side filtering engine for binary dataset.
|
| 3 |
+
* Works with TypedArrays for fast vectorized operations.
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
export interface ModelData {
|
| 7 |
+
x: Float32Array;
|
| 8 |
+
y: Float32Array;
|
| 9 |
+
z: Float32Array;
|
| 10 |
+
domainId: Uint8Array;
|
| 11 |
+
licenseId: Uint8Array;
|
| 12 |
+
familyId: Uint16Array;
|
| 13 |
+
flags: Uint8Array;
|
| 14 |
+
modelIds: string[];
|
| 15 |
+
domains: string[];
|
| 16 |
+
licenses: string[];
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
export interface FilterCriteria {
|
| 20 |
+
domains?: string[];
|
| 21 |
+
licenses?: string[];
|
| 22 |
+
isBaseModel?: boolean | null;
|
| 23 |
+
families?: number[];
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
/**
|
| 27 |
+
* Create a boolean mask based on filter criteria.
|
| 28 |
+
* Returns Uint8Array where 1 = included, 0 = excluded.
|
| 29 |
+
*/
|
| 30 |
+
export function createFilterMask(
|
| 31 |
+
data: ModelData,
|
| 32 |
+
criteria: FilterCriteria
|
| 33 |
+
): Uint8Array {
|
| 34 |
+
const numModels = data.x.length;
|
| 35 |
+
const mask = new Uint8Array(numModels);
|
| 36 |
+
|
| 37 |
+
// Initialize all to 1 (included)
|
| 38 |
+
mask.fill(1);
|
| 39 |
+
|
| 40 |
+
// Filter by domain
|
| 41 |
+
if (criteria.domains && criteria.domains.length > 0) {
|
| 42 |
+
const domainSet = new Set(criteria.domains);
|
| 43 |
+
for (let i = 0; i < numModels; i++) {
|
| 44 |
+
const domain = data.domains[data.domainId[i]];
|
| 45 |
+
if (!domainSet.has(domain)) {
|
| 46 |
+
mask[i] = 0;
|
| 47 |
+
}
|
| 48 |
+
}
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
// Filter by license
|
| 52 |
+
if (criteria.licenses && criteria.licenses.length > 0) {
|
| 53 |
+
const licenseSet = new Set(criteria.licenses);
|
| 54 |
+
for (let i = 0; i < numModels; i++) {
|
| 55 |
+
const license = data.licenses[data.licenseId[i]];
|
| 56 |
+
if (!licenseSet.has(license)) {
|
| 57 |
+
mask[i] = 0;
|
| 58 |
+
}
|
| 59 |
+
}
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
// Filter by base model flag
|
| 63 |
+
if (criteria.isBaseModel !== undefined && criteria.isBaseModel !== null) {
|
| 64 |
+
for (let i = 0; i < numModels; i++) {
|
| 65 |
+
const isBase = (data.flags[i] & 0x01) !== 0;
|
| 66 |
+
if (isBase !== criteria.isBaseModel) {
|
| 67 |
+
mask[i] = 0;
|
| 68 |
+
}
|
| 69 |
+
}
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
// Filter by family
|
| 73 |
+
if (criteria.families && criteria.families.length > 0) {
|
| 74 |
+
const familySet = new Set(criteria.families);
|
| 75 |
+
for (let i = 0; i < numModels; i++) {
|
| 76 |
+
if (!familySet.has(data.familyId[i])) {
|
| 77 |
+
mask[i] = 0;
|
| 78 |
+
}
|
| 79 |
+
}
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
return mask;
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
/**
|
| 86 |
+
* Apply filter mask to data and return filtered arrays.
|
| 87 |
+
*/
|
| 88 |
+
export function applyMask(
|
| 89 |
+
data: ModelData,
|
| 90 |
+
mask: Uint8Array
|
| 91 |
+
): {
|
| 92 |
+
x: Float32Array;
|
| 93 |
+
y: Float32Array;
|
| 94 |
+
z: Float32Array;
|
| 95 |
+
indices: number[];
|
| 96 |
+
count: number;
|
| 97 |
+
} {
|
| 98 |
+
const count = mask.reduce((sum, val) => sum + val, 0);
|
| 99 |
+
const x = new Float32Array(count);
|
| 100 |
+
const y = new Float32Array(count);
|
| 101 |
+
const z = new Float32Array(count);
|
| 102 |
+
const indices: number[] = [];
|
| 103 |
+
|
| 104 |
+
let j = 0;
|
| 105 |
+
for (let i = 0; i < mask.length; i++) {
|
| 106 |
+
if (mask[i] === 1) {
|
| 107 |
+
x[j] = data.x[i];
|
| 108 |
+
y[j] = data.y[i];
|
| 109 |
+
z[j] = data.z[i];
|
| 110 |
+
indices.push(i);
|
| 111 |
+
j++;
|
| 112 |
+
}
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
return { x, y, z, indices, count };
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
/**
|
| 119 |
+
* Get model ID for a given index.
|
| 120 |
+
*/
|
| 121 |
+
export function getModelId(data: ModelData, index: number): string {
|
| 122 |
+
return data.modelIds[index] || '';
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
/**
|
| 126 |
+
* Get domain name for a given index.
|
| 127 |
+
*/
|
| 128 |
+
export function getDomain(data: ModelData, index: number): string {
|
| 129 |
+
return data.domains[data.domainId[index]] || '';
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
/**
|
| 133 |
+
* Get license name for a given index.
|
| 134 |
+
*/
|
| 135 |
+
export function getLicense(data: ModelData, index: number): string {
|
| 136 |
+
return data.licenses[data.licenseId[index]] || '';
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
/**
|
| 140 |
+
* Check if model is a base model (no parent).
|
| 141 |
+
*/
|
| 142 |
+
export function isBaseModel(data: ModelData, index: number): boolean {
|
| 143 |
+
return (data.flags[index] & 0x01) !== 0;
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
/**
|
| 147 |
+
* Check if model has children.
|
| 148 |
+
*/
|
| 149 |
+
export function hasChildren(data: ModelData, index: number): boolean {
|
| 150 |
+
return (data.flags[index] & 0x04) !== 0;
|
| 151 |
+
}
|
| 152 |
+
|
frontend/src/utils/data/enhancedCache.ts
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Enhanced IndexedDB cache with TTL and automatic cleanup.
|
| 3 |
+
* Uses idb library for better reliability and performance.
|
| 4 |
+
*/
|
| 5 |
+
import { openDB, DBSchema, IDBPDatabase } from 'idb';
|
| 6 |
+
|
| 7 |
+
interface CacheEntry {
|
| 8 |
+
key: string;
|
| 9 |
+
data: any;
|
| 10 |
+
timestamp: number;
|
| 11 |
+
ttl: number;
|
| 12 |
+
size?: number;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
interface CacheDB extends DBSchema {
|
| 16 |
+
cache: {
|
| 17 |
+
key: string;
|
| 18 |
+
value: CacheEntry;
|
| 19 |
+
indexes: { 'by-timestamp': number };
|
| 20 |
+
};
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
class EnhancedIndexedDBCache {
|
| 24 |
+
private db: IDBPDatabase<CacheDB> | null = null;
|
| 25 |
+
private dbName = 'hfviz-cache-v2';
|
| 26 |
+
private readonly storeName: 'cache' = 'cache';
|
| 27 |
+
private maxSize = 100 * 1024 * 1024; // 100MB
|
| 28 |
+
private defaultTTL = 5 * 60 * 1000; // 5 minutes
|
| 29 |
+
|
| 30 |
+
async init(): Promise<void> {
|
| 31 |
+
try {
|
| 32 |
+
this.db = await openDB<CacheDB>(this.dbName, 2, {
|
| 33 |
+
upgrade(db, oldVersion) {
|
| 34 |
+
if (!db.objectStoreNames.contains('cache')) {
|
| 35 |
+
const store = db.createObjectStore('cache', { keyPath: 'key' });
|
| 36 |
+
store.createIndex('by-timestamp', 'timestamp');
|
| 37 |
+
}
|
| 38 |
+
},
|
| 39 |
+
});
|
| 40 |
+
|
| 41 |
+
// Cleanup on init
|
| 42 |
+
await this.cleanup();
|
| 43 |
+
} catch (err) {
|
| 44 |
+
console.error('Failed to initialize IndexedDB:', err);
|
| 45 |
+
}
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
async get<T = any>(key: string): Promise<T | null> {
|
| 49 |
+
if (!this.db) await this.init();
|
| 50 |
+
if (!this.db) return null;
|
| 51 |
+
|
| 52 |
+
try {
|
| 53 |
+
const entry = await this.db.get(this.storeName, key);
|
| 54 |
+
|
| 55 |
+
if (!entry) return null;
|
| 56 |
+
|
| 57 |
+
// Check if expired
|
| 58 |
+
if (Date.now() - entry.timestamp > entry.ttl) {
|
| 59 |
+
await this.delete(key);
|
| 60 |
+
return null;
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
return entry.data as T;
|
| 64 |
+
} catch (err) {
|
| 65 |
+
console.error('IndexedDB get error:', err);
|
| 66 |
+
return null;
|
| 67 |
+
}
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
async set(key: string, data: any, ttl: number = this.defaultTTL): Promise<boolean> {
|
| 71 |
+
if (!this.db) await this.init();
|
| 72 |
+
if (!this.db) return false;
|
| 73 |
+
|
| 74 |
+
try {
|
| 75 |
+
// Estimate size
|
| 76 |
+
const size = new Blob([JSON.stringify(data)]).size;
|
| 77 |
+
|
| 78 |
+
// Check if we need to make space
|
| 79 |
+
await this.ensureSpace(size);
|
| 80 |
+
|
| 81 |
+
const entry: CacheEntry = {
|
| 82 |
+
key,
|
| 83 |
+
data,
|
| 84 |
+
timestamp: Date.now(),
|
| 85 |
+
ttl,
|
| 86 |
+
size,
|
| 87 |
+
};
|
| 88 |
+
|
| 89 |
+
await this.db.put(this.storeName, entry);
|
| 90 |
+
return true;
|
| 91 |
+
} catch (err) {
|
| 92 |
+
console.error('IndexedDB set error:', err);
|
| 93 |
+
return false;
|
| 94 |
+
}
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
async delete(key: string): Promise<boolean> {
|
| 98 |
+
if (!this.db) await this.init();
|
| 99 |
+
if (!this.db) return false;
|
| 100 |
+
|
| 101 |
+
try {
|
| 102 |
+
await this.db.delete(this.storeName, key);
|
| 103 |
+
return true;
|
| 104 |
+
} catch (err) {
|
| 105 |
+
console.error('IndexedDB delete error:', err);
|
| 106 |
+
return false;
|
| 107 |
+
}
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
async clear(): Promise<boolean> {
|
| 111 |
+
if (!this.db) await this.init();
|
| 112 |
+
if (!this.db) return false;
|
| 113 |
+
|
| 114 |
+
try {
|
| 115 |
+
await this.db.clear(this.storeName);
|
| 116 |
+
return true;
|
| 117 |
+
} catch (err) {
|
| 118 |
+
console.error('IndexedDB clear error:', err);
|
| 119 |
+
return false;
|
| 120 |
+
}
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
async cleanup(): Promise<void> {
|
| 124 |
+
if (!this.db) return;
|
| 125 |
+
|
| 126 |
+
try {
|
| 127 |
+
const tx = this.db.transaction(this.storeName, 'readwrite');
|
| 128 |
+
const store = tx.objectStore(this.storeName);
|
| 129 |
+
const now = Date.now();
|
| 130 |
+
|
| 131 |
+
let cursor = await store.openCursor();
|
| 132 |
+
while (cursor) {
|
| 133 |
+
const entry = cursor.value;
|
| 134 |
+
if (now - entry.timestamp > entry.ttl) {
|
| 135 |
+
await cursor.delete();
|
| 136 |
+
}
|
| 137 |
+
cursor = await cursor.continue();
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
await tx.done;
|
| 141 |
+
} catch (err) {
|
| 142 |
+
console.error('IndexedDB cleanup error:', err);
|
| 143 |
+
}
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
async ensureSpace(requiredSize: number): Promise<void> {
|
| 147 |
+
if (!this.db) return;
|
| 148 |
+
|
| 149 |
+
try {
|
| 150 |
+
// Get all entries sorted by timestamp
|
| 151 |
+
const tx = this.db.transaction(this.storeName, 'readwrite');
|
| 152 |
+
const index = tx.objectStore(this.storeName).index('by-timestamp');
|
| 153 |
+
const entries: CacheEntry[] = [];
|
| 154 |
+
|
| 155 |
+
let cursor = await index.openCursor();
|
| 156 |
+
while (cursor) {
|
| 157 |
+
entries.push(cursor.value);
|
| 158 |
+
cursor = await cursor.continue();
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
await tx.done;
|
| 162 |
+
|
| 163 |
+
// Calculate total size
|
| 164 |
+
let totalSize = entries.reduce((sum, e) => sum + (e.size || 0), 0);
|
| 165 |
+
|
| 166 |
+
// Remove oldest entries if needed
|
| 167 |
+
if (totalSize + requiredSize > this.maxSize) {
|
| 168 |
+
const sorted = entries.sort((a, b) => a.timestamp - b.timestamp);
|
| 169 |
+
|
| 170 |
+
for (const entry of sorted) {
|
| 171 |
+
if (totalSize + requiredSize <= this.maxSize) break;
|
| 172 |
+
await this.delete(entry.key);
|
| 173 |
+
totalSize -= (entry.size || 0);
|
| 174 |
+
}
|
| 175 |
+
}
|
| 176 |
+
} catch (err) {
|
| 177 |
+
console.error('IndexedDB ensureSpace error:', err);
|
| 178 |
+
}
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
async stats(): Promise<{ count: number; totalSize: number }> {
|
| 182 |
+
if (!this.db) await this.init();
|
| 183 |
+
if (!this.db) return { count: 0, totalSize: 0 };
|
| 184 |
+
|
| 185 |
+
try {
|
| 186 |
+
const tx = this.db.transaction(this.storeName, 'readonly');
|
| 187 |
+
const store = tx.objectStore(this.storeName);
|
| 188 |
+
|
| 189 |
+
let count = 0;
|
| 190 |
+
let totalSize = 0;
|
| 191 |
+
|
| 192 |
+
let cursor = await store.openCursor();
|
| 193 |
+
while (cursor) {
|
| 194 |
+
count++;
|
| 195 |
+
totalSize += cursor.value.size || 0;
|
| 196 |
+
cursor = await cursor.continue();
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
await tx.done;
|
| 200 |
+
return { count, totalSize };
|
| 201 |
+
} catch (err) {
|
| 202 |
+
console.error('IndexedDB stats error:', err);
|
| 203 |
+
return { count: 0, totalSize: 0 };
|
| 204 |
+
}
|
| 205 |
+
}
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
// Export singleton
|
| 209 |
+
export const enhancedCache = new EnhancedIndexedDBCache();
|
| 210 |
+
|
| 211 |
+
// Auto-cleanup every 5 minutes
|
| 212 |
+
if (typeof window !== 'undefined') {
|
| 213 |
+
setInterval(() => {
|
| 214 |
+
enhancedCache.cleanup();
|
| 215 |
+
}, 5 * 60 * 1000);
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
export default enhancedCache;
|
| 219 |
+
|
| 220 |
+
|
frontend/src/utils/data/indexedDB.ts
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* IndexedDB utility for client-side caching of embeddings and model data.
|
| 3 |
+
* Enables offline access and faster subsequent loads.
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
const DB_NAME = 'hf_viz_cache';
|
| 7 |
+
const DB_VERSION = 1;
|
| 8 |
+
|
| 9 |
+
interface CacheEntry<T> {
|
| 10 |
+
key: string;
|
| 11 |
+
data: T;
|
| 12 |
+
timestamp: number;
|
| 13 |
+
version: string;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
const CACHE_VERSION = '1.0.0'; // Increment to invalidate old cache
|
| 17 |
+
const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
| 18 |
+
const MAX_CACHE_SIZE = 100; // Maximum number of entries per store
|
| 19 |
+
|
| 20 |
+
class IndexedDBCache {
|
| 21 |
+
private db: IDBDatabase | null = null;
|
| 22 |
+
private initPromise: Promise<void> | null = null;
|
| 23 |
+
|
| 24 |
+
private async init(): Promise<void> {
|
| 25 |
+
if (this.db) return;
|
| 26 |
+
if (this.initPromise) return this.initPromise;
|
| 27 |
+
|
| 28 |
+
this.initPromise = new Promise((resolve, reject) => {
|
| 29 |
+
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
| 30 |
+
|
| 31 |
+
request.onerror = () => reject(request.error);
|
| 32 |
+
request.onsuccess = () => {
|
| 33 |
+
this.db = request.result;
|
| 34 |
+
resolve();
|
| 35 |
+
};
|
| 36 |
+
|
| 37 |
+
request.onupgradeneeded = (event) => {
|
| 38 |
+
const db = (event.target as IDBOpenDBRequest).result;
|
| 39 |
+
|
| 40 |
+
// Create object stores
|
| 41 |
+
if (!db.objectStoreNames.contains('embeddings')) {
|
| 42 |
+
db.createObjectStore('embeddings', { keyPath: 'key' });
|
| 43 |
+
}
|
| 44 |
+
if (!db.objectStoreNames.contains('reduced_embeddings')) {
|
| 45 |
+
db.createObjectStore('reduced_embeddings', { keyPath: 'key' });
|
| 46 |
+
}
|
| 47 |
+
if (!db.objectStoreNames.contains('models')) {
|
| 48 |
+
db.createObjectStore('models', { keyPath: 'key' });
|
| 49 |
+
}
|
| 50 |
+
if (!db.objectStoreNames.contains('stats')) {
|
| 51 |
+
db.createObjectStore('stats', { keyPath: 'key' });
|
| 52 |
+
}
|
| 53 |
+
};
|
| 54 |
+
});
|
| 55 |
+
|
| 56 |
+
return this.initPromise;
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
private async getStore(storeName: string, mode: 'readonly' | 'readwrite' = 'readonly'): Promise<IDBObjectStore> {
|
| 60 |
+
await this.init();
|
| 61 |
+
if (!this.db) throw new Error('Database not initialized');
|
| 62 |
+
|
| 63 |
+
const transaction = this.db.transaction([storeName], mode);
|
| 64 |
+
return transaction.objectStore(storeName);
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
async set<T>(storeName: string, key: string, data: T): Promise<void> {
|
| 68 |
+
await this.init();
|
| 69 |
+
if (!this.db) throw new Error('Database not initialized');
|
| 70 |
+
|
| 71 |
+
// Prepare entry data
|
| 72 |
+
const entry: CacheEntry<T> = {
|
| 73 |
+
key,
|
| 74 |
+
data,
|
| 75 |
+
timestamp: Date.now(),
|
| 76 |
+
version: CACHE_VERSION,
|
| 77 |
+
};
|
| 78 |
+
|
| 79 |
+
// Create a single transaction for all operations
|
| 80 |
+
const transaction = this.db.transaction([storeName], 'readwrite');
|
| 81 |
+
const store = transaction.objectStore(storeName);
|
| 82 |
+
|
| 83 |
+
// Queue the put operation FIRST to ensure transaction stays alive
|
| 84 |
+
// This is critical - by queueing it immediately, we ensure the transaction
|
| 85 |
+
// has a pending request throughout all other operations
|
| 86 |
+
const putPromise = new Promise<void>((resolve, reject) => {
|
| 87 |
+
const request = store.put(entry);
|
| 88 |
+
request.onsuccess = () => resolve();
|
| 89 |
+
request.onerror = () => reject(request.error);
|
| 90 |
+
});
|
| 91 |
+
|
| 92 |
+
// Enforce cache size limit - delete oldest entries if at limit
|
| 93 |
+
const count = await new Promise<number>((resolve, reject) => {
|
| 94 |
+
const countRequest = store.count();
|
| 95 |
+
countRequest.onsuccess = () => resolve(countRequest.result);
|
| 96 |
+
countRequest.onerror = () => reject(countRequest.error);
|
| 97 |
+
});
|
| 98 |
+
|
| 99 |
+
// Queue delete operations if needed
|
| 100 |
+
const deletePromises: Promise<void>[] = [];
|
| 101 |
+
|
| 102 |
+
if (count >= MAX_CACHE_SIZE) {
|
| 103 |
+
// Get all entries, sort by timestamp, delete oldest
|
| 104 |
+
const allEntries: Array<{ key: string; timestamp: number }> = [];
|
| 105 |
+
const getAllRequest = store.openCursor();
|
| 106 |
+
|
| 107 |
+
await new Promise<void>((resolve, reject) => {
|
| 108 |
+
getAllRequest.onsuccess = (event: any) => {
|
| 109 |
+
const cursor = event.target.result;
|
| 110 |
+
if (cursor) {
|
| 111 |
+
const entry = cursor.value as CacheEntry<T>;
|
| 112 |
+
allEntries.push({ key: entry.key, timestamp: entry.timestamp });
|
| 113 |
+
cursor.continue();
|
| 114 |
+
} else {
|
| 115 |
+
resolve();
|
| 116 |
+
}
|
| 117 |
+
};
|
| 118 |
+
getAllRequest.onerror = () => reject(getAllRequest.error);
|
| 119 |
+
});
|
| 120 |
+
|
| 121 |
+
// Sort by timestamp (oldest first) and delete excess
|
| 122 |
+
allEntries.sort((a, b) => a.timestamp - b.timestamp);
|
| 123 |
+
const toDelete = allEntries.slice(0, count - MAX_CACHE_SIZE + 1);
|
| 124 |
+
|
| 125 |
+
// Queue all delete operations immediately
|
| 126 |
+
for (const entryToDelete of toDelete) {
|
| 127 |
+
deletePromises.push(
|
| 128 |
+
new Promise<void>((resolve, reject) => {
|
| 129 |
+
const deleteRequest = store.delete(entryToDelete.key);
|
| 130 |
+
deleteRequest.onsuccess = () => resolve();
|
| 131 |
+
deleteRequest.onerror = () => reject(deleteRequest.error);
|
| 132 |
+
})
|
| 133 |
+
);
|
| 134 |
+
}
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
// Wait for both deletes (if any) and put to complete
|
| 138 |
+
// The put request was queued first, so transaction stays alive throughout
|
| 139 |
+
await Promise.all([...deletePromises, putPromise]);
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
async get<T>(storeName: string, key: string): Promise<T | null> {
|
| 143 |
+
const store = await this.getStore(storeName);
|
| 144 |
+
|
| 145 |
+
return new Promise((resolve, reject) => {
|
| 146 |
+
const request = store.get(key);
|
| 147 |
+
request.onsuccess = async () => {
|
| 148 |
+
const entry = request.result as CacheEntry<T> | undefined;
|
| 149 |
+
if (!entry) {
|
| 150 |
+
resolve(null);
|
| 151 |
+
return;
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
// Check version
|
| 155 |
+
if (entry.version !== CACHE_VERSION) {
|
| 156 |
+
// Version mismatch, delete old entry using readwrite transaction
|
| 157 |
+
try {
|
| 158 |
+
await this.delete(storeName, key);
|
| 159 |
+
} catch (err) {
|
| 160 |
+
// Ignore delete errors
|
| 161 |
+
}
|
| 162 |
+
resolve(null);
|
| 163 |
+
return;
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
// Check TTL
|
| 167 |
+
const age = Date.now() - entry.timestamp;
|
| 168 |
+
if (age > CACHE_TTL_MS) {
|
| 169 |
+
// Cache expired, delete old entry using readwrite transaction
|
| 170 |
+
try {
|
| 171 |
+
await this.delete(storeName, key);
|
| 172 |
+
} catch (err) {
|
| 173 |
+
// Ignore delete errors
|
| 174 |
+
}
|
| 175 |
+
resolve(null);
|
| 176 |
+
return;
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
resolve(entry.data);
|
| 180 |
+
};
|
| 181 |
+
request.onerror = () => reject(request.error);
|
| 182 |
+
});
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
async has(storeName: string, key: string): Promise<boolean> {
|
| 186 |
+
const store = await this.getStore(storeName);
|
| 187 |
+
|
| 188 |
+
return new Promise((resolve, reject) => {
|
| 189 |
+
const request = store.getKey(key);
|
| 190 |
+
request.onsuccess = () => resolve(request.result !== undefined);
|
| 191 |
+
request.onerror = () => reject(request.error);
|
| 192 |
+
});
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
async delete(storeName: string, key: string): Promise<void> {
|
| 196 |
+
const store = await this.getStore(storeName, 'readwrite');
|
| 197 |
+
|
| 198 |
+
return new Promise((resolve, reject) => {
|
| 199 |
+
const request = store.delete(key);
|
| 200 |
+
request.onsuccess = () => resolve();
|
| 201 |
+
request.onerror = () => reject(request.error);
|
| 202 |
+
});
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
async clear(storeName: string): Promise<void> {
|
| 206 |
+
const store = await this.getStore(storeName, 'readwrite');
|
| 207 |
+
|
| 208 |
+
return new Promise((resolve, reject) => {
|
| 209 |
+
const request = store.clear();
|
| 210 |
+
request.onsuccess = () => resolve();
|
| 211 |
+
request.onerror = () => reject(request.error);
|
| 212 |
+
});
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
async getCacheSize(storeName: string): Promise<number> {
|
| 216 |
+
const store = await this.getStore(storeName);
|
| 217 |
+
|
| 218 |
+
return new Promise((resolve, reject) => {
|
| 219 |
+
const request = store.count();
|
| 220 |
+
request.onsuccess = () => resolve(request.result);
|
| 221 |
+
request.onerror = () => reject(request.error);
|
| 222 |
+
});
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
// Helper methods for specific data types
|
| 226 |
+
async cacheModels(key: string, models: any[]): Promise<void> {
|
| 227 |
+
return this.set('models', key, models);
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
async getCachedModels(key: string): Promise<any[] | null> {
|
| 231 |
+
return this.get<any[]>('models', key);
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
async cacheStats(key: string, stats: any): Promise<void> {
|
| 235 |
+
return this.set('stats', key, stats);
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
async getCachedStats(key: string): Promise<any | null> {
|
| 239 |
+
return this.get<any>('stats', key);
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
// Generate cache key from filter parameters
|
| 243 |
+
static generateCacheKey(params: {
|
| 244 |
+
minDownloads: number;
|
| 245 |
+
minLikes: number;
|
| 246 |
+
searchQuery?: string;
|
| 247 |
+
projectionMethod: string;
|
| 248 |
+
}): string {
|
| 249 |
+
return JSON.stringify({
|
| 250 |
+
minDownloads: params.minDownloads,
|
| 251 |
+
minLikes: params.minLikes,
|
| 252 |
+
searchQuery: params.searchQuery || '',
|
| 253 |
+
projectionMethod: params.projectionMethod,
|
| 254 |
+
});
|
| 255 |
+
}
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
// Export singleton instance
|
| 259 |
+
export const cache = new IndexedDBCache();
|
| 260 |
+
export { IndexedDBCache };
|
| 261 |
+
export default cache;
|
| 262 |
+
|
netlify.toml
CHANGED
|
@@ -5,8 +5,6 @@
|
|
| 5 |
|
| 6 |
[build.environment]
|
| 7 |
NODE_VERSION = "18"
|
| 8 |
-
# Set your Railway backend URL in Netlify dashboard:
|
| 9 |
-
# Site settings → Environment variables → REACT_APP_API_URL
|
| 10 |
|
| 11 |
[[redirects]]
|
| 12 |
from = "/*"
|
|
@@ -21,9 +19,7 @@
|
|
| 21 |
X-Content-Type-Options = "nosniff"
|
| 22 |
Referrer-Policy = "strict-origin-when-cross-origin"
|
| 23 |
|
| 24 |
-
# Cache static assets aggressively
|
| 25 |
[[headers]]
|
| 26 |
for = "/static/*"
|
| 27 |
[headers.values]
|
| 28 |
Cache-Control = "public, max-age=31536000, immutable"
|
| 29 |
-
|
|
|
|
| 5 |
|
| 6 |
[build.environment]
|
| 7 |
NODE_VERSION = "18"
|
|
|
|
|
|
|
| 8 |
|
| 9 |
[[redirects]]
|
| 10 |
from = "/*"
|
|
|
|
| 19 |
X-Content-Type-Options = "nosniff"
|
| 20 |
Referrer-Policy = "strict-origin-when-cross-origin"
|
| 21 |
|
|
|
|
| 22 |
[[headers]]
|
| 23 |
for = "/static/*"
|
| 24 |
[headers.values]
|
| 25 |
Cache-Control = "public, max-age=31536000, immutable"
|
|
|