diff --git a/src/components/advanced/AdvancedTools.tsx b/src/components/advanced/AdvancedTools.tsx index f0daed0..6c8ce0f 100644 --- a/src/components/advanced/AdvancedTools.tsx +++ b/src/components/advanced/AdvancedTools.tsx @@ -4,11 +4,13 @@ */ import { TreatmentDetection } from './TreatmentDetection'; +import { TreatmentWizard } from './TreatmentWizard'; import { ProportionAnalyzer } from './ProportionAnalyzer'; import { ToolSection } from '../ui/ToolSection'; const ICON_PATHS = { treatment: 'M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z', + wizard: 'M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z', ruler: 'M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01', }; @@ -25,6 +27,16 @@ export function AdvancedTools() { + + + + = { + Excellent: 'bg-green-200 text-green-800', + 'Very Good': 'bg-blue-200 text-blue-800', + Good: 'bg-slate-200 text-slate-800', + Fair: 'bg-amber-200 text-amber-800', + Poor: 'bg-red-200 text-red-800', +}; interface ProportionStandard { cut: string; @@ -130,6 +168,8 @@ export function ProportionAnalyzer() { const [depth, setDepth] = useState(''); const [crownAngle, setCrownAngle] = useState(''); const [pavilionAngle, setPavilionAngle] = useState(''); + const [girdle, setGirdle] = useState(''); + const [culet, setCulet] = useState(''); const selectedStandard = PROPORTION_STANDARDS.find( s => `${s.cut} (${s.gem})` === selectedCut @@ -208,6 +248,20 @@ export function ProportionAnalyzer() { const analysis = analyzeProportions(); + const isDiamondRoundBrilliant = + selectedStandard?.cut === 'Round Brilliant' && selectedStandard?.gem === 'Diamond'; + const giaResult = isDiamondRoundBrilliant + ? gradeRoundBrilliant({ + tablePercent: table ? parseFloat(table) : undefined, + depthPercent: depth ? parseFloat(depth) : undefined, + crownAngle: crownAngle ? parseFloat(crownAngle) : undefined, + pavilionAngle: pavilionAngle ? parseFloat(pavilionAngle) : undefined, + girdleThickness: girdle || undefined, + culetSize: culet || undefined, + }) + : null; + const giaInputCount = giaResult?.parameterGrades.length ?? 0; + return (
@@ -336,6 +390,83 @@ export function ProportionAnalyzer() {
+ {isDiamondRoundBrilliant && ( +
+
+ + +
+
+ + +
+
+ )} + + {giaResult && giaInputCount > 0 && ( +
+
+
+ GIA-style Cut Grade: {giaResult.grade} +
+ + Diamond round brilliant + +
+ {giaResult.limitingParameter && giaResult.grade !== 'Excellent' && ( +

+ Limiting parameter: {giaResult.limitingParameter.comment} +

+ )} +
+ Per-parameter: +
    + {giaResult.parameterGrades.map((p, idx) => ( +
  • + + {p.grade} + + {p.parameter} = {p.value} +
  • + ))} +
+
+

+ Educational reference only — formal GIA grading also weighs symmetry, polish, and overall appeal. +

+
+ )} + {analysis && (
= { + unlikely: 'bg-slate-100 text-slate-700', + possible: 'bg-amber-50 text-amber-800 border border-amber-200', + likely: 'bg-orange-100 text-orange-800', + 'very likely': 'bg-rose-100 text-rose-800 border border-rose-200', +}; + +export function TreatmentWizard() { + const [gemKind, setGemKind] = useState('corundum'); + const [selected, setSelected] = useState>(new Set()); + + const availableClues = useMemo(() => cluesForKind(gemKind), [gemKind]); + + const verdicts = useMemo( + () => runWizard({ gemKind, selectedClueIds: Array.from(selected) }), + [gemKind, selected], + ); + + const toggle = (id: string) => { + setSelected((s) => { + const next = new Set(s); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + const handleGemChange = (next: string) => { + setGemKind(next as GemKind); + // Drop clues that no longer apply to the new kind. + const allowedIds = new Set(cluesForKind(next as GemKind).map((c) => c.id)); + setSelected((s) => new Set(Array.from(s).filter((id) => allowedIds.has(id)))); + }; + + return ( +
+

+ Pick the gem kind, tick the clues you actually see under loupe, microscope, UV, or warming. + The wizard weighs each clue and ranks treatments by likelihood. A negative score means the + clue argues against that treatment. +

+ + + toggle(clue.id)} + className="mt-1 accent-cyan-600" + /> +
+
{clue.label}
+ {clue.description && ( +
{clue.description}
+ )} +
+ + ); + })} +
+ {selected.size > 0 && ( + + )} +
+ + {selected.size === 0 ? ( +
+ Tick at least one clue above to see ranked treatments. +
+ ) : verdicts.length === 0 ? ( +
+ Selected clues do not point to any common treatment — likely natural / untreated within + the limits of these observations. +
+ ) : ( +
+

+ {verdicts.length} candidate treatment{verdicts.length === 1 ? '' : 's'} +

+ {verdicts.map((v) => ( +
+
+
{v.label}
+ + {v.confidence} (score {v.score >= 0 ? '+' : ''} + {v.score}) + +
+ {v.supportingClueIds.length > 0 && ( +
+ Supports:{' '} + {v.supportingClueIds + .map((id) => availableClues.find((c) => c.id === id)?.label ?? id) + .join('; ')} +
+ )} + {v.contradictingClueIds.length > 0 && ( +
+ Argues against:{' '} + {v.contradictingClueIds + .map((id) => availableClues.find((c) => c.id === id)?.label ?? id) + .join('; ')} +
+ )} +
+ ))} +
+ )} + +
+ Note: this wizard reasons over visual & instrumental clues only. Some treatments (e.g. + beryllium lattice diffusion, low-temperature heating of pastel sapphire) require advanced + spectroscopy (LIBS / FTIR / UV-Vis) for definitive detection. Consult a recognised gem + laboratory when stakes are high. +
+ + ); +} diff --git a/src/components/advanced/index.ts b/src/components/advanced/index.ts index 0f93dbd..5a03076 100644 --- a/src/components/advanced/index.ts +++ b/src/components/advanced/index.ts @@ -3,5 +3,6 @@ */ export { TreatmentDetection } from './TreatmentDetection'; +export { TreatmentWizard } from './TreatmentWizard'; export { ProportionAnalyzer } from './ProportionAnalyzer'; export { AdvancedTools } from './AdvancedTools'; diff --git a/src/components/calculator/BirefringenceCalc.tsx b/src/components/calculator/BirefringenceCalc.tsx index be4290f..6b25df9 100644 --- a/src/components/calculator/BirefringenceCalc.tsx +++ b/src/components/calculator/BirefringenceCalc.tsx @@ -47,7 +47,7 @@ export function BirefringenceCalc() {

-
+
= { + thin: 1.00, + medium: 1.02, + 'slightly-thick': 1.04, + thick: 1.06, + 'very-thick': 1.09, + 'extremely-thick': 1.12, +}; + +const GIRDLE_OPTIONS: { value: GirdleAdjustment; label: string }[] = [ + { value: 'thin', label: 'Thin (×1.00)' }, + { value: 'medium', label: 'Medium (×1.02)' }, + { value: 'slightly-thick', label: 'Slightly thick (×1.04)' }, + { value: 'thick', label: 'Thick (×1.06)' }, + { value: 'very-thick', label: 'Very thick (×1.09)' }, + { value: 'extremely-thick', label: 'Extremely thick (×1.12)' }, +]; + // Fallback shape options (used when database not available) const FALLBACK_SHAPES: { value: Shape; label: string }[] = [ { value: 'round-brilliant', label: 'Round Brilliant' }, @@ -44,6 +75,7 @@ const FALLBACK_SG = [ export function CaratEstimator() { const [sgSource, setSgSource] = useState<'preset' | 'custom'>('preset'); const [sgCustom, setSgCustom] = useState(''); + const [girdle, setGirdle] = useState('medium'); const { shapeFactors, mineralsWithSG, fallbackShapeFactors } = useCalculatorData(); @@ -120,10 +152,11 @@ export function CaratEstimator() { const shape = values.shape as Shape; const dbFactor = shapes.find(s => s.value === shape); const factor = dbFactor?.factor ?? fallbackShapeFactors[shape] ?? 0.0061; - const carats = length * width * depth * sgValue * factor; + const girdleFactor = GIRDLE_FACTORS[girdle]; + const carats = length * width * depth * sgValue * factor * girdleFactor; - return { carats, factor, grams: carats * 0.2 }; - }, [parsedValues, sgSource, sgCustom, values.shape, shapes, fallbackShapeFactors]); + return { carats, factor, girdleFactor, grams: carats * 0.2 }; + }, [parsedValues, sgSource, sgCustom, values.shape, shapes, fallbackShapeFactors, girdle]); // Custom SG validation const sgError = sgSource === 'custom' && sgCustom @@ -148,7 +181,7 @@ export function CaratEstimator() {

-
+
-
+
setGirdle(v as GirdleAdjustment)} + /> + +
setRow(i, { liquidId: v })} + /> + + + setMode(v as 'single' | 'double')} + /> + + - + {mode === 'double' ? ( + + + + ) : ( + + setValue('tolerance', v)} /> -
+ )} + + {doubleReadingResult && ( +
+
Double-reading inference
+
+ RI {doubleReadingResult.ri1.toFixed(3)} – {doubleReadingResult.ri2.toFixed(3)}, + birefringence {doubleReadingResult.birefringence.toFixed(3)}{' '} + ({doubleReadingResult.classification}) +
+
+ Optic character: {doubleReadingResult.character} — {doubleReadingResult.characterLabel} +
+
+ Matches below use the average RI ({doubleReadingResult.lookupRI.toFixed(3)}) ± {values.tolerance}. +
+
+ )} - {result !== null && ( + {lookupTarget !== null && (
{matches.length > 0 ? ( ) : (
- No common gems found with RI {values.ri} (±{values.tolerance}). Try widening the tolerance or - checking your reading. + No common gems found near RI {lookupTarget.toFixed(3)} (±{values.tolerance}). Try widening the tolerance or + checking your readings.
)}
diff --git a/src/components/calculator/SGCalculator.tsx b/src/components/calculator/SGCalculator.tsx index 1e4f3b8..c3574aa 100644 --- a/src/components/calculator/SGCalculator.tsx +++ b/src/components/calculator/SGCalculator.tsx @@ -1,18 +1,73 @@ /** * Specific Gravity Calculator component. - * Uses hydrostatic weighing method: SG = Weight in air / (Weight in air - Weight in water) - * Matches against minerals in the database with fallback to hardcoded reference data. + * + * Hydrostatic weighing: SG = W_air / ((W_air − W_water) / ρ_water(T)) + * The water-temperature correction adjusts for the small density change + * of water with temperature, which matters for high-precision SG below ~3.0. */ -import { useEffect } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useCalculatorForm } from '../../hooks/useCalculatorForm'; import { useGemLookup, formatSG } from '../../hooks/useGemLookup'; -import { calculateSG } from '../../lib/calculator/conversions'; import { validateNumber } from './ValidationMessage'; -import { FormField, NumberInput } from '../form'; +import { FormField, NumberInput, Select } from '../form'; import { NumberResult, GemMatchBadges } from './results'; +/** + * Density of pure water (g/cm³) at 1 atm, NIST table. + * Linear interpolation between samples is more than accurate enough for + * gem-room work. + */ +const WATER_DENSITY_TABLE: { tempC: number; density: number }[] = [ + { tempC: 4, density: 1.00000 }, + { tempC: 15, density: 0.99913 }, + { tempC: 20, density: 0.99821 }, + { tempC: 25, density: 0.99704 }, + { tempC: 30, density: 0.99565 }, +]; + +const TEMP_OPTIONS = [ + { value: '20', label: '20 °C (room temperature, default)' }, + { value: '4', label: '4 °C (max density — ideal but cold)' }, + { value: '15', label: '15 °C (cool tap water)' }, + { value: '25', label: '25 °C (warm room)' }, + { value: '30', label: '30 °C (warm)' }, +]; + +function waterDensityAt(tempC: number): number { + const table = WATER_DENSITY_TABLE; + if (tempC <= table[0].tempC) return table[0].density; + if (tempC >= table[table.length - 1].tempC) return table[table.length - 1].density; + for (let i = 1; i < table.length; i++) { + const lo = table[i - 1]; + const hi = table[i]; + if (tempC <= hi.tempC) { + const t = (tempC - lo.tempC) / (hi.tempC - lo.tempC); + return lo.density + t * (hi.density - lo.density); + } + } + return 1.0; +} + +/** + * Pure SG formula with water-density correction. + * SG = W_air / ((W_air − W_water) / ρ_water(T)) + */ +export function calculateSGCorrected( + weightInAir: number, + weightInWater: number, + waterDensity: number, +): number { + if (weightInAir <= 0) return 0; + const denom = (weightInAir - weightInWater) / waterDensity; + if (denom <= 0) return 0; + return weightInAir / denom; +} + export function SGCalculator() { + const [tempC, setTempC] = useState('20'); + const waterDensity = useMemo(() => waterDensityAt(parseFloat(tempC) || 20), [tempC]); + const { values, errors, result, setValue } = useCalculatorForm({ fields: { weightInAir: { @@ -35,7 +90,7 @@ export function SGCalculator() { compute: ({ weightInAir, weightInWater }) => { if (weightInAir === undefined || weightInWater === undefined) return null; if (weightInAir <= 0 || weightInWater >= weightInAir) return null; - return calculateSG(weightInAir, weightInWater); + return calculateSGCorrected(weightInAir, weightInWater, waterDensity); }, }); @@ -55,11 +110,11 @@ export function SGCalculator() {

Enter the weight of your stone in air and water to calculate its specific gravity.

- Formula: SG = Weight in air ÷ (Weight in air − Weight in water) + Formula: SG = Wair ÷ ((Wair − Wwater) ÷ ρwater(T))

-
+
+ + + + + +
+ +
+
+
+ + {observed.length === 0 ? ( +
+ Tick at least one band above to see ranked candidates. +
+ ) : matches.length === 0 ? ( +
+ No reference species match these bands within ± {tolerance} nm. Try widening the tolerance or rechecking the readings against a reference scale. +
+ ) : ( +
+

+ {matches.length} candidate {matches.length === 1 ? 'species' : 'species'} ({observed.length} band{observed.length === 1 ? '' : 's'} ticked) +

+ {matches.map((m) => ( +
+
+
+ + {m.reference.name} + + {m.hasSelective && ( + + diagnostic + + )} +
+
coverage {(m.coverage * 100).toFixed(0)}%
+
+
{m.reason}
+
+ {m.matched.map((mb, i) => ( + + {mb.observed} nm{mb.band.cause ? ` (${mb.band.cause})` : ''} + + ))} +
+
+ ))} +
+ )} +
+ ); +} diff --git a/src/components/lab/UvFluorescenceLookup.tsx b/src/components/lab/UvFluorescenceLookup.tsx new file mode 100644 index 0000000..07301b7 --- /dev/null +++ b/src/components/lab/UvFluorescenceLookup.tsx @@ -0,0 +1,239 @@ +/** + * UV Fluorescence Lookup widget. + * + * The user reports observed LWUV and SWUV reactions; the widget parses the + * freeform `fluorescence` field of every mineral family in the database and + * returns a ranked candidate list, with treatment red-flag warnings where + * the observation matches a known treatment signature (e.g., chalky SWUV + * in heated sapphire). + */ + +import { useEffect, useMemo, useState } from 'react'; +import { getAllFamilies, type MineralFamily } from '../../lib/db'; +import { + parseFluorescence, + scoreUvMatch, + type UvIntensity, +} from '../../lib/uv-fluorescence/parse-fluorescence'; +import { FormField, Select } from '../form'; +import { Pagination } from '../ui'; +import { usePagination } from '../../hooks/usePagination'; + +const INTENSITY_OPTIONS: { value: UvIntensity; label: string }[] = [ + { value: 'unknown', label: '— skip this band —' }, + { value: 'inert', label: 'Inert (no reaction)' }, + { value: 'weak', label: 'Weak' }, + { value: 'moderate', label: 'Moderate' }, + { value: 'strong', label: 'Strong' }, + { value: 'very_strong', label: 'Very strong' }, +]; + +const COLOR_OPTIONS = [ + { value: '', label: '— any colour —' }, + { value: 'red', label: 'Red' }, + { value: 'orange', label: 'Orange' }, + { value: 'yellow', label: 'Yellow' }, + { value: 'green', label: 'Green' }, + { value: 'blue', label: 'Blue' }, + { value: 'violet', label: 'Violet / purple' }, + { value: 'pink', label: 'Pink' }, + { value: 'white', label: 'White' }, + { value: 'chalky', label: 'Chalky / cloudy' }, +]; + +interface MatchRow { + family: MineralFamily; + score: number; + treatmentFlag?: string; +} + +function detectTreatmentFlag(family: MineralFamily, swuvColor: string): string | undefined { + if (!family.fluorescence) return undefined; + const text = family.fluorescence.toLowerCase(); + // Classic chalky-SWUV reaction in heat-treated sapphire. + if ( + swuvColor === 'chalky' && + /sapphire|corundum/.test(family.name.toLowerCase()) && + /chalk|cloudy/.test(text) + ) { + return 'Chalky SWUV is a strong indicator of heat treatment in corundum.'; + } + return undefined; +} + +export function UvFluorescenceLookup() { + const [families, setFamilies] = useState([]); + const [loading, setLoading] = useState(true); + const [dbError, setDbError] = useState(null); + + const [lwuvIntensity, setLwuvIntensity] = useState('strong'); + const [lwuvColor, setLwuvColor] = useState(''); + const [swuvIntensity, setSwuvIntensity] = useState('unknown'); + const [swuvColor, setSwuvColor] = useState(''); + + useEffect(() => { + let mounted = true; + (async () => { + try { + const all = await getAllFamilies(); + if (!mounted) return; + setFamilies(all); + } catch (err) { + if (!mounted) return; + setDbError(err instanceof Error ? err.message : 'Database load failed'); + } finally { + if (mounted) setLoading(false); + } + })(); + return () => { + mounted = false; + }; + }, []); + + const obs = { lwuvIntensity, lwuvColor, swuvIntensity, swuvColor }; + const hasObservation = lwuvIntensity !== 'unknown' || swuvIntensity !== 'unknown'; + + const matches = useMemo(() => { + if (!hasObservation || families.length === 0) return []; + const out: MatchRow[] = []; + for (const family of families) { + const fl = parseFluorescence(family.fluorescence); + if (!fl) continue; + const score = scoreUvMatch(obs, fl); + if (score < 0.4) continue; + out.push({ + family, + score, + treatmentFlag: detectTreatmentFlag(family, swuvColor), + }); + } + return out.sort((a, b) => b.score - a.score).slice(0, 50); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [families, lwuvIntensity, lwuvColor, swuvIntensity, swuvColor]); + + const { page, params, onPageChange, onPageSizeChange, resetPage } = usePagination({ + initialPageSize: 10, + }); + useEffect(() => { + resetPage(); + }, [matches.length, resetPage]); + + const totalPages = Math.ceil(matches.length / params.pageSize); + const startIndex = (page - 1) * params.pageSize; + const paginated = matches.slice(startIndex, startIndex + params.pageSize); + const pagination = { + page, + pageSize: params.pageSize, + total: matches.length, + totalPages, + hasNext: page < totalPages, + hasPrev: page > 1, + }; + + return ( +
+

+ Observe the stone under both long-wave (365 nm) and short-wave (254 nm) UV in a darkened + cabinet and report what you see. The reasoner ranks species whose stored fluorescence text + matches your observation. +

+ +
+

Long-wave (365 nm)

+
+ + + +
+
+ +
+

Short-wave (254 nm)

+
+ + + +
+
+ + {loading && ( +
+ Loading mineral database… +
+ )} + {dbError && ( +
+ Database unavailable: {dbError} +
+ )} + + {!loading && !dbError && families.length > 0 && ( + <> + {!hasObservation ? ( +
+ Select an LW or SW intensity to see ranked candidates. +
+ ) : matches.length === 0 ? ( +
+ No species match these UV reactions. Try widening colour or intensity, or recheck observations under darker conditions. +
+ ) : ( +
+

+ {matches.length} candidate {matches.length === 1 ? 'family' : 'families'} +

+ {paginated.map((m) => ( +
+
+ + {m.family.name} + +
match {(m.score * 100).toFixed(0)}%
+
+ {m.family.fluorescence && ( +
+ Stored: {m.family.fluorescence} +
+ )} + {m.treatmentFlag && ( +
+ ⚠ {m.treatmentFlag} +
+ )} +
+ ))} + {totalPages > 1 && ( + + )} +
+ )} + + )} +
+ ); +} diff --git a/src/components/optical/OpticSignReasoner.tsx b/src/components/optical/OpticSignReasoner.tsx new file mode 100644 index 0000000..8cf08b3 --- /dev/null +++ b/src/components/optical/OpticSignReasoner.tsx @@ -0,0 +1,311 @@ +/** + * Optic-sign / 2V reasoner. + * + * The user reports the optic character seen in the polariscope plus the + * relevant refractive indices (ω/ε for uniaxial, α/β/γ for biaxial). The + * widget computes the optic sign, birefringence, and 2V (when applicable), + * then ranks candidate species from the database whose stored + * `optical_character` matches the observation. + * + * The mineral DB stores optic character as freeform text — we parse it via + * `parseOpticalCharacter()` so no SQL schema migration is needed. + */ + +import { useEffect, useMemo, useState } from 'react'; +import { getAllMinerals, type Mineral } from '../../lib/db'; +import { + parseOpticalCharacter, + uniaxialSign, + biaxialSign, + biaxial2V, + characterMatches, + type OpticCharacterKind, + type OpticSign, +} from '../../lib/optic-sign/optic-character'; +import { FormField, NumberInput, Select } from '../form'; +import { Pagination } from '../ui'; +import { usePagination } from '../../hooks/usePagination'; + +const CHARACTER_OPTIONS: { value: OpticCharacterKind; label: string }[] = [ + { value: 'isotropic', label: 'Isotropic (single dark cross or dark all rotations)' }, + { value: 'uniaxial', label: 'Uniaxial (one optic axis)' }, + { value: 'biaxial', label: 'Biaxial (two optic axes)' }, + { value: 'aggregate', label: 'Aggregate (snake-like flickering = AGG)' }, +]; + +interface MatchRow { + mineral: Mineral; + riOverlap: boolean; + birefringenceOverlap: boolean; +} + +const SIGN_LABEL: Record = { + '+': 'positive', + '-': 'negative', + '+/-': 'either sign (variable)', + 'n/a': '— not applicable —', +}; + +export function OpticSignReasoner() { + const [minerals, setMinerals] = useState([]); + const [loading, setLoading] = useState(true); + const [dbError, setDbError] = useState(null); + + const [character, setCharacter] = useState('uniaxial'); + const [omega, setOmega] = useState(''); + const [epsilon, setEpsilon] = useState(''); + const [alpha, setAlpha] = useState(''); + const [beta, setBeta] = useState(''); + const [gamma, setGamma] = useState(''); + const tolerance = 0.005; + + useEffect(() => { + let mounted = true; + (async () => { + try { + const all = await getAllMinerals(); + if (!mounted) return; + setMinerals(all); + } catch (err) { + if (!mounted) return; + setDbError(err instanceof Error ? err.message : 'Database load failed'); + } finally { + if (mounted) setLoading(false); + } + })(); + return () => { + mounted = false; + }; + }, []); + + const computed = useMemo(() => { + if (character === 'isotropic') { + return { + sign: 'n/a' as OpticSign, + birefringence: 0, + twoV: null as number | null, + riCentre: null as number | null, + }; + } + if (character === 'aggregate') { + return { + sign: 'n/a' as OpticSign, + birefringence: null, + twoV: null, + riCentre: null, + }; + } + if (character === 'uniaxial') { + const o = parseFloat(omega); + const e = parseFloat(epsilon); + if (Number.isNaN(o) || Number.isNaN(e)) { + return { sign: 'n/a' as OpticSign, birefringence: null, twoV: null, riCentre: null }; + } + return { + sign: uniaxialSign(o, e), + birefringence: Math.abs(e - o), + twoV: null, + riCentre: (o + e) / 2, + }; + } + // biaxial + const a = parseFloat(alpha); + const b = parseFloat(beta); + const g = parseFloat(gamma); + if (Number.isNaN(a) || Number.isNaN(g)) { + return { sign: 'n/a' as OpticSign, birefringence: null, twoV: null, riCentre: null }; + } + const aSorted = Math.min(a, g); + const gSorted = Math.max(a, g); + const bUsed = Number.isNaN(b) ? (aSorted + gSorted) / 2 : b; + return { + sign: biaxialSign(aSorted, bUsed, gSorted), + birefringence: gSorted - aSorted, + twoV: biaxial2V(aSorted, bUsed, gSorted), + riCentre: (aSorted + gSorted) / 2, + }; + }, [character, omega, epsilon, alpha, beta, gamma]); + + const matches = useMemo(() => { + if (minerals.length === 0) return []; + const out: MatchRow[] = []; + const obsSign: OpticSign = computed.sign; + const ri = computed.riCentre; + const br = computed.birefringence; + + for (const m of minerals) { + const ref = parseOpticalCharacter(m.optical_character); + if (!characterMatches(character, obsSign, ref)) continue; + + let riOk = true; + if (ri !== null && m.ri_min !== undefined && m.ri_max !== undefined) { + riOk = + ri >= m.ri_min - tolerance && ri <= m.ri_max + tolerance; + } + + let brOk = true; + if (br !== null && br > 0 && m.birefringence !== undefined) { + brOk = Math.abs(m.birefringence - br) <= 0.01; + } + + if (!riOk) continue; + out.push({ + mineral: m, + riOverlap: + ri !== null && m.ri_min !== undefined && m.ri_max !== undefined && riOk, + birefringenceOverlap: brOk && br !== null && br > 0, + }); + } + + out.sort((x, y) => { + // Items with both RI and birefringence overlap rank highest. + const xs = (x.riOverlap ? 2 : 0) + (x.birefringenceOverlap ? 1 : 0); + const ys = (y.riOverlap ? 2 : 0) + (y.birefringenceOverlap ? 1 : 0); + if (ys !== xs) return ys - xs; + return x.mineral.name.localeCompare(y.mineral.name); + }); + return out; + }, [minerals, character, computed]); + + const { paged, page, setPage, totalPages } = usePagination(matches, 10); + + return ( +
+

+ Pick what you saw in the polariscope. For uniaxial gems, enter ω and ε from the + refractometer; for biaxial, enter α and γ (β is optional). The reasoner derives optic + sign, birefringence, and 2V where defined, then ranks candidate species. +

+ +
+ + setColourCount(Number(v) as ObservedColourCount)} + /> + + +
+
{interpretation.title}
+
{interpretation.body}
+
+
+ + {/* Step 2 — colours */} +
+

Step 2: Observed colours

+

+ Type the colour name in plain English ("yellowish-green", "blue", "pale violet"). Order does not matter. +

+
+ + setC1(e.target.value)} + className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-crystal-500 focus:border-crystal-500" + placeholder="e.g., yellow-green" + /> + + {colourCount >= 2 && ( + + setC2(e.target.value)} + className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-crystal-500 focus:border-crystal-500" + placeholder="e.g., blue-green" + /> + + )} + {colourCount >= 3 && ( + + setC3(e.target.value)} + className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-crystal-500 focus:border-crystal-500" + placeholder="e.g., red-brown" + /> + + )} +
+
+ + {/* Step 3 — strength */} +
+ +