diff --git a/e2e/tests/isotope-chart.spec.ts b/e2e/tests/isotope-chart.spec.ts new file mode 100644 index 0000000..d5b902a --- /dev/null +++ b/e2e/tests/isotope-chart.spec.ts @@ -0,0 +1,185 @@ +import { test, expect } from '@playwright/test'; +import { + waitForDatabaseReady, + acceptMeteredWarningIfPresent, + acceptPrivacyConsent +} from '../fixtures/test-helpers'; + +test.describe('Isotope Chart', () => { + test.beforeEach(async ({ page }) => { + await acceptPrivacyConsent(page); + await page.goto('/'); + await acceptMeteredWarningIfPresent(page); + await waitForDatabaseReady(page); + }); + + test('should navigate to isotope chart page', async ({ page }) => { + // Click on Isotope Chart navigation link + await page.click('text=Isotope Chart'); + + // Wait for page to load + await expect(page).toHaveURL('/isotope-chart'); + + // Verify page title + await expect(page.locator('h1')).toContainText('Isotope Chart'); + await expect(page.locator('h1')).toContainText('Segré Chart'); + }); + + test('should display chart with controls', async ({ page }) => { + await page.goto('/isotope-chart'); + await waitForDatabaseReady(page); + + // Verify main elements are present + await expect(page.locator('h1')).toContainText('Isotope Chart'); + + // Check for toggle controls + await expect(page.locator('text=Valley of Stability')).toBeVisible(); + await expect(page.locator('text=Magic Numbers')).toBeVisible(); + + // Check for legend button + await expect(page.locator('text=Legend')).toBeVisible(); + + // Check for canvas element (chart rendering) + const canvas = page.locator('canvas'); + await expect(canvas).toBeVisible(); + }); + + test('should toggle valley of stability', async ({ page }) => { + await page.goto('/isotope-chart'); + await waitForDatabaseReady(page); + + // Find the valley of stability checkbox + const valleyCheckbox = page.locator('input[type="checkbox"]').first(); + + // Should be checked by default + await expect(valleyCheckbox).toBeChecked(); + + // Uncheck it + await valleyCheckbox.click(); + await expect(valleyCheckbox).not.toBeChecked(); + + // Check it again + await valleyCheckbox.click(); + await expect(valleyCheckbox).toBeChecked(); + }); + + test('should toggle magic numbers', async ({ page }) => { + await page.goto('/isotope-chart'); + await waitForDatabaseReady(page); + + // Find the magic numbers checkbox (second checkbox) + const magicCheckbox = page.locator('input[type="checkbox"]').nth(1); + + // Should be checked by default + await expect(magicCheckbox).toBeChecked(); + + // Uncheck it + await magicCheckbox.click(); + await expect(magicCheckbox).not.toBeChecked(); + + // Check it again + await magicCheckbox.click(); + await expect(magicCheckbox).toBeChecked(); + }); + + test('should toggle legend visibility', async ({ page }) => { + await page.goto('/isotope-chart'); + await waitForDatabaseReady(page); + + // Legend should be visible by default + await expect(page.locator('text=Nuclide Stability')).toBeVisible(); + await expect(page.locator('text=Chart Features')).toBeVisible(); + + // Click hide legend button + await page.click('text=Hide Legend'); + + // Legend sections should not be visible + await expect(page.locator('text=Nuclide Stability')).not.toBeVisible(); + + // Click show legend button + await page.click('text=Show Legend'); + + // Legend should be visible again + await expect(page.locator('text=Nuclide Stability')).toBeVisible(); + }); + + test('should display info banner with explanation', async ({ page }) => { + await page.goto('/isotope-chart'); + await waitForDatabaseReady(page); + + // Check for info banner + await expect(page.locator('text=What is the Segré Chart?')).toBeVisible(); + await expect(page.locator('text=valley of stability')).toBeVisible(); + await expect(page.locator('text=Magic numbers')).toBeVisible(); + }); + + test('should show stability legend items', async ({ page }) => { + await page.goto('/isotope-chart'); + await waitForDatabaseReady(page); + + // Verify stability color legend + await expect(page.locator('text=Stable')).toBeVisible(); + await expect(page.locator('text=Long-lived')).toBeVisible(); + await expect(page.locator('text=Short-lived')).toBeVisible(); + await expect(page.locator('text=Unknown / Not in database')).toBeVisible(); + }); + + test('should show axes information in legend', async ({ page }) => { + await page.goto('/isotope-chart'); + await waitForDatabaseReady(page); + + // Verify axes information + await expect(page.locator('text=Proton number (Z)')).toBeVisible(); + await expect(page.locator('text=Neutron number (N)')).toBeVisible(); + await expect(page.locator('text=Z=1-94, N=0-150')).toBeVisible(); + }); + + test('should show interaction instructions', async ({ page }) => { + await page.goto('/isotope-chart'); + await waitForDatabaseReady(page); + + // Verify interaction instructions + await expect(page.locator('text=Hover:')).toBeVisible(); + await expect(page.locator('text=Click:')).toBeVisible(); + await expect(page.locator('text=Scroll:')).toBeVisible(); + await expect(page.locator('text=Drag:')).toBeVisible(); + }); + + test('should render canvas chart', async ({ page }) => { + await page.goto('/isotope-chart'); + await waitForDatabaseReady(page); + + // Wait for canvas to be rendered + const canvas = page.locator('canvas'); + await expect(canvas).toBeVisible(); + + // Canvas should have non-zero dimensions + const boundingBox = await canvas.boundingBox(); + expect(boundingBox).not.toBeNull(); + expect(boundingBox!.width).toBeGreaterThan(0); + expect(boundingBox!.height).toBeGreaterThan(0); + }); + + test('should have zoom controls hint', async ({ page }) => { + await page.goto('/isotope-chart'); + await waitForDatabaseReady(page); + + // Check for zoom hints + await expect(page.locator('text=Scroll to zoom')).toBeVisible(); + await expect(page.locator('text=Double-click to reset')).toBeVisible(); + }); + + test('should be responsive on mobile', async ({ page }) => { + // Set mobile viewport + await page.setViewportSize({ width: 375, height: 667 }); + await page.goto('/isotope-chart'); + await waitForDatabaseReady(page); + + // Page should still load + await expect(page.locator('h1')).toContainText('Isotope Chart'); + + // Canvas should be visible + const canvas = page.locator('canvas'); + await expect(canvas).toBeVisible(); + }); +}); diff --git a/e2e/tests/navigation.spec.ts b/e2e/tests/navigation.spec.ts index 3ad1620..573ce1d 100644 --- a/e2e/tests/navigation.spec.ts +++ b/e2e/tests/navigation.spec.ts @@ -21,6 +21,7 @@ test.describe('Navigation and Routing', () => { { path: '/fission', name: 'Fission', heading: /Fission Reactions/i, needsDb: true }, { path: '/twotwo', name: 'Two-to-Two', heading: /Two-to-Two Reactions/i, needsDb: true }, { path: '/element-data', name: 'Element Data', heading: /Element Data/i, needsDb: true }, + { path: '/isotope-chart', name: 'Isotope Chart', heading: /Isotope Chart/i, needsDb: true }, { path: '/tables', name: 'Tables in Detail', heading: /Tables in Detail/i, needsDb: true }, { path: '/all-tables', name: 'All Tables', heading: /All Tables/i, needsDb: true }, { path: '/cascades', name: 'Cascades', heading: /Cascade Simulations/i, needsDb: false }, diff --git a/metered-connection-warning-screenshot.png b/metered-connection-warning-screenshot.png new file mode 100644 index 0000000..98bfdf6 Binary files /dev/null and b/metered-connection-warning-screenshot.png differ diff --git a/src/App.tsx b/src/App.tsx index 5217d30..27b161a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,6 +13,7 @@ import ShowElementData from './pages/ShowElementData' import TablesInDetail from './pages/TablesInDetail' import AllTables from './pages/AllTables' import CascadesAll from './pages/CascadesAll' +import IsotopeChart from './pages/IsotopeChart' import PrivacyPreferences from './pages/PrivacyPreferences' import SentryTest from './pages/SentryTest' import DatabaseErrorCard from './components/DatabaseErrorCard' @@ -88,6 +89,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/components/IsotopeChart.tsx b/src/components/IsotopeChart.tsx new file mode 100644 index 0000000..f1f8ba5 --- /dev/null +++ b/src/components/IsotopeChart.tsx @@ -0,0 +1,390 @@ +import { useEffect, useRef, useMemo, useState, useCallback } from 'react'; +import { TransformWrapper, TransformComponent } from 'react-zoom-pan-pinch'; +import { useNavigate } from 'react-router-dom'; +import { useDatabase } from '../contexts/DatabaseContext'; +import { useTheme } from '../contexts/ThemeContext'; +import type { IsotopeChartProps, ChartNuclide } from '../types'; +import { + getAllNuclidesForChart, + getValleyOfStabilityPoints, + getStabilityColor, + getReactionPathColor, + MAGIC_NUMBERS, + isMagicNumber, +} from '../services/isotopeChartService'; + +const CELL_SIZE = 8; // Base cell size in pixels +const PADDING = 60; // Padding for axis labels +const GRID_INTERVAL = 5; // Draw grid lines every N cells + +export default function IsotopeChart({ + highlightedNuclides = [], + reactionPaths = [], + showValleyOfStability = true, + showMagicNumbers = true, + onNuclideClick, +}: IsotopeChartProps) { + const { db } = useDatabase(); + const { theme } = useTheme(); + const navigate = useNavigate(); + const canvasRef = useRef(null); + const [hoveredNuclide, setHoveredNuclide] = useState(null); + const [mousePos, setMousePos] = useState<{ x: number; y: number } | null>(null); + + const isDark = theme === 'dark'; + + // Load all nuclides from database + const nuclides = useMemo(() => { + if (!db) return []; + return getAllNuclidesForChart(db); + }, [db]); + + // Calculate chart dimensions + const { maxZ, maxN, chartWidth, chartHeight } = useMemo(() => { + const maxZ = Math.max(...nuclides.map(n => n.Z), 94); + const maxN = Math.max(...nuclides.map(n => n.N), 150); + const chartWidth = PADDING + (maxZ + 1) * CELL_SIZE + PADDING; + const chartHeight = PADDING + (maxN + 1) * CELL_SIZE + PADDING; + return { maxZ, maxN, chartWidth, chartHeight }; + }, [nuclides]); + + // Create lookup map for nuclides by Z-N coordinates + const nuclideMap = useMemo(() => { + const map = new Map(); + nuclides.forEach(n => { + map.set(`${n.Z}-${n.N}`, n); + }); + return map; + }, [nuclides]); + + // Valley of stability points + const valleyPoints = useMemo(() => getValleyOfStabilityPoints(maxZ), [maxZ]); + + // Convert highlighted nuclides to Set for O(1) lookup + const highlightedSet = useMemo(() => new Set(highlightedNuclides), [highlightedNuclides]); + + // Draw the chart on canvas + useEffect(() => { + if (!canvasRef.current || nuclides.length === 0) return; + + const canvas = canvasRef.current; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + // Set canvas size + canvas.width = chartWidth; + canvas.height = chartHeight; + + // Clear canvas + ctx.fillStyle = isDark ? '#111827' : '#ffffff'; // gray-900 / white + ctx.fillRect(0, 0, chartWidth, chartHeight); + + // Draw grid lines + ctx.strokeStyle = isDark ? '#374151' : '#e5e7eb'; // gray-700 / gray-200 + ctx.lineWidth = 0.5; + + for (let z = 0; z <= maxZ; z += GRID_INTERVAL) { + const x = PADDING + z * CELL_SIZE; + ctx.beginPath(); + ctx.moveTo(x, PADDING); + ctx.lineTo(x, PADDING + maxN * CELL_SIZE); + ctx.stroke(); + } + + for (let n = 0; n <= maxN; n += GRID_INTERVAL) { + const y = PADDING + (maxN - n) * CELL_SIZE; // Invert Y axis (N increases upward) + ctx.beginPath(); + ctx.moveTo(PADDING, y); + ctx.lineTo(PADDING + maxZ * CELL_SIZE, y); + ctx.stroke(); + } + + // Draw nuclide cells + nuclides.forEach(nuclide => { + const x = PADDING + nuclide.Z * CELL_SIZE; + const y = PADDING + (maxN - nuclide.N) * CELL_SIZE; // Invert Y axis + + // Get color based on stability + const color = getStabilityColor(nuclide.stability, isDark); + ctx.fillStyle = color; + ctx.fillRect(x, y, CELL_SIZE - 1, CELL_SIZE - 1); + + // Highlight if in highlighted set + if (highlightedSet.has(`${nuclide.Z}-${nuclide.A}`)) { + ctx.strokeStyle = '#fbbf24'; // amber-400 + ctx.lineWidth = 2; + ctx.strokeRect(x - 1, y - 1, CELL_SIZE + 1, CELL_SIZE + 1); + } + }); + + // Draw axis labels + ctx.fillStyle = isDark ? '#e5e7eb' : '#1f2937'; // gray-200 / gray-800 + ctx.font = '12px sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + + // Z axis label + ctx.save(); + ctx.translate(chartWidth / 2, chartHeight - 20); + ctx.fillText('Proton Number (Z)', 0, 0); + ctx.restore(); + + // N axis label + ctx.save(); + ctx.translate(20, chartHeight / 2); + ctx.rotate(-Math.PI / 2); + ctx.fillText('Neutron Number (N)', 0, 0); + ctx.restore(); + + // Draw Z axis tick labels + ctx.font = '10px sans-serif'; + for (let z = 0; z <= maxZ; z += 10) { + const x = PADDING + z * CELL_SIZE; + ctx.fillText(z.toString(), x, PADDING - 10); + } + + // Draw N axis tick labels + for (let n = 0; n <= maxN; n += 10) { + const y = PADDING + (maxN - n) * CELL_SIZE; + ctx.textAlign = 'right'; + ctx.fillText(n.toString(), PADDING - 10, y); + } + + }, [nuclides, chartWidth, chartHeight, maxZ, maxN, isDark, highlightedSet]); + + // Handle mouse move for hover tooltip + const handleMouseMove = useCallback((e: React.MouseEvent) => { + const canvas = canvasRef.current; + if (!canvas) return; + + const rect = canvas.getBoundingClientRect(); + const scaleX = canvas.width / rect.width; + const scaleY = canvas.height / rect.height; + + const canvasX = (e.clientX - rect.left) * scaleX; + const canvasY = (e.clientY - rect.top) * scaleY; + + // Convert canvas coordinates to Z, N + const Z = Math.floor((canvasX - PADDING) / CELL_SIZE); + const N = maxN - Math.floor((canvasY - PADDING) / CELL_SIZE); + + // Look up nuclide at this position + const nuclide = nuclideMap.get(`${Z}-${N}`); + + if (nuclide) { + setHoveredNuclide(nuclide); + setMousePos({ x: e.clientX, y: e.clientY }); + } else { + setHoveredNuclide(null); + setMousePos(null); + } + }, [maxN, nuclideMap]); + + const handleMouseLeave = useCallback(() => { + setHoveredNuclide(null); + setMousePos(null); + }, []); + + // Handle click on nuclide + const handleClick = useCallback((e: React.MouseEvent) => { + const canvas = canvasRef.current; + if (!canvas) return; + + const rect = canvas.getBoundingClientRect(); + const scaleX = canvas.width / rect.width; + const scaleY = canvas.height / rect.height; + + const canvasX = (e.clientX - rect.left) * scaleX; + const canvasY = (e.clientY - rect.top) * scaleY; + + const Z = Math.floor((canvasX - PADDING) / CELL_SIZE); + const N = maxN - Math.floor((canvasY - PADDING) / CELL_SIZE); + + const nuclide = nuclideMap.get(`${Z}-${N}`); + + if (nuclide) { + if (onNuclideClick) { + onNuclideClick(nuclide.Z, nuclide.A, nuclide.E); + } else { + // Default: navigate to element data page + navigate(`/element-data?Z=${nuclide.Z}&A=${nuclide.A}`); + } + } + }, [maxN, nuclideMap, onNuclideClick, navigate]); + + if (!db || nuclides.length === 0) { + return ( +
+ Loading isotope chart... +
+ ); + } + + return ( +
+ + +
+ {/* Canvas for nuclide cells and grid */} + + + {/* SVG overlay for valley, magic numbers, and reaction paths */} + + {/* Valley of stability line */} + {showValleyOfStability && ( + { + const x = PADDING + z * CELL_SIZE + CELL_SIZE / 2; + const y = PADDING + (maxN - n) * CELL_SIZE + CELL_SIZE / 2; + return `${x},${y}`; + }) + .join(' ')} + fill="none" + stroke="#10b981" + strokeWidth="2" + strokeDasharray="4,4" + opacity="0.6" + /> + )} + + {/* Magic number lines */} + {showMagicNumbers && MAGIC_NUMBERS.map(magic => { + if (magic > maxZ && magic > maxN) return null; + + return ( + + {/* Vertical line for magic Z */} + {magic <= maxZ && ( + + )} + {/* Horizontal line for magic N */} + {magic <= maxN && ( + + )} + + ); + })} + + {/* Reaction pathways */} + {reactionPaths.map((pathway, idx) => { + // Calculate average position of "from" nuclides + const fromX = pathway.from.reduce((sum, n) => sum + PADDING + n.Z * CELL_SIZE + CELL_SIZE / 2, 0) / pathway.from.length; + const fromY = pathway.from.reduce((sum, n) => sum + PADDING + (maxN - n.N) * CELL_SIZE + CELL_SIZE / 2, 0) / pathway.from.length; + + // Calculate average position of "to" nuclides + const toX = pathway.to.reduce((sum, n) => sum + PADDING + n.Z * CELL_SIZE + CELL_SIZE / 2, 0) / pathway.to.length; + const toY = pathway.to.reduce((sum, n) => sum + PADDING + (maxN - n.N) * CELL_SIZE + CELL_SIZE / 2, 0) / pathway.to.length; + + const color = getReactionPathColor(pathway.reactionType); + + return ( + + + + + + + + + ); + })} + +
+
+
+ + {/* Hover tooltip */} + {hoveredNuclide && mousePos && ( +
+
+ {hoveredNuclide.E}-{hoveredNuclide.A} +
+
+
Z: {hoveredNuclide.Z} (protons)
+
N: {hoveredNuclide.N} (neutrons)
+
A: {hoveredNuclide.A} (mass)
+ {hoveredNuclide.logHalfLife !== undefined && ( +
+ Half-life: {hoveredNuclide.logHalfLife > 9 + ? 'Stable' + : `10^${hoveredNuclide.logHalfLife.toFixed(1)} years`} +
+ )} +
+ {isMagicNumber(hoveredNuclide.Z) &&
Magic Z ({hoveredNuclide.Z})
} + {isMagicNumber(hoveredNuclide.N) &&
Magic N ({hoveredNuclide.N})
} + {!isMagicNumber(hoveredNuclide.Z) && !isMagicNumber(hoveredNuclide.N) && ( +
Click to view details
+ )} +
+
+
+ )} +
+ ); +} diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index d8fd937..b18e388 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -1,6 +1,6 @@ import { ReactNode, useEffect, useCallback, useState } from 'react' import { Link, useLocation } from 'react-router-dom' -import { Menu, X, Atom, Moon, Sun, ChevronLeft, ChevronRight, Home as HomeIcon, GitMerge, Scissors, ArrowLeftRight, FlaskConical, Table, TableProperties, Shield } from 'lucide-react' +import { Menu, X, Atom, Moon, Sun, ChevronLeft, ChevronRight, Home as HomeIcon, GitMerge, Scissors, ArrowLeftRight, FlaskConical, Table, TableProperties, Grid3x3, Shield } from 'lucide-react' import { useTheme } from '../contexts/ThemeContext' import { useLayout } from '../contexts/LayoutContext' import DatabaseUpdateBanner from './DatabaseUpdateBanner' @@ -23,6 +23,7 @@ interface NavigationItem { const navigation: NavigationItem[] = [ { name: 'Home', path: '/', icon: HomeIcon }, { name: 'Show Element Data', path: '/element-data', icon: FlaskConical }, + { name: 'Isotope Chart', path: '/isotope-chart', icon: Grid3x3 }, { name: 'Fusion Reactions', path: '/fusion', icon: GitMerge }, { name: 'Fission Reactions', path: '/fission', icon: Scissors }, { name: 'Two-To-Two Reactions', path: '/twotwo', icon: ArrowLeftRight }, diff --git a/src/pages/IsotopeChart.tsx b/src/pages/IsotopeChart.tsx new file mode 100644 index 0000000..f0c4a2c --- /dev/null +++ b/src/pages/IsotopeChart.tsx @@ -0,0 +1,201 @@ +import { useState } from 'react'; +import { Info, ZoomIn, RefreshCw } from 'lucide-react'; +import IsotopeChartComponent from '../components/IsotopeChart'; + +export default function IsotopeChart() { + const [showValley, setShowValley] = useState(true); + const [showMagic, setShowMagic] = useState(true); + const [showLegend, setShowLegend] = useState(true); + + return ( +
+ {/* Header */} +
+
+
+

+ Isotope Chart (Segré Chart) +

+

+ Interactive N-Z diagram showing {' '} + 321 nuclides with stability visualization +

+
+ +
+
+ + {/* Controls & Legend */} +
+
+ {/* Toggle Controls */} +
+ + +
+ + {/* Zoom Hints */} +
+
+ + Scroll to zoom +
+
+ + Double-click to reset +
+
+
+ + {/* Legend */} + {showLegend && ( +
+
+ {/* Stability Legend */} +
+

+ Nuclide Stability +

+
+
+
+ + Stable (t½ {'>'} 1 billion years) + +
+
+
+ + Long-lived (t½ {'>'} 100 years) + +
+
+
+ + Short-lived (t½ ≤ 100 years) + +
+
+
+ + Unknown / Not in database + +
+
+
+ + {/* Chart Features */} +
+

+ Chart Features +

+
+
+
+ + Valley of Stability + +
+
+
+ + Magic Numbers (2, 8, 20, 28, 50, 82, 126) + +
+
+
+ + {/* Axes */} +
+

+ Axes +

+
+
+ X-axis: Proton number (Z) +
+
+ Y-axis: Neutron number (N) +
+
+ Range: Z=1-94, N=0-150 +
+
+
+ + {/* Interactions */} +
+

+ Interactions +

+
+
+ Hover: View nuclide details +
+
+ Click: Navigate to element data +
+
+ Scroll: Zoom in/out +
+
+ Drag: Pan around chart +
+
+
+
+
+ )} +
+ + {/* Chart Container */} +
+ +
+ + {/* Info Banner */} +
+
+ +
+

+ What is the Segré Chart? The isotope chart (N-Z diagram) plots all known nuclides by their neutron count (N) + versus proton count (Z). The valley of stability shows where + stable nuclides cluster. Magic numbers (2, 8, 20, 28, 50, + 82, 126) indicate particularly stable configurations where nuclear shells are filled, analogous to noble gases in chemistry. +

+
+
+
+
+ ); +} diff --git a/src/services/isotopeChartService.ts b/src/services/isotopeChartService.ts new file mode 100644 index 0000000..275d2c3 --- /dev/null +++ b/src/services/isotopeChartService.ts @@ -0,0 +1,215 @@ +import type { Database } from 'sql.js'; +import type { + Nuclide, + ChartNuclide, + NuclideStability, + ReactionPathway, + Reaction, + FusionReaction, + FissionReaction, + TwoToTwoReaction, + ReactionType, +} from '../types'; + +/** + * Magic numbers in nuclear physics + * Nuclides with magic numbers of protons or neutrons are particularly stable + */ +export const MAGIC_NUMBERS = [2, 8, 20, 28, 50, 82, 126]; + +/** + * Determine nuclide stability based on log half-life + * @param nuclide - Nuclide data from NuclidesPlus + * @returns Stability classification + */ +export function getNuclideStability(nuclide: Pick): NuclideStability { + if (!nuclide.logHalfLife && nuclide.logHalfLife !== 0) return 'unknown'; + + const lhl = nuclide.logHalfLife; + + if (lhl > 9) return 'stable'; // > 1 billion years + if (lhl > 2) return 'long'; // > 100 years + return 'short'; // ≤ 100 years +} + +/** + * Get all nuclides from database formatted for chart display + * Calculates neutron count (N = A - Z) and stability for each nuclide + */ +export function getAllNuclidesForChart(db: Database): ChartNuclide[] { + const sql = ` + SELECT Z, A, E, LHL + FROM NuclidesPlus + WHERE Z >= 1 + ORDER BY Z, A + `; + + const results = db.exec(sql); + const nuclides: ChartNuclide[] = []; + + if (results.length > 0) { + const values = results[0].values; + + values.forEach((row: any[]) => { + const Z = row[0] as number; + const A = row[1] as number; + const E = row[2] as string; + const LHL = row[3] as number | null; + + const N = A - Z; + const logHalfLife = LHL ?? undefined; + const stability = getNuclideStability({ logHalfLife }); + + nuclides.push({ + Z, + N, + A, + E, + logHalfLife, + stability, + }); + }); + } + + return nuclides; +} + +/** + * Calculate points for the valley of stability line + * The valley represents the most stable neutron-to-proton ratio + * + * Formula: + * - Light elements (Z < 20): N ≈ Z + * - Medium elements (20 ≤ Z < 84): N ≈ 1.5 × Z + * - Heavy elements (Z ≥ 84): follows empirical curve + * + * @param maxZ - Maximum atomic number to calculate (default 94) + * @returns Array of [Z, N] points defining the valley line + */ +export function getValleyOfStabilityPoints(maxZ: number = 94): Array<[number, number]> { + const points: Array<[number, number]> = []; + + for (let Z = 1; Z <= maxZ; Z++) { + let N: number; + + if (Z < 20) { + // Light elements: N ≈ Z + N = Z; + } else if (Z < 84) { + // Medium elements: N ≈ 1.5 × Z + N = Math.round(1.5 * Z); + } else { + // Heavy elements: empirical formula + // N ≈ 1.5 × Z + (Z - 84) × 0.4 + N = Math.round(1.5 * Z + (Z - 84) * 0.4); + } + + points.push([Z, N]); + } + + return points; +} + +/** + * Check if a proton or neutron count is a magic number + */ +export function isMagicNumber(count: number): boolean { + return MAGIC_NUMBERS.includes(count); +} + +/** + * Convert query results to reaction pathways for visualization + * + * @param reactions - Array of reactions from query results + * @param reactionType - Type of reaction ('fusion' | 'fission' | 'twotwo') + * @param limit - Maximum number of pathways to include (default 50 for performance) + * @returns Array of ReactionPathway objects with from/to coordinates + */ +export function calculateReactionPathways( + reactions: Reaction[], + reactionType: ReactionType, + limit: number = 50 +): ReactionPathway[] { + const pathways: ReactionPathway[] = []; + + // Limit to first N reactions for performance + const limitedReactions = reactions.slice(0, limit); + + limitedReactions.forEach((r) => { + if (reactionType === 'fusion') { + const reaction = r as FusionReaction; + pathways.push({ + from: [ + { Z: reaction.Z1, N: reaction.A1 - reaction.Z1, E: reaction.E1, A: reaction.A1 }, + { Z: reaction.Z2, N: reaction.A2 - reaction.Z2, E: reaction.E2, A: reaction.A2 }, + ], + to: [ + { Z: reaction.Z, N: reaction.A - reaction.Z, E: reaction.E, A: reaction.A }, + ], + reactionType: 'fusion', + MeV: reaction.MeV, + }); + } else if (reactionType === 'fission') { + const reaction = r as FissionReaction; + pathways.push({ + from: [ + { Z: reaction.Z, N: reaction.A - reaction.Z, E: reaction.E, A: reaction.A }, + ], + to: [ + { Z: reaction.Z1, N: reaction.A1 - reaction.Z1, E: reaction.E1, A: reaction.A1 }, + { Z: reaction.Z2, N: reaction.A2 - reaction.Z2, E: reaction.E2, A: reaction.A2 }, + ], + reactionType: 'fission', + MeV: reaction.MeV, + }); + } else { + // twotwo + const reaction = r as TwoToTwoReaction; + pathways.push({ + from: [ + { Z: reaction.Z1, N: reaction.A1 - reaction.Z1, E: reaction.E1, A: reaction.A1 }, + { Z: reaction.Z2, N: reaction.A2 - reaction.Z2, E: reaction.E2, A: reaction.A2 }, + ], + to: [ + { Z: reaction.Z3, N: reaction.A3 - reaction.Z3, E: reaction.E3, A: reaction.A3 }, + { Z: reaction.Z4, N: reaction.A4 - reaction.Z4, E: reaction.E4, A: reaction.A4 }, + ], + reactionType: 'twotwo', + MeV: reaction.MeV, + }); + } + }); + + return pathways; +} + +/** + * Get color for nuclide stability + * Returns Tailwind CSS color classes + */ +export function getStabilityColor(stability: NuclideStability, isDark: boolean): string { + switch (stability) { + case 'stable': + return isDark ? '#22c55e' : '#16a34a'; // green-500 / green-600 + case 'long': + return isDark ? '#f97316' : '#ea580c'; // orange-500 / orange-600 + case 'short': + return isDark ? '#ef4444' : '#dc2626'; // red-500 / red-600 + case 'unknown': + return isDark ? '#4b5563' : '#d1d5db'; // gray-600 / gray-300 + } +} + +/** + * Get color for reaction pathway arrows based on reaction type + */ +export function getReactionPathColor(reactionType: ReactionType): string { + switch (reactionType) { + case 'fusion': + return '#3b82f6'; // blue-500 + case 'fission': + return '#ef4444'; // red-500 + case 'twotwo': + return '#a855f7'; // purple-500 + } +} diff --git a/src/types/index.ts b/src/types/index.ts index 002a08b..1769bd3 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -291,3 +291,32 @@ export interface AllQueryStates { twotwo?: QueryPageState; version?: number; // For future migration if state structure changes } + +// Isotope Chart (Segré Chart) types +export type NuclideStability = 'stable' | 'long' | 'short' | 'unknown'; + +export interface ChartNuclide { + Z: number; // Proton number + N: number; // Neutron number (calculated as A - Z) + A: number; // Mass number + E: string; // Element symbol + logHalfLife?: number; // Log₁₀ of half-life in years + stability: NuclideStability; +} + +export interface ReactionPathway { + from: Array<{ Z: number; N: number; E: string; A: number }>; + to: Array<{ Z: number; N: number; E: string; A: number }>; + reactionType: ReactionType; // 'fusion' | 'fission' | 'twotwo' + MeV: number; +} + +export interface IsotopeChartProps { + highlightedNuclides?: string[]; // Array of "Z-A" format (e.g., ["1-1", "3-7"]) + reactionPaths?: ReactionPathway[]; + showValleyOfStability?: boolean; + showMagicNumbers?: boolean; + width?: number; + height?: number; + onNuclideClick?: (Z: number, A: number, E: string) => void; +}