diff --git a/src/components/gallery/FamilyCard.tsx b/src/components/gallery/FamilyCard.tsx new file mode 100644 index 0000000..45be124 --- /dev/null +++ b/src/components/gallery/FamilyCard.tsx @@ -0,0 +1,240 @@ +import { useState } from 'react'; +import { Badge } from '../ui'; +import { cn } from '../ui/cn'; +import type { MineralFamily, MineralExpression } from '../../lib/db'; + +// Crystal system color mapping for badges +const SYSTEM_COLORS: Record = { + cubic: 'cubic', + hexagonal: 'hexagonal', + trigonal: 'trigonal', + tetragonal: 'tetragonal', + orthorhombic: 'orthorhombic', + monoclinic: 'monoclinic', + triclinic: 'triclinic', +}; + +interface FamilyCardProps { + family: MineralFamily; + expressions?: MineralExpression[]; + onClick?: () => void; + href?: string; +} + +/** + * Gallery card showing mineral families with expression indicators. + * Displays the primary crystal form with a badge showing total expression count. + */ +export function FamilyCard({ family, expressions = [], onClick, href }: FamilyCardProps) { + const [imageError, setImageError] = useState(false); + const [isHovered, setIsHovered] = useState(false); + + const systemColor = SYSTEM_COLORS[family.crystal_system.toLowerCase()] || 'default'; + const primaryExpression = expressions.find(e => e.is_primary) || expressions[0]; + const expressionCount = family.expressionCount || expressions.length; + + // Get SVG content - prefer inline, then path-based + const svgContent = primaryExpression?.model_svg || family.primarySvg; + const svgPath = !svgContent && primaryExpression ? `/crystals/${primaryExpression.id}.svg` : null; + + const CardWrapper = href ? 'a' : 'article'; + const cardProps = href ? { href } : {}; + + const formatHardness = () => { + if (!family.hardness_min) return null; + if (family.hardness_min === family.hardness_max) { + return family.hardness_min; + } + return `${family.hardness_min}-${family.hardness_max}`; + }; + + const formatSG = () => { + if (!family.sg_min) return null; + if (family.sg_min === family.sg_max) { + return family.sg_min.toFixed(2); + } + return `${family.sg_min.toFixed(2)}-${family.sg_max?.toFixed(2)}`; + }; + + const formatRI = () => { + if (!family.ri_min) return null; + if (family.ri_min === family.ri_max) { + return family.ri_min.toFixed(3); + } + return `${family.ri_min.toFixed(3)}-${family.ri_max?.toFixed(3)}`; + }; + + return ( + setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > + {/* Crystal Preview */} +
+ {svgContent ? ( +
+ ) : svgPath && !imageError ? ( + {family.name} setImageError(true)} + /> + ) : ( +
+ +
+ )} + + {/* Expression Count Badge */} + {expressionCount > 1 && ( +
+ + {expressionCount} forms +
+ )} + + {/* Hover: Expression Thumbnails */} + {expressionCount > 1 && isHovered && expressions.length > 0 && ( +
+
+ {expressions.slice(0, 5).map((expr) => ( +
+ {expr.model_svg ? ( +
+ ) : ( +
+ )} +
+ ))} + {expressionCount > 5 && ( +
+ +{expressionCount - 5} +
+ )} +
+
+ )} +
+ + {/* Info Section */} +
+
+

+ {family.name} +

+ + {family.crystal_system} + +
+ + {family.chemistry && ( +

{family.chemistry}

+ )} + + {/* Compact Properties */} +
+ {formatHardness() && ( + + H + {formatHardness()} + + )} + {formatSG() && ( + + SG + {formatSG()} + + )} + {formatRI() && ( + + RI + {formatRI()} + + )} +
+
+ + ); +} + +/** + * Placeholder icon when no crystal SVG is available. + */ +function CrystalPlaceholder({ system }: { system: string }) { + return ( + + + + + + ); +} + +/** + * Small crystal icon for the expression count badge. + */ +function CrystalIcon({ className }: { className?: string }) { + return ( + + + + + + ); +} + +export default FamilyCard; diff --git a/src/components/gallery/index.ts b/src/components/gallery/index.ts index 70111c6..9500646 100644 --- a/src/components/gallery/index.ts +++ b/src/components/gallery/index.ts @@ -2,3 +2,4 @@ export { Gallery } from './Gallery'; export { GalleryGrid } from './GalleryGrid'; export { FilterBar } from './FilterBar'; export { MineralModal } from './MineralModal'; +export { FamilyCard } from './FamilyCard'; diff --git a/src/components/minerals/ExpressionSelector.tsx b/src/components/minerals/ExpressionSelector.tsx new file mode 100644 index 0000000..e7ce461 --- /dev/null +++ b/src/components/minerals/ExpressionSelector.tsx @@ -0,0 +1,150 @@ +import { useRef, useEffect, useState } from 'react'; +import { cn } from '../ui/cn'; +import type { MineralExpression } from '../../lib/db'; + +interface ExpressionSelectorProps { + expressions: MineralExpression[]; + selected: string; + onSelect: (expressionId: string) => void; + className?: string; +} + +/** + * Horizontal scrollable gallery for selecting crystal form variants. + * Shows thumbnails of each expression with the selected one highlighted. + */ +export function ExpressionSelector({ + expressions, + selected, + onSelect, + className, +}: ExpressionSelectorProps) { + const scrollRef = useRef(null); + const [showLeftFade, setShowLeftFade] = useState(false); + const [showRightFade, setShowRightFade] = useState(false); + + // Check scroll position to show/hide fade edges + const updateScrollFades = () => { + if (scrollRef.current) { + const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current; + setShowLeftFade(scrollLeft > 8); + setShowRightFade(scrollLeft < scrollWidth - clientWidth - 8); + } + }; + + useEffect(() => { + updateScrollFades(); + const scrollEl = scrollRef.current; + if (scrollEl) { + scrollEl.addEventListener('scroll', updateScrollFades); + window.addEventListener('resize', updateScrollFades); + return () => { + scrollEl.removeEventListener('scroll', updateScrollFades); + window.removeEventListener('resize', updateScrollFades); + }; + } + }, [expressions]); + + // Scroll selected item into view when selection changes + useEffect(() => { + if (scrollRef.current) { + const selectedEl = scrollRef.current.querySelector(`[data-expression-id="${selected}"]`); + if (selectedEl) { + selectedEl.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' }); + } + } + }, [selected]); + + if (expressions.length <= 1) { + return null; + } + + return ( +
+ {/* Horizontal Scroll Container */} +
+ {expressions.map((expr) => ( + + ))} +
+ + {/* Fade edges on scroll */} +
+
+
+ ); +} + +/** + * Small crystal icon placeholder. + */ +function CrystalIcon({ className }: { className?: string }) { + return ( + + + + + + ); +} + +export default ExpressionSelector; diff --git a/src/components/minerals/FamilyDetail.tsx b/src/components/minerals/FamilyDetail.tsx new file mode 100644 index 0000000..f71cc5e --- /dev/null +++ b/src/components/minerals/FamilyDetail.tsx @@ -0,0 +1,369 @@ +import { useState, useEffect, useCallback } from 'react'; +import { Badge } from '../ui'; +import { cn } from '../ui/cn'; +import { ExpressionSelector } from './ExpressionSelector'; +import type { MineralFamily, MineralExpression } from '../../lib/db'; + +// Crystal system color mapping +const SYSTEM_COLORS: Record = { + cubic: 'cubic', + hexagonal: 'hexagonal', + trigonal: 'trigonal', + tetragonal: 'tetragonal', + orthorhombic: 'orthorhombic', + monoclinic: 'monoclinic', + triclinic: 'triclinic', +}; + +interface FamilyDetailProps { + family: MineralFamily; + expressions: MineralExpression[]; + initialExpression?: string; +} + +/** + * Full page layout for viewing a mineral family with its expressions. + * Features a 3D/SVG viewer, expression selector, and property tables. + */ +export function FamilyDetail({ family, expressions, initialExpression }: FamilyDetailProps) { + // Find initial expression + const primaryExpr = expressions.find(e => e.is_primary) || expressions[0]; + const initialId = initialExpression + ? expressions.find(e => e.slug === initialExpression || e.id === initialExpression)?.id + : primaryExpr?.id; + + const [selectedId, setSelectedId] = useState(initialId || ''); + const selected = expressions.find(e => e.id === selectedId); + + const systemColor = SYSTEM_COLORS[family.crystal_system.toLowerCase()] || 'default'; + + // Update URL when expression changes (without navigation) + useEffect(() => { + if (typeof window === 'undefined') return; + + const url = new URL(window.location.href); + if (selected && !selected.is_primary && selected.slug !== 'default') { + url.searchParams.set('form', selected.slug); + } else { + url.searchParams.delete('form'); + } + window.history.replaceState({}, '', url.toString()); + }, [selected]); + + // Handle expression selection + const handleSelectExpression = useCallback((id: string) => { + setSelectedId(id); + }, []); + + // Copy CDL to clipboard + const handleCopyCDL = useCallback(() => { + if (selected?.cdl) { + navigator.clipboard.writeText(selected.cdl); + } + }, [selected]); + + // Format property helpers + const formatHardness = () => { + if (!family.hardness_min) return null; + if (family.hardness_min === family.hardness_max) { + return String(family.hardness_min); + } + return `${family.hardness_min}-${family.hardness_max}`; + }; + + const formatSG = () => { + if (!family.sg_min) return null; + if (family.sg_min === family.sg_max) { + return family.sg_min.toFixed(2); + } + return `${family.sg_min.toFixed(2)}-${family.sg_max?.toFixed(2)}`; + }; + + const formatRI = () => { + if (!family.ri_min) return null; + if (family.ri_min === family.ri_max) { + return family.ri_min.toFixed(3); + } + return `${family.ri_min.toFixed(3)}-${family.ri_max?.toFixed(3)}`; + }; + + return ( +
+ {/* Hero Section */} +
+
+ + {/* Left: Crystal Viewer */} +
+ {/* SVG Viewer */} +
+ {selected?.model_svg ? ( +
+ ) : ( +
+ +

No preview available

+
+ )} +
+ + {/* Expression Selector */} + {expressions.length > 1 && ( +
+

+ Crystal Forms ({expressions.length}) +

+ +
+ )} + + {/* CDL Code */} + {selected?.cdl && ( +
+
+ CDL Expression + +
+ + {selected.cdl} + +
+ )} +
+ + {/* Right: Information */} +
+ {/* Header */} +
+
+ + {family.crystal_system} + + {family.category && ( + {family.category} + )} +
+

+ {family.name} +

+ {selected && selected.name !== family.name && selected.slug !== 'default' && ( +

+ {selected.name} form +

+ )} + {family.chemistry && ( +

+ {family.chemistry} +

+ )} +
+ + {/* Quick Facts */} +
+ + + +
+ + {/* Expression-specific info */} + {selected?.form_description && ( +
+

+ {selected.name} Form +

+

{selected.form_description}

+ {selected.habit && ( +

+ Habit: {selected.habit} +

+ )} +
+ )} + + {/* Family Properties Table */} + + + + + + {family.birefringence && ( + + )} + {family.dispersion && ( + + )} + {family.pleochroism && ( + + )} + + + {/* Notes */} + {(family.notes || family.description) && ( +
+

Notes

+

{family.notes || family.description}

+
+ )} + + {/* Links */} +
+ + + Open in Playground + + {expressions.length > 1 && ( + + + All Forms + + )} +
+
+
+
+
+ ); +} + +// Helper Components + +function QuickFact({ + icon, + iconColor, + label, + value, +}: { + icon: string; + iconColor: string; + label: string; + value: string | null; +}) { + if (!value) return null; + + return ( +
+
+ {icon} + {label} +
+
{value}
+
+ ); +} + +function PropertyTable({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) { + return ( +
+

+ {title} +

+
+ {children} +
+
+ ); +} + +function PropertyRow({ + label, + value, +}: { + label: string; + value: string | null | undefined; +}) { + if (!value) return null; + + return ( +
+ {label} + {value} +
+ ); +} + +function CrystalPlaceholder({ className }: { className?: string }) { + return ( + + + + + + ); +} + +function PlayIcon({ className }: { className?: string }) { + return ( + + + + ); +} + +function GridIcon({ className }: { className?: string }) { + return ( + + + + + + + ); +} + +export default FamilyDetail; diff --git a/src/components/minerals/index.ts b/src/components/minerals/index.ts index b9d53e9..0498a8d 100644 --- a/src/components/minerals/index.ts +++ b/src/components/minerals/index.ts @@ -1,3 +1,4 @@ // Mineral page components - Astro components are imported directly -// This file exists for consistency with other component directories -export {}; +// React components for interactive family/expression display +export { ExpressionSelector } from './ExpressionSelector'; +export { FamilyDetail } from './FamilyDetail'; diff --git a/src/hooks/useCalculatorData.ts b/src/hooks/useCalculatorData.ts index 38ec85c..80b0f7b 100644 --- a/src/hooks/useCalculatorData.ts +++ b/src/hooks/useCalculatorData.ts @@ -5,8 +5,8 @@ import { useState, useEffect, useCallback } from 'react'; import { - findMineralsByRI, - findMineralsBySG, + findFamiliesByRI, + findFamiliesBySG, getCutShapeFactors, getThresholds, getMineralsWithHeatTreatment, @@ -16,6 +16,7 @@ import { getMineralsForRefractometer, getMineralsWithPleochroism, type Mineral, + type MineralFamily, type CutShapeFactor, type GemmologicalThreshold, } from '../lib/db'; @@ -25,15 +26,15 @@ import { type GemReference, } from '../lib/calculator/conversions'; +// Helper to ensure numeric values (database may return strings or null) +function toNumber(val: unknown): number | undefined { + if (val === null || val === undefined) return undefined; + const num = typeof val === 'number' ? val : parseFloat(String(val)); + return isNaN(num) ? undefined : num; +} + // Convert Mineral to GemReference format for compatibility function mineralToGemRef(mineral: Mineral): GemReference { - // Helper to ensure numeric values (database may return strings or null) - const toNumber = (val: unknown): number | undefined => { - if (val === null || val === undefined) return undefined; - const num = typeof val === 'number' ? val : parseFloat(String(val)); - return isNaN(num) ? undefined : num; - }; - return { name: mineral.name, ri: mineral.ri_min && mineral.ri_max @@ -52,6 +53,35 @@ function mineralToGemRef(mineral: Mineral): GemReference { }; } +// Convert MineralFamily to GemReference format (no duplicates) +function familyToGemRef(family: MineralFamily): GemReference { + // Format hardness as string range + const formatHardness = (): string => { + if (!family.hardness_min) return ''; + if (family.hardness_min === family.hardness_max) { + return String(family.hardness_min); + } + return `${family.hardness_min}-${family.hardness_max}`; + }; + + return { + name: family.name, + ri: family.ri_min && family.ri_max + ? family.ri_min === family.ri_max + ? family.ri_min + : [family.ri_min, family.ri_max] + : 0, + sg: family.sg_min && family.sg_max + ? family.sg_min === family.sg_max + ? family.sg_min + : [family.sg_min, family.sg_max] + : 0, + birefringence: toNumber(family.birefringence), + dispersion: toNumber(family.dispersion), + hardness: formatHardness(), + }; +} + interface UseCalculatorDataReturn { // Data loading state loading: boolean; @@ -147,12 +177,14 @@ export function useCalculatorData(): UseCalculatorDataReturn { loadData(); }, []); - // RI lookup with database or fallback + // RI lookup with database or fallback (uses families to avoid duplicates) const findByRI = useCallback(async (ri: number, tolerance: number = 0.01): Promise => { if (dbAvailable) { try { - const minerals = await findMineralsByRI(ri, tolerance); - return minerals.map(mineralToGemRef); + // Use family-based query to avoid duplicate entries like + // fluorite, fluorite-octahedron, fluorite-twin showing separately + const families = await findFamiliesByRI(ri, tolerance); + return families.map(familyToGemRef); } catch (err) { console.warn('DB RI lookup failed, using fallback', err); } @@ -166,12 +198,14 @@ export function useCalculatorData(): UseCalculatorDataReturn { }); }, [dbAvailable]); - // SG lookup with database or fallback + // SG lookup with database or fallback (uses families to avoid duplicates) const findBySG = useCallback(async (sg: number, tolerance: number = 0.05): Promise => { if (dbAvailable) { try { - const minerals = await findMineralsBySG(sg, tolerance); - return minerals.map(mineralToGemRef); + // Use family-based query to avoid duplicate entries like + // fluorite, fluorite-octahedron, fluorite-twin showing separately + const families = await findFamiliesBySG(sg, tolerance); + return families.map(familyToGemRef); } catch (err) { console.warn('DB SG lookup failed, using fallback', err); } diff --git a/src/lib/db.ts b/src/lib/db.ts index 158aa00..574bd3c 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -118,6 +118,114 @@ export interface GemmologicalThreshold { description?: string; } +// ============================================================================= +// Family + Expression Types (normalized structure) +// ============================================================================= + +/** + * A mineral family with shared gemmological properties. + * Families group multiple crystal expressions that share identical properties + * but have different crystal morphologies. + */ +export interface MineralFamily { + id: string; + name: string; + crystal_system: string; + point_group?: string; + chemistry?: string; + category?: string; + + // Physical properties + hardness_min?: number; + hardness_max?: number; + sg_min?: number; + sg_max?: number; + + // Optical properties + ri_min?: number; + ri_max?: number; + birefringence?: number; + dispersion?: number; + optical_character?: string; + pleochroism?: string; + pleochroism_strength?: string; + pleochroism_color1?: string; + pleochroism_color2?: string; + pleochroism_color3?: string; + pleochroism_notes?: string; + + // Physical characteristics + lustre?: string; + cleavage?: string; + fracture?: string; + + // Educational content + description?: string; + notes?: string; + diagnostic_features?: string; + common_inclusions?: string; + + // JSON arrays (stored as strings, need parsing) + localities_json?: string; + colors_json?: string; + treatments_json?: string; + inclusions_json?: string; + forms_json?: string; + + // Heat treatment + heat_treatment_temp_min?: number; + heat_treatment_temp_max?: number; + + // Special properties + twin_law?: string; + phenomenon?: string; + fluorescence?: string; + + // Computed fields (populated by JOIN queries) + expressionCount?: number; + primarySvg?: string; +} + +/** + * A crystal morphology expression within a mineral family. + * Expressions represent different crystal habits or forms of the same mineral. + */ +export interface MineralExpression { + id: string; + family_id: string; + name: string; + slug: string; + cdl: string; + point_group?: string; + form_description?: string; + habit?: string; + forms_json?: string; + + // Visual assets + svg_path?: string; + gltf_path?: string; + stl_path?: string; + thumbnail_path?: string; + + // Inline model data + model_svg?: string; + model_stl?: Uint8Array; + model_gltf?: string; + models_generated_at?: string; + + // Metadata + is_primary: boolean; + sort_order: number; + note?: string; +} + +/** + * A family with its expressions pre-loaded. + */ +export interface MineralFamilyWithExpressions extends MineralFamily { + expressions: MineralExpression[]; +} + export async function getDB(): Promise { if (db) return db; if (dbPromise) return dbPromise; @@ -786,3 +894,313 @@ export async function getAllMineralsPaginated( // Re-export pagination types and utilities for convenience export type { PaginationParams, PaginatedResult }; export { DEFAULT_PAGE_SIZE, PAGE_SIZE_OPTIONS } from './pagination'; + +// ============================================================================= +// Family + Expression Query Functions (normalized structure) +// ============================================================================= + +/** + * Get all mineral families with expression counts. + * Returns families sorted by name, with count of expressions and primary SVG. + */ +export async function getAllFamilies(): Promise { + const database = await getDB(); + const result = database.exec(` + SELECT + f.*, + COUNT(e.id) as expressionCount, + (SELECT e2.model_svg FROM mineral_expressions e2 + WHERE e2.family_id = f.id AND e2.is_primary = 1 LIMIT 1) as primarySvg + FROM mineral_families f + LEFT JOIN mineral_expressions e ON f.id = e.family_id + GROUP BY f.id + ORDER BY f.name + `); + + if (result.length === 0) return []; + + const columns = result[0].columns; + return result[0].values.map((row) => { + const family: Record = {}; + columns.forEach((col, i) => { + family[col] = row[i]; + }); + return family as MineralFamily; + }); +} + +/** + * Get a single family by ID. + */ +export async function getFamilyById(familyId: string): Promise { + const database = await getDB(); + const result = database.exec( + `SELECT * FROM mineral_families WHERE id = ?`, + [familyId.toLowerCase()] + ); + + if (result.length === 0 || result[0].values.length === 0) return null; + + const columns = result[0].columns; + const row = result[0].values[0]; + const family: Record = {}; + columns.forEach((col, i) => { + family[col] = row[i]; + }); + return family as MineralFamily; +} + +/** + * Get a family with all its expressions. + */ +export async function getFamilyWithExpressions( + familyId: string +): Promise { + const family = await getFamilyById(familyId); + if (!family) return null; + + const expressions = await getExpressionsForFamily(familyId); + + return { + ...family, + expressions, + }; +} + +/** + * Get all expressions for a family. + * Returns expressions sorted by is_primary DESC, sort_order ASC. + */ +export async function getExpressionsForFamily( + familyId: string +): Promise { + const database = await getDB(); + const result = database.exec( + `SELECT * FROM mineral_expressions + WHERE family_id = ? + ORDER BY is_primary DESC, sort_order ASC`, + [familyId.toLowerCase()] + ); + + if (result.length === 0) return []; + + const columns = result[0].columns; + return result[0].values.map((row) => { + const expr: Record = {}; + columns.forEach((col, i) => { + expr[col] = row[i]; + }); + // Convert is_primary to boolean + expr.is_primary = Boolean(expr.is_primary); + expr.sort_order = expr.sort_order || 0; + return expr as MineralExpression; + }); +} + +/** + * Get a single expression by ID. + */ +export async function getExpressionById( + expressionId: string +): Promise { + const database = await getDB(); + const result = database.exec( + `SELECT * FROM mineral_expressions WHERE id = ?`, + [expressionId.toLowerCase()] + ); + + if (result.length === 0 || result[0].values.length === 0) return null; + + const columns = result[0].columns; + const row = result[0].values[0]; + const expr: Record = {}; + columns.forEach((col, i) => { + expr[col] = row[i]; + }); + expr.is_primary = Boolean(expr.is_primary); + expr.sort_order = expr.sort_order || 0; + return expr as MineralExpression; +} + +/** + * Get families by crystal system. + */ +export async function getFamiliesBySystem(system: string): Promise { + const database = await getDB(); + const result = database.exec( + `SELECT f.*, COUNT(e.id) as expressionCount + FROM mineral_families f + LEFT JOIN mineral_expressions e ON f.id = e.family_id + WHERE LOWER(f.crystal_system) = ? + GROUP BY f.id + ORDER BY f.name`, + [system.toLowerCase()] + ); + + if (result.length === 0) return []; + + const columns = result[0].columns; + return result[0].values.map((row) => { + const family: Record = {}; + columns.forEach((col, i) => { + family[col] = row[i]; + }); + return family as MineralFamily; + }); +} + +/** + * Find families matching an RI value within tolerance. + * Returns unique families (no duplicates like fluorite/fluorite-octahedron). + */ +export async function findFamiliesByRI( + ri: number, + tolerance: number = 0.01 +): Promise { + const database = await getDB(); + const result = database.exec( + `SELECT * FROM mineral_families + WHERE ri_min IS NOT NULL AND ri_max IS NOT NULL + AND (ri_min - ? <= ? AND ri_max + ? >= ?) + ORDER BY ABS((ri_min + ri_max) / 2 - ?) ASC + LIMIT 20`, + [tolerance, ri, tolerance, ri, ri] + ); + + if (result.length === 0) return []; + + const columns = result[0].columns; + return result[0].values.map((row) => { + const family: Record = {}; + columns.forEach((col, i) => { + family[col] = row[i]; + }); + return family as MineralFamily; + }); +} + +/** + * Find families matching an SG value within tolerance. + * Returns unique families (no duplicates like fluorite/fluorite-octahedron). + */ +export async function findFamiliesBySG( + sg: number, + tolerance: number = 0.05 +): Promise { + const database = await getDB(); + const result = database.exec( + `SELECT * FROM mineral_families + WHERE sg_min IS NOT NULL AND sg_max IS NOT NULL + AND (sg_min - ? <= ? AND sg_max + ? >= ?) + ORDER BY ABS((sg_min + sg_max) / 2 - ?) ASC + LIMIT 20`, + [tolerance, sg, tolerance, sg, sg] + ); + + if (result.length === 0) return []; + + const columns = result[0].columns; + return result[0].values.map((row) => { + const family: Record = {}; + columns.forEach((col, i) => { + family[col] = row[i]; + }); + return family as MineralFamily; + }); +} + +/** + * Search families by name or chemistry. + */ +export async function searchFamilies(query: string): Promise { + const database = await getDB(); + const searchTerm = `%${query.toLowerCase()}%`; + + const result = database.exec( + `SELECT f.*, COUNT(e.id) as expressionCount + FROM mineral_families f + LEFT JOIN mineral_expressions e ON f.id = e.family_id + WHERE LOWER(f.name) LIKE ? + OR LOWER(f.chemistry) LIKE ? + OR LOWER(f.crystal_system) LIKE ? + GROUP BY f.id + ORDER BY + CASE WHEN LOWER(f.name) LIKE ? THEN 0 ELSE 1 END, + f.name ASC + LIMIT 50`, + [searchTerm, searchTerm, searchTerm, searchTerm] + ); + + if (result.length === 0) return []; + + const columns = result[0].columns; + return result[0].values.map((row) => { + const family: Record = {}; + columns.forEach((col, i) => { + family[col] = row[i]; + }); + return family as MineralFamily; + }); +} + +/** + * Get count of families and expressions. + */ +export async function getFamilyStats(): Promise<{ families: number; expressions: number }> { + const database = await getDB(); + + const familyResult = database.exec(`SELECT COUNT(*) FROM mineral_families`); + const exprResult = database.exec(`SELECT COUNT(*) FROM mineral_expressions`); + + return { + families: familyResult.length > 0 ? (familyResult[0].values[0][0] as number) : 0, + expressions: exprResult.length > 0 ? (exprResult[0].values[0][0] as number) : 0, + }; +} + +/** + * Get families with SG data (for calculators - eliminates duplicates). + */ +export async function getFamiliesWithSG(): Promise { + const database = await getDB(); + const result = database.exec( + `SELECT id, name, sg_min, sg_max FROM mineral_families + WHERE sg_min IS NOT NULL AND sg_max IS NOT NULL + ORDER BY name` + ); + + if (result.length === 0) return []; + + const columns = result[0].columns; + return result[0].values.map((row) => { + const family: Record = {}; + columns.forEach((col, i) => { + family[col] = row[i]; + }); + return family as MineralFamily; + }); +} + +/** + * Get families with dispersion data (for calculators - eliminates duplicates). + */ +export async function getFamiliesWithDispersion(): Promise { + const database = await getDB(); + const result = database.exec( + `SELECT id, name, dispersion, ri_min, ri_max + FROM mineral_families + WHERE dispersion IS NOT NULL AND dispersion > 0 + ORDER BY dispersion DESC` + ); + + if (result.length === 0) return []; + + const columns = result[0].columns; + return result[0].values.map((row) => { + const family: Record = {}; + columns.forEach((col, i) => { + family[col] = row[i]; + }); + return family as MineralFamily; + }); +}