diff --git a/.figures/dashboard.gif b/.figures/dashboard.gif new file mode 100644 index 0000000..75425cc Binary files /dev/null and b/.figures/dashboard.gif differ diff --git a/.figures/minio_service.gif b/.figures/minio_service.gif index 196b9d6..9260dfe 100644 Binary files a/.figures/minio_service.gif and b/.figures/minio_service.gif differ diff --git a/.figures/streamlit_dashboard.gif b/.figures/streamlit_dashboard.gif deleted file mode 100644 index cd84c59..0000000 Binary files a/.figures/streamlit_dashboard.gif and /dev/null differ diff --git a/README.md b/README.md index 7f08834..3d89d86 100644 --- a/README.md +++ b/README.md @@ -36,17 +36,17 @@ For a comprehensive description of the compilation, data processing, and scienti Use the interactive dashboard to select and view systems.

- Animated GIF of the slcomp interactive dashboard + Animated GIF of the slcomp interactive dashboard

-➡️ **[Dashboard Link](https://slcomp-public.streamlit.app)** +➡️ **[Dashboard Link](https://cosmoobs.github.io/slcomp)** Filter by `JNAME` or reference and inspect image cutouts. ## Download Data Tabular data and processed image cutouts are available for download.

- Animated GIF of data download from the MinIO service + Animated GIF of data download from the MinIO service

➡️ **[MinIO Service Link](https://ruggedly-quaky-maricruz.ngrok-free.app/login)** diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index d3a1de9..708526a 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -32,6 +32,7 @@ "eslint-plugin-react-hooks": "^5.1.0", "eslint-plugin-react-refresh": "^0.4.9", "globals": "^16.3.0", + "terser": "^5.44.0", "typescript": "^5.5.4", "typescript-eslint": "^8.42.0", "vite": "^5.4.2" @@ -1181,6 +1182,17 @@ "node": ">=6.0.0" } }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -2696,6 +2708,13 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -2822,6 +2841,13 @@ "dev": true, "license": "MIT" }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT" + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -5609,6 +5635,27 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/source-map-support/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -5765,6 +5812,25 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/terser": { + "version": "5.44.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.44.0.tgz", + "integrity": "sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", diff --git a/dashboard/package.json b/dashboard/package.json index 383f553..ec79157 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -34,6 +34,7 @@ "eslint-plugin-react-hooks": "^5.1.0", "eslint-plugin-react-refresh": "^0.4.9", "globals": "^16.3.0", + "terser": "^5.44.0", "typescript": "^5.5.4", "typescript-eslint": "^8.42.0", "vite": "^5.4.2" diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx index ddcdd46..8da1eb1 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -1,5 +1,5 @@ import React, { useMemo, useState, lazy, Suspense, useCallback } from 'react'; -import { AppBar, Box, Container, Tab, Tabs, Toolbar, Typography, Paper, IconButton, Tooltip, Button, CircularProgress } from '@mui/material'; +import { AppBar, Box, Container, Tab, Tabs, Toolbar, Typography, Paper, IconButton, Tooltip, Button, CircularProgress, useMediaQuery, useTheme } from '@mui/material'; import FilterAltIcon from '@mui/icons-material/FilterAlt'; import ClearAllIcon from '@mui/icons-material/ClearAll'; import { useQuery } from '@tanstack/react-query'; @@ -42,7 +42,7 @@ const App: React.FC = () => { const [filters, setFilters] = useState(initialFilters); const [tab, setTab] = useState(0); - // Derive numeric domains for selected fields + // Derive numeric domains for selected fields (memoized with cache) const numericFields = useMemo(() => [ { key: 'RA', label: 'RA' }, { key: 'DEC', label: 'DEC' }, @@ -51,10 +51,15 @@ const App: React.FC = () => { ], []); const domain = useMemo(()=>{ + if (!database.length) return {}; + const acc: Record = {}; numericFields.forEach(f=>{ const vals: number[] = []; - for(const r of database){ + // Sample every 10th record for large datasets to speed up domain calculation + const step = database.length > 10000 ? 10 : 1; + for(let i = 0; i < database.length; i += step){ + const r = database[i]; if(!r) continue; let v: unknown = r[f.key]; if(typeof v === 'string'){ const parsed = parseFloat(v); if(!isNaN(parsed)) v = parsed; } @@ -65,21 +70,26 @@ const App: React.FC = () => { return acc; }, [database, numericFields]); - // Precompute mapping: JNAME -> set of references (for fast filtering) + // Precompute mapping: JNAME -> set of references (for fast filtering) - optimized const jnameToRefs = useMemo(()=> { + if (!references.length) return {}; + const map: Record> = {}; for(const ref of references){ const entry = dictionary[ref] as { JNAME?: string[] }; if(!entry || !Array.isArray(entry.JNAME)) continue; for(const jn of entry.JNAME){ - (map[jn] ||= new Set()).add(ref); + if (!map[jn]) map[jn] = new Set(); + map[jn].add(ref); } } return map; }, [references, dictionary]); - // Unique base objects (collapse duplicates by JNAME, keep first numeric values encountered) + // Unique base objects (collapse duplicates by JNAME) - optimized with early returns const baseObjects = useMemo(()=> { + if (!database.length) return []; + const toNum = (x: unknown): number | null => { if(typeof x === 'number' && !isNaN(x)) return x; if(typeof x === 'string'){ @@ -89,20 +99,23 @@ const App: React.FC = () => { return null; }; const seen: Record = {}; + for(const r of database){ - if(!r || !r.JNAME) continue; - const RA = toNum(r.RA); - const DEC = toNum(r.DEC); - const z_L = toNum(r.z_L); - const z_S = toNum(r.z_S); + if(!r?.JNAME) continue; + if(!seen[r.JNAME]){ + const RA = toNum(r.RA); + const DEC = toNum(r.DEC); + const z_L = toNum(r.z_L); + const z_S = toNum(r.z_S); seen[r.JNAME] = { JNAME: r.JNAME, RA, DEC, z_L, z_S }; } else { + // Only update null values to avoid unnecessary work const tgt = seen[r.JNAME]; - if(tgt.RA == null && RA != null) tgt.RA = RA; - if(tgt.DEC == null && DEC != null) tgt.DEC = DEC; - if(tgt.z_L == null && z_L != null) tgt.z_L = z_L; - if(tgt.z_S == null && z_S != null) tgt.z_S = z_S; + if(tgt.RA == null) tgt.RA = toNum(r.RA); + if(tgt.DEC == null) tgt.DEC = toNum(r.DEC); + if(tgt.z_L == null) tgt.z_L = toNum(r.z_L); + if(tgt.z_S == null) tgt.z_S = toNum(r.z_S); } } return Object.values(seen); @@ -114,29 +127,44 @@ const App: React.FC = () => { // Debounce search text to avoid excessive filtering const debouncedSearch = useDebounce(filters.jnameSearch, 300); + // Optimized filtering with early returns and better algorithms const filteredObjects = useMemo(()=> { if(!baseObjects.length) return []; + const search = debouncedSearch.trim().toLowerCase(); const useRefs = filters.references.length > 0; const refsSet = useRefs ? new Set(filters.references) : null; + const hasNumericFilters = activeNumericKeys.length > 0; + return baseObjects.filter((r: SkyMapObject)=> { - if(!r || !r.JNAME) return false; + if(!r?.JNAME) return false; + + // Text search first (cheapest filter) if(search && !String(r.JNAME).toLowerCase().includes(search)) return false; + + // Reference filter if(useRefs){ const rs = jnameToRefs[String(r.JNAME)]; if(!rs) return false; - let ok = false; - for(const ref of rs){ if(refsSet!.has(ref)){ ok = true; break; } } - if(!ok) return false; + let hasMatchingRef = false; + for(const ref of rs){ + if(refsSet!.has(ref)){ + hasMatchingRef = true; + break; + } + } + if(!hasMatchingRef) return false; } - if(activeNumericKeys.length){ + + // Numeric filters (most expensive, do last) + if(hasNumericFilters){ for(const k of activeNumericKeys){ const range = filters.numeric[k]!; const val = r[k] as number; - if(typeof val !== 'number') return false; - if(val < range[0] || val > range[1]) return false; + if(typeof val !== 'number' || val < range[0] || val > range[1]) return false; } } + return true; }); }, [baseObjects, debouncedSearch, filters.references, filters.numeric, jnameToRefs, activeNumericKeys]); @@ -171,6 +199,31 @@ const App: React.FC = () => { const anyLoading = dbLoading || consLoading || dictLoading || cutoutsLoading; const anyError = dbError || consError || dictError || cutoutsError; + const theme = useTheme(); + const isMdUp = useMediaQuery(theme.breakpoints.up('md')); + const panelHeight = isMdUp ? 360 : 300; // responsive height for map/table + + // Show loading state early to improve perceived performance + if (anyLoading && !database.length) { + return ( + + + + The LaStBeRu Explorer + + + + + + Loading astronomical data... + + This may take a moment for large datasets + + + + + ); + } return ( @@ -202,14 +255,35 @@ const App: React.FC = () => { )} - - + + - + - - + + {jname && } diff --git a/dashboard/src/api.ts b/dashboard/src/api.ts index 4b6a76e..1a0f6ea 100644 --- a/dashboard/src/api.ts +++ b/dashboard/src/api.ts @@ -14,21 +14,62 @@ const buildDataUrl = (file: string) => { return `${base}data/${file}`; }; +// Add request optimization and caching +const requestCache = new Map>(); + async function fetchJson(file: string): Promise { const url = buildDataUrl(file); - const res = await fetch(url, { cache: 'no-cache' }); - if (!res.ok) { - // Surface clearer diagnostics when something goes wrong (like path issues on Pages) - const text = await res.text(); - throw new Error(`Failed to fetch ${url} (HTTP ${res.status}) - First 120 chars: ${text.slice(0, 120)}`); - } - try { - return await res.json(); - } catch (err) { - // Provide snippet of body to aid debugging of unexpected HTML responses - const body = await res.clone().text().catch(() => ''); - throw new Error(`Invalid JSON at ${url}: ${(err as Error).message}. Snippet: ${body.slice(0, 120)}`); + + // Return cached promise if available + if (requestCache.has(url)) { + return requestCache.get(url); } + + const fetchPromise = (async () => { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 30000); // 30s timeout + + try { + const res = await fetch(url, { + cache: 'force-cache', // Use browser cache when available + signal: controller.signal, + headers: { + 'Accept': 'application/json', + 'Cache-Control': 'public, max-age=3600' // Cache for 1 hour + } + }); + + clearTimeout(timeoutId); + + if (!res.ok) { + // Surface clearer diagnostics when something goes wrong (like path issues on Pages) + const text = await res.text(); + throw new Error(`Failed to fetch ${url} (HTTP ${res.status}) - First 120 chars: ${text.slice(0, 120)}`); + } + + const data = await res.json(); + return data; + } catch (err) { + clearTimeout(timeoutId); + + if (err instanceof Error && err.name === 'AbortError') { + throw new Error(`Request timeout for ${url}`); + } + + // Provide snippet of body to aid debugging of unexpected HTML responses + throw new Error(`Failed to load ${url}: ${(err as Error).message}`); + } + })(); + + // Cache the promise + requestCache.set(url, fetchPromise); + + // Remove from cache after completion (success or failure) + fetchPromise.finally(() => { + setTimeout(() => requestCache.delete(url), 5000); // Keep cache for 5s + }); + + return fetchPromise; } // Public data loaders (can be swapped for real API later) diff --git a/dashboard/src/components/CutoutGrid.tsx b/dashboard/src/components/CutoutGrid.tsx index 93ec772..a9cf315 100644 --- a/dashboard/src/components/CutoutGrid.tsx +++ b/dashboard/src/components/CutoutGrid.tsx @@ -37,14 +37,15 @@ const CutoutCard: React.FC<{ record: CutoutRecord }> = memo(({ record }) => { return ( {record.band} - - {isLoading && } - {!isLoading && data && + + {isLoading && } + {!isLoading && data && { const img = e.currentTarget; if(img.dataset.fallbackTried) return; @@ -67,8 +68,9 @@ const CutoutCard: React.FC<{ record: CutoutRecord }> = memo(({ record }) => { if(newSrc !== img.src) img.src = newSrc; } }} />} - {!isLoading && !data && !error && No image} - {error && Error} + {!isLoading && !data && !error && No image} + {error && Error} + {error && Err} diff --git a/dashboard/src/components/FiltersDrawer.tsx b/dashboard/src/components/FiltersDrawer.tsx index d6d8347..631e391 100644 --- a/dashboard/src/components/FiltersDrawer.tsx +++ b/dashboard/src/components/FiltersDrawer.tsx @@ -75,7 +75,21 @@ export const FiltersDrawer: React.FC = ({ open, onClose, allReferences, n }; return ( - + ({ + width: { xs: '100%', sm: 320, md: 360 }, + maxWidth: '100%', + background: 'linear-gradient(180deg,#0d1820,#0a141a)', + display:'flex', + flexDirection:'column', + borderRight: '1px solid rgba(255,255,255,0.08)' + }) + }} + > Filters @@ -97,7 +111,7 @@ export const FiltersDrawer: React.FC = ({ open, onClose, allReferences, n - + {allReferences.map(r => { const active = value.references.includes(r); diff --git a/dashboard/src/components/ObjectsTable.tsx b/dashboard/src/components/ObjectsTable.tsx index fc253da..a73918a 100644 --- a/dashboard/src/components/ObjectsTable.tsx +++ b/dashboard/src/components/ObjectsTable.tsx @@ -16,44 +16,57 @@ interface Props { } export const ObjectsTable: React.FC = memo(({ objects, onSelect, selected, height=360, fullHeight=false }) => { - const rows = useMemo(()=> objects - .filter(o=> !!o && !!o.JNAME) - .map((o,i)=> ({ id: o.JNAME || i, ...o })), [objects]); + // Memoize rows computation to avoid recalculation on every render + const rows = useMemo(()=> { + if (!objects || objects.length === 0) return []; + return objects + .filter(o=> !!o?.JNAME) + .map((o,i)=> ({ id: o.JNAME || i, ...o })); + }, [objects]); + const cols: GridColDef[] = useMemo(()=> [ { field:'JNAME', headerName:'JNAME', flex:1, minWidth:160 } ], []); - // Pagination state - const [pageSize, setPageSize] = useState(100); // Aumentado de 80 para 100 + // NOTE: DataGrid MIT version limits pageSize to 100 (larger requires Pro/Premium) + // Keep state capped at 100 to avoid runtime errors. + const [pageSize, setPageSize] = useState(100); const [page, setPage] = useState(0); - // Otimização: usar useCallback para evitar re-criação da função + // Optimize callbacks with useCallback and dependency arrays const handleRowClick = useCallback((params: any) => { onSelect(params.row.JNAME); }, [onSelect]); const handlePaginationChange = useCallback((model: any) => { - setPage(model.page); - setPageSize(model.pageSize); - }, []); + if (model.page !== page) setPage(model.page); + if (model.pageSize !== pageSize) { + // Guard against attempts to exceed MIT cap + const safeSize = Math.min(100, model.pageSize || 100); + setPageSize(safeSize); + } + }, [page, pageSize]); + + // Optimized selection following with reduced re-computation + const selectedRowIndex = useMemo(() => { + if (!selected || !rows.length) return -1; + return rows.findIndex(r => r.JNAME === selected); + }, [selected, rows]); - // Ensure selected row visible by jumping to its page when selection changes - useEffect(()=> { - if(!selected) return; - const idx = rows.findIndex(r=> r.JNAME === selected); - if(idx >=0){ - const newPage = Math.floor(idx / pageSize); - if(newPage !== page) setPage(newPage); + useEffect(() => { + if (selectedRowIndex >= 0) { + const newPage = Math.floor(selectedRowIndex / pageSize); + if (newPage !== page) setPage(newPage); } - }, [selected, rows, pageSize, page]); + }, [selectedRowIndex, pageSize, page]); - const header = ( + const header = useMemo(() => ( - Filtered Objects + Filtered Objects ({objects.length}) - ); + ), [objects.length]); - // Otimização: memoizar props comuns do DataGrid + // Memoize DataGrid props to prevent unnecessary re-renders const dataGridProps = useMemo(() => ({ rows, columns: cols, @@ -65,11 +78,34 @@ export const ObjectsTable: React.FC = memo(({ objects, onSelect, selected getRowClassName: (params: any) => params.row.JNAME === selected ? 'selected-row' : '', paginationModel: { page, pageSize }, onPaginationModelChange: handlePaginationChange, + // Performance optimizations + disableVirtualization: false, // Keep virtualization enabled + rowBufferPx: 100, // Reduce buffer for better performance + columnBufferPx: 100, }), [rows, cols, handleRowClick, selected, page, pageSize, handlePaginationChange]); + // Memoize styles to prevent recalculation + const dataGridStyles = useMemo(() => ({ + '& .MuiDataGrid-virtualScroller': { overflowX:'hidden' }, + '& .MuiDataGrid-cell': { fontSize:12 }, + '& .MuiDataGrid-columnHeaders': { background:'rgba(255,255,255,0.04)', backdropFilter:'blur(6px)' }, + '& .selected-row .MuiDataGrid-cell': { background:'rgba(0,120,180,0.25)!important' }, + '& .MuiDataGrid-row:hover': { background:'rgba(255,255,255,0.02)' } + }), []); + + const paperStyles = useMemo(() => ({ + display:'flex', + flexDirection:'column', + p:1, + height:'100%', + background:'rgba(255,255,255,0.02)', + backdropFilter:'blur(4px)', + border:'1px solid rgba(255,255,255,0.05)' + }), []); + if(fullHeight){ return ( - + {header}
= memo(({ objects, onSelect, selected pagination sx={{ height:'100%', - '& .MuiDataGrid-virtualScroller': { overflowX:'hidden' }, - '& .MuiDataGrid-cell': { fontSize:12 }, - '& .MuiDataGrid-columnHeaders': { background:'rgba(255,255,255,0.04)', backdropFilter:'blur(6px)' }, - '& .selected-row .MuiDataGrid-cell': { background:'rgba(0,120,180,0.25)!important' } + ...dataGridStyles }} />
@@ -96,12 +129,9 @@ export const ObjectsTable: React.FC = memo(({ objects, onSelect, selected
diff --git a/dashboard/src/components/PerformanceMonitor.tsx b/dashboard/src/components/PerformanceMonitor.tsx new file mode 100644 index 0000000..03433ef --- /dev/null +++ b/dashboard/src/components/PerformanceMonitor.tsx @@ -0,0 +1,103 @@ +import React, { useEffect, useState, memo } from 'react'; +import { Typography, Box, Collapse } from '@mui/material'; + +interface PerformanceStats { + renderTime: number; + dataLoadTime: number; + filteredCount: number; + totalCount: number; + memoryUsage?: number; +} + +interface Props { + stats: PerformanceStats; + show?: boolean; +} + +export const PerformanceMonitor: React.FC = memo(({ stats, show = false }) => { + const [expanded, setExpanded] = useState(false); + + // Show performance stats in development mode + const isDev = import.meta.env.DEV; + + useEffect(() => { + // Log performance stats to console in dev mode + if (isDev) { + console.log('Performance Stats:', stats); + } + }, [stats, isDev]); + + if (!isDev && !show) return null; + + return ( + + setExpanded(!expanded)} + > + + Performance {expanded ? '▼' : '▶'} + + + +
Render: {stats.renderTime.toFixed(1)}ms
+
Data Load: {stats.dataLoadTime.toFixed(1)}ms
+
Filtered: {stats.filteredCount.toLocaleString()}
+
Total: {stats.totalCount.toLocaleString()}
+ {stats.memoryUsage && ( +
Memory: {(stats.memoryUsage / 1024 / 1024).toFixed(1)}MB
+ )} +
+
+
+
+ ); +}); + +PerformanceMonitor.displayName = 'PerformanceMonitor'; + +// Hook to measure performance +export const usePerformanceStats = () => { + const [stats, setStats] = useState({ + renderTime: 0, + dataLoadTime: 0, + filteredCount: 0, + totalCount: 0 + }); + + const updateStats = (newStats: Partial) => { + setStats(prev => ({ ...prev, ...newStats })); + }; + + const measureRenderTime = (callback: () => void) => { + const start = performance.now(); + callback(); + const end = performance.now(); + updateStats({ renderTime: end - start }); + }; + + const getMemoryUsage = () => { + if ('memory' in performance) { + return (performance as any).memory.usedJSHeapSize; + } + return undefined; + }; + + useEffect(() => { + const interval = setInterval(() => { + updateStats({ memoryUsage: getMemoryUsage() }); + }, 2000); + + return () => clearInterval(interval); + }, []); + + return { stats, updateStats, measureRenderTime }; +}; diff --git a/dashboard/src/components/SkyMap.tsx b/dashboard/src/components/SkyMap.tsx index aa9f701..40173ae 100644 --- a/dashboard/src/components/SkyMap.tsx +++ b/dashboard/src/components/SkyMap.tsx @@ -16,34 +16,172 @@ interface Props { selected: string; } -// Convert RA (deg 0..360) to Mollweide longitude λ (deg -180..180) with RA=180 at λ=0 (center) and RA increasing to the left. -// Formula: shift so 180 -> 0 then wrap, then negate for leftwards increase. -const raToLon = (raDeg: number) => { - const wrapped = ((raDeg + 180) % 360) - 180; // RA=180 -> 0, RA=0 -> -180, RA=360-> -180 - return -wrapped; // invert to make RA increase to the left -}; - -const deg2rad = (d:number)=> d * Math.PI / 180; - -// Solve for theta in Mollweide projection: 2θ + sin 2θ = π sin φ -function solveTheta(phi: number){ - // Handle poles explicitly to avoid 0/0 in Newton step - const HALF_PI = Math.PI / 2; - if (Math.abs(Math.abs(phi) - HALF_PI) < 1e-12) { - return Math.sign(phi) * HALF_PI; +interface ProjectedPoint { + x: number; + y: number; + jname: string; + ra: number; + dec: number; +} + +// Web Worker wrapper for sky projection +class SkyProjectionWorker { + private worker: Worker | null = null; + private requestId = 0; + private pendingRequests = new Map void>(); + + constructor() { + try { + // Create worker from inline script to avoid bundling issues + const workerScript = ` + const raToLon = (raDeg) => { + const wrapped = ((raDeg + 180) % 360) - 180; + return -wrapped; + }; + + const deg2rad = (d) => d * Math.PI / 180; + + function solveTheta(phi) { + const HALF_PI = Math.PI / 2; + if (Math.abs(Math.abs(phi) - HALF_PI) < 1e-12) { + return Math.sign(phi) * HALF_PI; + } + let theta = Math.max(-HALF_PI, Math.min(HALF_PI, phi)); + for (let i = 0; i < 12; i++) { + const f = 2 * theta + Math.sin(2 * theta) - Math.PI * Math.sin(phi); + const fp = 2 + 2 * Math.cos(2 * theta); + if (Math.abs(fp) < 1e-12) break; + const delta = f / fp; + theta -= delta; + if (Math.abs(delta) < 1e-10) break; + } + return theta; + } + + function projectObjects(objects) { + const result = []; + for (const o of objects) { + if (!o) continue; + const toNum = (v) => typeof v === 'number' ? v : (typeof v === 'string' ? parseFloat(v) : NaN); + let RA = toNum(o.RA); + let DEC = toNum(o.DEC); + if (isNaN(RA) || isNaN(DEC)) continue; + if (RA < 0) RA = ((RA % 360) + 360) % 360; + if (RA >= 360) RA = RA % 360; + if (DEC < -90 || DEC > 90) continue; + const lon = deg2rad(raToLon(RA)); + const lat = deg2rad(DEC); + const theta = solveTheta(lat); + const xNorm = (2 * Math.SQRT2 / Math.PI) * lon * Math.cos(theta); + const yNorm = -Math.SQRT2 * Math.sin(theta); + result.push({ x: xNorm, y: yNorm, jname: o.JNAME, ra: RA, dec: DEC }); + } + return result; + } + + self.onmessage = function(e) { + const { objects, requestId } = e.data; + try { + const projectedPoints = projectObjects(objects); + self.postMessage({ requestId, projectedPoints, success: true }); + } catch (error) { + self.postMessage({ requestId, error: error.message, success: false }); + } + }; + `; + + const blob = new Blob([workerScript], { type: 'application/javascript' }); + this.worker = new Worker(URL.createObjectURL(blob)); + + this.worker.onmessage = (e) => { + const { requestId, projectedPoints, success } = e.data; + const callback = this.pendingRequests.get(requestId); + if (callback && success) { + callback(projectedPoints); + this.pendingRequests.delete(requestId); + } + }; + } catch (error) { + console.warn('Web Worker not available, falling back to main thread'); + } } - let theta = Math.max(-HALF_PI, Math.min(HALF_PI, phi)); // clamp initial guess - for (let i = 0; i < 12; i++) { - const f = 2 * theta + Math.sin(2 * theta) - Math.PI * Math.sin(phi); - const fp = 2 + 2 * Math.cos(2 * theta); // 4·cos²θ - if (Math.abs(fp) < 1e-12) break; // avoid blow-ups near poles - const delta = f / fp; - theta -= delta; - if (Math.abs(delta) < 1e-10) break; + + async projectObjects(objects: SkyObject[]): Promise { + // Fallback to main thread if worker unavailable or for small datasets + if (!this.worker || objects.length < 1000) { + return this.projectObjectsMainThread(objects); + } + + return new Promise((resolve) => { + const id = ++this.requestId; + this.pendingRequests.set(id, resolve); + this.worker!.postMessage({ objects, requestId: id }); + + // Timeout fallback + setTimeout(() => { + if (this.pendingRequests.has(id)) { + this.pendingRequests.delete(id); + resolve(this.projectObjectsMainThread(objects)); + } + }, 1000); + }); + } + + private projectObjectsMainThread(objects: SkyObject[]): ProjectedPoint[] { + const result: ProjectedPoint[] = []; + const raToLon = (raDeg: number) => { + const wrapped = ((raDeg + 180) % 360) - 180; + return -wrapped; + }; + const deg2rad = (d: number) => d * Math.PI / 180; + + function solveTheta(phi: number) { + const HALF_PI = Math.PI / 2; + if (Math.abs(Math.abs(phi) - HALF_PI) < 1e-12) { + return Math.sign(phi) * HALF_PI; + } + let theta = Math.max(-HALF_PI, Math.min(HALF_PI, phi)); + for (let i = 0; i < 12; i++) { + const f = 2 * theta + Math.sin(2 * theta) - Math.PI * Math.sin(phi); + const fp = 2 + 2 * Math.cos(2 * theta); + if (Math.abs(fp) < 1e-12) break; + const delta = f / fp; + theta -= delta; + if (Math.abs(delta) < 1e-10) break; + } + return theta; + } + + for (const o of objects) { + if (!o) continue; + const toNum = (v: any) => typeof v === 'number' ? v : (typeof v === 'string' ? parseFloat(v) : NaN); + let RA = toNum(o.RA); + let DEC = toNum(o.DEC); + if (isNaN(RA) || isNaN(DEC)) continue; + if (RA < 0) RA = ((RA % 360) + 360) % 360; + if (RA >= 360) RA = RA % 360; + if (DEC < -90 || DEC > 90) continue; + const lon = deg2rad(raToLon(RA)); + const lat = deg2rad(DEC); + const theta = solveTheta(lat); + const xNorm = (2 * Math.SQRT2 / Math.PI) * lon * Math.cos(theta); + const yNorm = -Math.SQRT2 * Math.sin(theta); + result.push({ x: xNorm, y: yNorm, jname: o.JNAME, ra: RA, dec: DEC }); + } + return result; + } + + destroy() { + if (this.worker) { + this.worker.terminate(); + this.worker = null; + } + this.pendingRequests.clear(); } - return theta; } +const workerInstance = new SkyProjectionWorker(); + export const SkyMap: React.FC = memo(({ objects, height=360, width=600, onSelect, selected }) => { const padding = 16; const canvasRef = useRef(null); @@ -51,37 +189,29 @@ export const SkyMap: React.FC = memo(({ objects, height=360, width=600, o const dpr = typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1; const [canvasW, setCanvasW] = useState(width); const [canvasH, setCanvasH] = useState(height-26); - // Interactive transform const [zoom, setZoom] = useState(1); const [pan, setPan] = useState({x:0,y:0}); const isPanningRef = useRef(false); const lastPosRef = useRef({x:0,y:0}); + const [pts, setPts] = useState([]); + const [isProjecting, setIsProjecting] = useState(false); - const maxX = 2*Math.SQRT2; // world half width in x ~2.828 - const maxY = Math.SQRT2; // world half height in y ~1.414 + const maxX = 2*Math.SQRT2; + const maxY = Math.SQRT2; - // Pre-project objects once (world coords) - const pts = useMemo(()=>{ - const list: { x:number; y:number; jname:string; ra:number; dec:number }[] = []; - if(!objects) return list; - for(const o of objects){ - if(!o) continue; - const toNum = (v:any)=> typeof v === 'number' ? v : (typeof v === 'string'? parseFloat(v): NaN); - let RA = toNum(o.RA); - let DEC = toNum(o.DEC); - if(isNaN(RA) || isNaN(DEC)) continue; - if(RA < 0) RA = ((RA % 360) + 360) % 360; - if(RA >= 360) RA = RA % 360; - if(DEC < -90 || DEC > 90) continue; - const lon = deg2rad(raToLon(RA)); - const lat = deg2rad(DEC); - const theta = solveTheta(lat); - const xNorm = (2*Math.SQRT2/Math.PI) * lon * Math.cos(theta); - const yNorm = -Math.SQRT2 * Math.sin(theta); - list.push({ x:xNorm, y:yNorm, jname:o.JNAME, ra:RA, dec:DEC }); + // Async projection with worker + useEffect(() => { + if (!objects || objects.length === 0) { + setPts([]); + return; } - return list; - },[objects]); + + setIsProjecting(true); + workerInstance.projectObjects(objects).then((projectedPoints) => { + setPts(projectedPoints); + setIsProjecting(false); + }); + }, [objects]); // Resize observer to adapt width (fill parent) useEffect(()=>{ @@ -90,33 +220,33 @@ export const SkyMap: React.FC = memo(({ objects, height=360, width=600, o for(const e of entries){ const w = e.contentRect.width; setCanvasW(w); - setCanvasH(height-26); // keep fixed height region + setCanvasH(height-26); } }); ro.observe(containerRef.current); return ()=> ro.disconnect(); },[height]); - // Drawing function - // Refs para animação - const animRef = useRef(); - const lastPtsRef = useRef(pts); - const lastSelectedRef = useRef(selected); - const lastPanRef = useRef(pan); - const lastZoomRef = useRef(zoom); - - useEffect(()=>{ lastPtsRef.current = pts; },[pts]); - useEffect(()=>{ lastSelectedRef.current = selected; },[selected]); - useEffect(()=>{ lastPanRef.current = pan; },[pan]); - useEffect(()=>{ lastZoomRef.current = zoom; },[zoom]); + // Optimized drawing function with requestAnimationFrame throttling + const drawFrame = useRef(); + const lastDrawTime = useRef(0); + // When true we already have a frame scheduled / drawing in progress. + // Start as false so the first schedule actually enqueues a draw. + const needsRedraw = useRef(false); - const draw = useCallback((time?: number)=>{ - const canvas = canvasRef.current; if(!canvas) return; - const ctx = canvas.getContext('2d'); if(!ctx) return; - const w = canvasW * dpr; const h = canvasH * dpr; + const draw = useCallback(()=>{ + const canvas = canvasRef.current; + if(!canvas) return; + const ctx = canvas.getContext('2d'); + if(!ctx) return; + + const w = canvasW * dpr; + const h = canvasH * dpr; if(canvas.width !== w || canvas.height !== h){ - canvas.width = w; canvas.height = h; + canvas.width = w; + canvas.height = h; } + ctx.save(); ctx.scale(dpr,dpr); ctx.clearRect(0,0,canvasW,canvasH); @@ -157,110 +287,127 @@ export const SkyMap: React.FC = memo(({ objects, height=360, width=600, o ctx.restore(); }; - // RA meridians every 30° - for(let raDeg=0; raDeg<360; raDeg+=30){ - const lonDeg = raToLon(raDeg); - const lon = deg2rad(lonDeg); - const seg: number[][] = []; - for(let latDeg=-90; latDeg<=90; latDeg+=3){ - const lat = deg2rad(latDeg); - const theta = solveTheta(lat); - const x = (2*Math.SQRT2/Math.PI) * lon * Math.cos(theta); - const y = -Math.SQRT2 * Math.sin(theta) - seg.push([x,y,latDeg===-90?1:0]); + // Only draw grid if zoomed enough (performance optimization) + if (zoom > 0.7) { + // RA meridians every 30° + for(let raDeg=0; raDeg<360; raDeg+=30){ + const lonDeg = (((raDeg + 180) % 360) - 180) * -1; // raToLon inline + const lon = lonDeg * Math.PI / 180; // deg2rad inline + const seg: number[][] = []; + for(let latDeg=-90; latDeg<=90; latDeg+=6){ // Reduced resolution + const lat = latDeg * Math.PI / 180; + // Simplified theta calculation for grid lines + const theta = Math.max(-Math.PI/2, Math.min(Math.PI/2, lat)); + const x = (2*Math.SQRT2/Math.PI) * lon * Math.cos(theta); + const y = -Math.SQRT2 * Math.sin(theta) + seg.push([x,y,latDeg===-90?1:0]); + } + worldLine(seg,'rgba(255,255,255,0.08)'); } - worldLine(seg,'rgba(255,255,255,0.08)'); - } - // DEC parallels - const latLines = [-75,-60,-45,-30,-15,0,15,30,45,60,75]; - for(const latDeg of latLines){ - const lat = deg2rad(latDeg); - const theta = solveTheta(lat); - const seg: number[][] = []; - for(let raDeg=0; raDeg<=360; raDeg+=3){ - const lonDeg = raToLon(raDeg); - const lon = deg2rad(lonDeg); - const x = (2*Math.SQRT2/Math.PI) * lon * Math.cos(theta); - const y = Math.SQRT2 * Math.sin(theta); - seg.push([x,y,raDeg===0?1:0]); + // DEC parallels (reduced set) + const latLines = [-60,-30,0,30,60]; + for(const latDeg of latLines){ + const lat = latDeg * Math.PI / 180; + const theta = Math.max(-Math.PI/2, Math.min(Math.PI/2, lat)); + const seg: number[][] = []; + for(let raDeg=0; raDeg<=360; raDeg+=6){ // Reduced resolution + const lonDeg = (((raDeg + 180) % 360) - 180) * -1; + const lon = lonDeg * Math.PI / 180; + const x = (2*Math.SQRT2/Math.PI) * lon * Math.cos(theta); + const y = Math.SQRT2 * Math.sin(theta); + seg.push([x,y,raDeg===0?1:0]); + } + worldLine(seg,'rgba(255,255,255,0.08)'); } - worldLine(seg,'rgba(255,255,255,0.08)'); - } - // Labels (draw in screen space using world->screen conversion) - ctx.save(); - ctx.font = '10px Roboto, sans-serif'; - ctx.fillStyle = 'rgba(255,255,255,0.85)'; - ctx.textAlign = 'center'; - // RA labels - for(let raDeg=0; raDeg<360; raDeg+=30){ - const lonDeg = raToLon(raDeg); - const lon = deg2rad(lonDeg); - const theta = solveTheta(0); - const x = (2*Math.SQRT2/Math.PI) * lon * Math.cos(theta); - const y = -maxY + 0.05; // a little inside - // world -> screen - const sx = centerX + (x * baseScaleX*zoom); - const sy = centerY + (y * baseScaleY*zoom) + 10; // offset for text baseline - ctx.fillText(`${raDeg}°`, sx, sy); - } - ctx.textAlign = 'left'; - for(const latDeg of [-60,-30,0,30,60]){ - const lat = deg2rad(latDeg); - const theta = solveTheta(lat); - const y = -Math.SQRT2 * Math.sin(theta); - const x = -maxX + 0.05; - const sx = centerX + (x * baseScaleX*zoom); - const sy = centerY + (y * baseScaleY*zoom) + 3; - ctx.fillText(`${latDeg}°`, sx, sy); + // Labels (only if zoomed enough) + if (zoom > 1.2) { + ctx.save(); + ctx.font = '10px Roboto, sans-serif'; + ctx.fillStyle = 'rgba(255,255,255,0.85)'; + ctx.textAlign = 'center'; + // RA labels (reduced set) + for(let raDeg=0; raDeg<360; raDeg+=60){ + const lonDeg = (((raDeg + 180) % 360) - 180) * -1; + const lon = lonDeg * Math.PI / 180; + const x = (2*Math.SQRT2/Math.PI) * lon * Math.cos(0); + const y = -maxY + 0.05; + const sx = centerX + (x * baseScaleX*zoom); + const sy = centerY + (y * baseScaleY*zoom) + 10; + ctx.fillText(`${raDeg}°`, sx, sy); + } + ctx.textAlign = 'left'; + for(const latDeg of [-60,0,60]){ + const lat = latDeg * Math.PI / 180; + const theta = Math.max(-Math.PI/2, Math.min(Math.PI/2, lat)); + const y = -Math.SQRT2 * Math.sin(theta); + const x = -maxX + 0.05; + const sx = centerX + (x * baseScaleX*zoom); + const sy = centerY + (y * baseScaleY*zoom) + 3; + ctx.fillText(`${latDeg}°`, sx, sy); + } + ctx.restore(); + } } - ctx.restore(); - // Points + // Optimized point rendering with LOD (Level of Detail) ctx.save(); ctx.translate(centerX, centerY); ctx.scale(baseScaleX*zoom, baseScaleY*zoom); - // Heurística de raio em pixels (independente do zoom) depois convertido a world - const ptsLocal = pts; // já atualizado pelo hook - const n = ptsLocal.length || 1; - // Raio base em pixels inversamente proporcional à raiz de n, limitado - const basePx = Math.max(1.2, Math.min(2.2, 16 / Math.sqrt(n))); - const selectedMinPx = 9; // sempre visível sem zoom - const zoomComp = Math.pow(zoom, 0.15); // leve crescimento ao aproximar - const basePxAdj = basePx * zoomComp; // base ajustado - const t = (time ?? 0) * 0.001; // ms -> s - // Primeiro desenha todos os não-selecionados - for(const p of ptsLocal){ - if(p.jname === selected) continue; - const targetPx = basePxAdj; - const pxToWorld = 1 / (baseScaleX*zoom); - const rWorld = targetPx * pxToWorld; - ctx.beginPath(); - ctx.arc(p.x,p.y, rWorld,0,Math.PI*2); + + const n = pts.length || 1; + const basePx = Math.max(0.8, Math.min(2.0, 12 / Math.sqrt(n))); + const selectedMinPx = 6; + const zoomComp = Math.pow(zoom, 0.1); + const basePxAdj = basePx * zoomComp; + const pxToWorld = 1 / (baseScaleX*zoom); + + // Use different rendering strategies based on point count and zoom + const shouldUseSimpleRender = n > 5000 && zoom < 2; + + if (shouldUseSimpleRender) { + // Simple rendering for many points ctx.fillStyle = 'rgba(90,180,220,0.78)'; - ctx.strokeStyle = 'rgba(0,0,0,0.45)'; - ctx.lineWidth = 0.5/Math.max(baseScaleX*zoom, baseScaleY*zoom); + ctx.beginPath(); + for(const p of pts){ + if(p.jname === selected) continue; + const rWorld = basePxAdj * pxToWorld; + ctx.moveTo(p.x + rWorld, p.y); + ctx.arc(p.x, p.y, rWorld, 0, Math.PI*2); + } ctx.fill(); - ctx.stroke(); + } else { + // Detailed rendering for fewer points + for(const p of pts){ + if(p.jname === selected) continue; + const rWorld = basePxAdj * pxToWorld; + ctx.beginPath(); + ctx.arc(p.x,p.y, rWorld,0,Math.PI*2); + ctx.fillStyle = 'rgba(90,180,220,0.78)'; + ctx.strokeStyle = 'rgba(0,0,0,0.45)'; + ctx.lineWidth = 0.3/Math.max(baseScaleX*zoom, baseScaleY*zoom); + ctx.fill(); + ctx.stroke(); + } } - // Depois desenha o selecionado no topo + + // Selected point with colorful hue/star animation if(selected){ - const p = ptsLocal.find(pt=> pt.jname === selected); + const p = pts.find(pt=> pt.jname === selected); if(p){ const targetPx = Math.max(selectedMinPx, basePxAdj * 5.2); - const pxToWorld = 1 / (baseScaleX*zoom); const rWorld = targetPx * pxToWorld; - // efeito estrela com variação de cor + const t = performance.now() * 0.001; const tw = 0.55 + 0.45 * 0.5 * (Math.sin(t*5.0) + Math.sin(t*3.2 + 1.3)); const gradOuter = rWorld * (2.8 + 0.4*Math.sin(t*2.2)); const hue = (t*40) % 360; const hue2 = (hue + 25) % 360; const hue3 = (hue + 55) % 360; const g = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, gradOuter); - g.addColorStop(0, `hsla(${hue}, 95%, 78%, ${0.90*tw})`); - g.addColorStop(0.25, `hsla(${hue2}, 90%, 62%, ${0.42*tw})`); - g.addColorStop(0.55, `hsla(${hue3}, 85%, 50%, ${0.18*tw})`); + g.addColorStop(0, `hsla(${hue}, 95%, 78%, ${(0.90*tw).toFixed(3)})`); + g.addColorStop(0.25, `hsla(${hue2}, 90%, 62%, ${(0.42*tw).toFixed(3)})`); + g.addColorStop(0.55, `hsla(${hue3}, 85%, 50%, ${(0.18*tw).toFixed(3)})`); g.addColorStop(1, `hsla(${hue3}, 85%, 40%, 0)`); ctx.save(); ctx.beginPath(); @@ -268,7 +415,7 @@ export const SkyMap: React.FC = memo(({ objects, height=360, width=600, o ctx.arc(p.x,p.y, gradOuter, 0, Math.PI*2); ctx.fill(); ctx.restore(); - // núcleo + // core ctx.beginPath(); ctx.arc(p.x,p.y, rWorld*0.6, 0, Math.PI*2); ctx.fillStyle = `hsla(${hue}, 95%, 88%, 0.92)`; @@ -290,31 +437,61 @@ export const SkyMap: React.FC = memo(({ objects, height=360, width=600, o } } ctx.restore(); - ctx.restore(); + needsRedraw.current = false; },[canvasW, canvasH, dpr, pts, pan, zoom, height, selected]); - // Loop de animação otimizado - só anima quando há objeto selecionado - useEffect(()=>{ + // Throttled redraw + const scheduleRedraw = useCallback(() => { + needsRedraw.current = true; + if (drawFrame.current) cancelAnimationFrame(drawFrame.current); + drawFrame.current = requestAnimationFrame(() => { + const now = performance.now(); + if (now - lastDrawTime.current >= 16) { // ~60fps cap + draw(); + lastDrawTime.current = now; + } else { + scheduleRedraw(); + } + }); + }, [draw]); + + // Animation loop only for selected items + const animationRef = useRef(); + useEffect(() => { if (!selected) { - // Se não há seleção, desenha uma vez e para - draw(); + if (animationRef.current) { + cancelAnimationFrame(animationRef.current); + animationRef.current = undefined; + } + scheduleRedraw(); // Draw once without animation return; } - const step = (t:number)=>{ - draw(t); - animRef.current = requestAnimationFrame(step); + const animate = () => { + scheduleRedraw(); + animationRef.current = requestAnimationFrame(animate); + }; + animationRef.current = requestAnimationFrame(animate); + + return () => { + if (animationRef.current) { + cancelAnimationFrame(animationRef.current); + animationRef.current = undefined; + } }; - animRef.current = requestAnimationFrame(step); - return ()=> { if(animRef.current) cancelAnimationFrame(animRef.current); }; - },[draw, selected]); + }, [selected, scheduleRedraw]); - // Trigger redraw - useEffect(()=>{ draw(); }, [draw]); + // Trigger redraws on data/state changes + useEffect(() => scheduleRedraw(), [pts, pan, zoom, canvasW, canvasH, scheduleRedraw]); - // Redraw selection change - useEffect(()=>{ draw(); }, [selected, draw]); + // Cleanup + useEffect(() => { + return () => { + if (drawFrame.current) cancelAnimationFrame(drawFrame.current); + if (animationRef.current) cancelAnimationFrame(animationRef.current); + }; + }, []); // Interaction handlers useEffect(()=>{ @@ -384,16 +561,19 @@ export const SkyMap: React.FC = memo(({ objects, height=360, width=600, o },[canvasW, canvasH, pts, pan, zoom, onSelect]); return ( - + - Sky — {pts.length} pts | zoom {zoom.toFixed(2)} + Sky — {pts.length} pts | zoom {zoom.toFixed(2)} {isProjecting && '(projecting...)'}
- {!pts.length && ( + {(!pts.length && !isProjecting) && ( No RA/DEC available to plot. )} -
+ {isProjecting && ( + Projecting objects... + )} +
@@ -404,3 +584,10 @@ export const SkyMap: React.FC = memo(({ objects, height=360, width=600, o }); SkyMap.displayName = 'SkyMap'; + +// Cleanup worker on component unmount +if (typeof window !== 'undefined') { + window.addEventListener('beforeunload', () => { + workerInstance.destroy(); + }); +} diff --git a/dashboard/src/hooks/useDebounce.ts b/dashboard/src/hooks/useDebounce.ts index e3706e6..53798a6 100644 --- a/dashboard/src/hooks/useDebounce.ts +++ b/dashboard/src/hooks/useDebounce.ts @@ -1,17 +1,36 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; export function useDebounce(value: T, delay: number): T { const [debouncedValue, setDebouncedValue] = useState(value); + const timeoutRef = useRef | null>(null); useEffect(() => { - const handler = setTimeout(() => { + // Clear existing timeout + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + + // Set new timeout + timeoutRef.current = setTimeout(() => { setDebouncedValue(value); }, delay); + // Cleanup on unmount return () => { - clearTimeout(handler); + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } }; }, [value, delay]); + // Cleanup on unmount + useEffect(() => { + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, []); + return debouncedValue; } diff --git a/dashboard/src/utils/columnWidth.ts b/dashboard/src/utils/columnWidth.ts new file mode 100644 index 0000000..e69de29 diff --git a/dashboard/src/workers/skyProjectionWorker.ts b/dashboard/src/workers/skyProjectionWorker.ts new file mode 100644 index 0000000..33b2e11 --- /dev/null +++ b/dashboard/src/workers/skyProjectionWorker.ts @@ -0,0 +1,95 @@ +// Web Worker for sky projection calculations +// This offloads the heavy Mollweide projection computation from the main thread + +interface SkyObject { + JNAME: string; + RA?: number | null; + DEC?: number | null; + [k: string]: unknown; +} + +interface ProjectedPoint { + x: number; + y: number; + jname: string; + ra: number; + dec: number; +} + +// Convert RA (deg 0..360) to Mollweide longitude λ (deg -180..180) +const raToLon = (raDeg: number) => { + const wrapped = ((raDeg + 180) % 360) - 180; + return -wrapped; +}; + +const deg2rad = (d: number) => d * Math.PI / 180; + +// Solve for theta in Mollweide projection: 2θ + sin 2θ = π sin φ +function solveTheta(phi: number) { + const HALF_PI = Math.PI / 2; + if (Math.abs(Math.abs(phi) - HALF_PI) < 1e-12) { + return Math.sign(phi) * HALF_PI; + } + let theta = Math.max(-HALF_PI, Math.min(HALF_PI, phi)); + for (let i = 0; i < 12; i++) { + const f = 2 * theta + Math.sin(2 * theta) - Math.PI * Math.sin(phi); + const fp = 2 + 2 * Math.cos(2 * theta); + if (Math.abs(fp) < 1e-12) break; + const delta = f / fp; + theta -= delta; + if (Math.abs(delta) < 1e-10) break; + } + return theta; +} + +// Project objects to Mollweide coordinates +function projectObjects(objects: SkyObject[]): ProjectedPoint[] { + const result: ProjectedPoint[] = []; + + for (const o of objects) { + if (!o) continue; + + const toNum = (v: any) => typeof v === 'number' ? v : (typeof v === 'string' ? parseFloat(v) : NaN); + let RA = toNum(o.RA); + let DEC = toNum(o.DEC); + + if (isNaN(RA) || isNaN(DEC)) continue; + if (RA < 0) RA = ((RA % 360) + 360) % 360; + if (RA >= 360) RA = RA % 360; + if (DEC < -90 || DEC > 90) continue; + + const lon = deg2rad(raToLon(RA)); + const lat = deg2rad(DEC); + const theta = solveTheta(lat); + const xNorm = (2 * Math.SQRT2 / Math.PI) * lon * Math.cos(theta); + const yNorm = -Math.SQRT2 * Math.sin(theta); + + result.push({ x: xNorm, y: yNorm, jname: o.JNAME, ra: RA, dec: DEC }); + } + + return result; +} + +// Listen for messages from main thread +self.onmessage = function(e) { + const { objects, requestId } = e.data; + + try { + const projectedPoints = projectObjects(objects); + + // Send result back to main thread + self.postMessage({ + requestId, + projectedPoints, + success: true + }); + } catch (error) { + self.postMessage({ + requestId, + error: error instanceof Error ? error.message : 'Unknown error', + success: false + }); + } +}; + +export {}; // Make this a module diff --git a/dashboard/vite.config.ts b/dashboard/vite.config.ts index 8a28b68..840d9b6 100644 --- a/dashboard/vite.config.ts +++ b/dashboard/vite.config.ts @@ -9,17 +9,40 @@ export default defineConfig({ base: basePath, plugins: [react()], build: { + target: 'es2020', chunkSizeWarningLimit: 1100, + sourcemap: false, // Disable source maps for production + minify: 'terser', + terserOptions: { + compress: { + drop_console: true, // Remove console.log in production + drop_debugger: true, + pure_funcs: ['console.log', 'console.info', 'console.debug'] + } + }, rollupOptions: { output: { manualChunks: { - react: ['react','react-dom'], - mui: ['@mui/material','@mui/icons-material','@mui/x-data-grid','@emotion/react','@emotion/styled'], - vendor: ['@tanstack/react-query', 'recoil'] + 'react-vendor': ['react', 'react-dom'], + 'mui-core': ['@mui/material', '@mui/system', '@emotion/react', '@emotion/styled'], + 'mui-icons': ['@mui/icons-material'], + 'mui-datagrid': ['@mui/x-data-grid'], + 'query-vendor': ['@tanstack/react-query'], + 'utils': ['recoil'] } } } }, + optimizeDeps: { + include: [ + 'react', + 'react-dom', + '@mui/material', + '@mui/icons-material', + '@mui/x-data-grid', + '@tanstack/react-query' + ] + }, server: { port: 5173, headers: {