From a31a38228947013a1c9e4c08e7fe9a2a8addba98 Mon Sep 17 00:00:00 2001 From: Bissbert <43237892+Bissbert@users.noreply.github.com> Date: Tue, 5 May 2026 20:15:19 +0700 Subject: [PATCH 1/3] fix(tools): remove price-per-carat advertising; refractometer OTL; mobile grids MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tools hub advertised features that did not exist (price-per-carat calculator, side-by-side gem comparison, origin characteristics guide, pleochroism reference) and the refractometer simulator silently dropped gems with RI > 1.81 — exactly the over-the-limit teaching moment it should surface. Several calculator grids also rendered fixed two- or three-column layouts on phones. Changes - ToolsHub: rewrite four card descriptions to match what is actually rendered. Pricing is intentionally out of scope (purely gemmological site, gem prices change constantly). - conversions.astro: drop pricing copy from page title and meta description. - RefractometerSimulator: keep over-the-limit gems in the dropdown and render an "Over the limit — continuously bright field, no shadow edge" panel instead of silently filtering them out. - SG, Birefringence, RI Lookup, Length, Temperature, Carat (two grids): add 'grid-cols-1' fallback with 'md:grid-cols-N' for proper mobile stacking. - MeasurementTools: add a Learn-more block linking to /learn/equipment/sg-measurement, /learn/equipment/refractometer, and the optical/physical-properties fundamentals pages. --- .../calculator/BirefringenceCalc.tsx | 2 +- src/components/calculator/CaratEstimator.tsx | 4 +-- src/components/calculator/LengthConverter.tsx | 2 +- .../calculator/MeasurementTools.tsx | 27 +++++++++++++++++++ src/components/calculator/RICalculator.tsx | 2 +- src/components/calculator/SGCalculator.tsx | 2 +- .../calculator/TemperatureConverter.tsx | 2 +- .../optical/RefractometerSimulator.tsx | 25 +++++++++++++---- src/components/tools/ToolsHub.tsx | 8 +++--- src/pages/tools/conversions.astro | 4 +-- 10 files changed, 60 insertions(+), 18 deletions(-) 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() {

-
+
-
+
-
+
setGirdle(e.target.value as GirdleThickness | '')} + className="w-full px-3 py-2 text-sm border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-crystal-500" + > + {GIRDLE_OPTIONS.map((o) => ( + + ))} + +
+
+ + +
+
+ )} + + {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 && (
= { + 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 @@ -189,6 +222,14 @@ export function CaratEstimator() { /> + + )}
-

Note: These are estimates. Actual weight varies with exact proportions, girdle thickness, and cut quality.

-

Example (1ct diamond): 6.5 × 6.5 × 4.0 mm, SG 3.52, Round = ~1.0 ct

+

Note: These are estimates. Actual weight varies with exact proportions, symmetry, and cut quality. The girdle factor accounts for material carried in a thicker-than-medium girdle.

+

Example (1ct diamond): 6.5 × 6.5 × 4.0 mm, SG 3.52, Round, medium girdle = ~1.0 ct

); diff --git a/src/components/calculator/HannemanRI.tsx b/src/components/calculator/HannemanRI.tsx new file mode 100644 index 0000000..7dd34b7 --- /dev/null +++ b/src/components/calculator/HannemanRI.tsx @@ -0,0 +1,203 @@ +/** + * Hanneman / Hodgkinson short-cut RI widget. + * + * For over-the-limit (OTL) and rough-stone identification: the user reports + * up to three liquid-comparison observations and the widget returns an + * inferred RI band plus a list of candidate species filtered from the DB. + */ + +import { useEffect, useMemo, useState } from 'react'; +import { getAllMinerals, type Mineral } from '../../lib/db'; +import { + CONTACT_LIQUIDS, + combineBands, + filterMineralsByBand, + type HannemanCriteria, + type Relief, +} from '../../lib/calculator/hanneman'; +import { FormField, Select } from '../form'; +import { Pagination } from '../ui'; +import { usePagination } from '../../hooks/usePagination'; + +type Row = HannemanCriteria; + +const RELIEF_OPTIONS: { value: Relief; label: string }[] = [ + { value: 'unknown', label: '— skip this row —' }, + { value: 'lower', label: 'Stone shows lower relief (RI < liquid)' }, + { value: 'equal', label: 'Stone disappears / equal (RI ≈ liquid)' }, + { value: 'higher', label: 'Stone shows higher relief (RI > liquid)' }, +]; + +const liquidOptions = CONTACT_LIQUIDS.map((l) => ({ + value: l.id, + label: `${l.name} — RI ${l.ri.toFixed(3)}`, +})); + +export function HannemanRI() { + const [minerals, setMinerals] = useState([]); + const [loading, setLoading] = useState(true); + const [dbError, setDbError] = useState(null); + + const [rows, setRows] = useState([ + { liquidId: 'methylene-iodide', relief: 'unknown' }, + { liquidId: 'methylene-iodide-si', relief: 'unknown' }, + { liquidId: 'mono-bromo', relief: 'unknown' }, + ]); + + 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 usable = useMemo(() => rows.filter((r) => r.relief !== 'unknown'), [rows]); + const band = useMemo(() => (usable.length === 0 ? null : combineBands(usable)), [usable]); + const matches = useMemo(() => { + if (!band || band.min > band.max) return []; + if (minerals.length === 0) return []; + return filterMineralsByBand(band, minerals); + }, [band, minerals]); + + 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, + }; + + const setRow = (i: number, patch: Partial) => + setRows((rs) => rs.map((r, idx) => (idx === i ? { ...r, ...patch } : r))); + + return ( +
+
+

+ For stones above the refractometer scale (RI {'>'} 1.81) or rough material with no + polished facet. Place the stone in a drop of each liquid and compare relief, then + report what you see. +

+
+ +
+ {rows.map((row, i) => ( +
+ + setRow(i, { relief: v as Relief })} + /> + +
+ ))} +
+ + {loading && ( +
+ Loading mineral database… +
+ )} + {dbError && ( +
+ Database unavailable: {dbError} +
+ )} + + {usable.length === 0 ? ( +
+ Select at least one observation above to infer an RI band. +
+ ) : !band ? null : band.min > band.max ? ( +
+ Conflicting observations. {band.rationale} Re-test with the suspect liquid. +
+ ) : ( + <> +
+
+ Inferred RI band: {band.min.toFixed(2)} – {band.max.toFixed(2)} +
+
{band.rationale}
+
+ + {!loading && !dbError && ( +
+

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

+ {matches.length === 0 ? ( +
+ No species match this RI band. Re-check observations or try wider liquids. +
+ ) : ( + paginated.map((m) => ( +
+
+ + {m.mineral.name} + +
+ RI {m.mineral.ri_min?.toFixed(3)} – {m.mineral.ri_max?.toFixed(3)} +
+
+ {m.mineral.optical_character && ( +
+ {m.mineral.optical_character} +
+ )} +
+ )) + )} + {totalPages > 1 && ( + + )} +
+ )} + + )} +
+ ); +} diff --git a/src/components/calculator/MeasurementTools.tsx b/src/components/calculator/MeasurementTools.tsx index 7f9376f..273306a 100644 --- a/src/components/calculator/MeasurementTools.tsx +++ b/src/components/calculator/MeasurementTools.tsx @@ -10,6 +10,7 @@ import { CriticalAngleCalc } from './CriticalAngleCalc'; import { CaratEstimator } from './CaratEstimator'; import { DispersionCalculator } from './DispersionCalculator'; import { DensityEstimator } from './DensityEstimator'; +import { HannemanRI } from './HannemanRI'; import { ToolSection } from '../ui/ToolSection'; const ICON_PATHS = { @@ -95,6 +96,16 @@ export function MeasurementTools() { + + + + {/* Learn More section */}

Learn More

diff --git a/src/components/calculator/RICalculator.tsx b/src/components/calculator/RICalculator.tsx index 98725bd..5969d2c 100644 --- a/src/components/calculator/RICalculator.tsx +++ b/src/components/calculator/RICalculator.tsx @@ -1,10 +1,10 @@ /** * RI Lookup Calculator component. - * Look up gems by refractive index value. - * Uses database with fallback to hardcoded reference data. + * Look up gems by refractive index value, with optional double-reading mode + * that infers birefringence and optic character (SR vs DR) on the fly. */ -import { useEffect } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useCalculatorForm } from '../../hooks/useCalculatorForm'; import { useGemLookup, formatRI, formatSG } from '../../hooks/useGemLookup'; import { useCalculatorData } from '../../hooks/useCalculatorData'; @@ -12,6 +12,10 @@ import { validateRI } from './ValidationMessage'; import { FormField, NumberInput, Select } from '../form'; import { GemMatchList } from './results'; import { Table } from '../ui'; +import { + calculateBirefringence, + classifyBirefringence, +} from '../../lib/calculator/conversions'; const TOLERANCE_OPTIONS = [ { value: '0.005', label: '± 0.005 (Narrow)' }, @@ -20,9 +24,17 @@ const TOLERANCE_OPTIONS = [ { value: '0.05', label: '± 0.05 (Very Wide)' }, ]; +const MODE_OPTIONS = [ + { value: 'single', label: 'Single reading' }, + { value: 'double', label: 'Double reading (anisotropic)' }, +]; + export function RICalculator() { const { fallbackGems } = useCalculatorData(); + const [mode, setMode] = useState<'single' | 'double'>('single'); + const [ri2, setRi2] = useState(''); + const { values, errors, result, setValue } = useCalculatorForm({ fields: { ri: { @@ -41,27 +53,62 @@ export function RICalculator() { }, }); - // Gem lookup with debouncing + const ri2Error = mode === 'double' && ri2 ? validateRI(ri2) : null; + + const doubleReadingResult = useMemo(() => { + if (mode !== 'double') return null; + const a = result?.ri; + const b = parseFloat(ri2); + if (a === undefined || isNaN(b) || b < 1) return null; + + const birefringence = calculateBirefringence(a, b); + const character = birefringence > 0.005 ? 'DR' : 'SR'; + const lookupRI = (a + b) / 2; + + return { + ri1: Math.min(a, b), + ri2: Math.max(a, b), + birefringence, + classification: classifyBirefringence(birefringence), + character, + characterLabel: + character === 'DR' + ? 'Doubly refractive (anisotropic — uniaxial or biaxial)' + : 'Singly refractive within reading tolerance — likely cubic, amorphous, or read along an optic axis', + lookupRI, + }; + }, [mode, result?.ri, ri2]); + + // Use the average of the two readings when in double mode, otherwise the single reading. + const lookupTarget = doubleReadingResult?.lookupRI ?? result?.ri ?? null; + const { matches, lookup } = useGemLookup({ type: 'ri', tolerance: parseFloat(values.tolerance) || 0.01, }); - // Trigger lookup when RI result changes useEffect(() => { - lookup(result?.ri ?? null); - }, [result?.ri, lookup]); + lookup(lookupTarget); + }, [lookupTarget, lookup]); return (
-

Enter an RI reading to find matching gemstones.

+

Enter an RI reading to find matching gemstones. Toggle Double reading to enter both shadow-edge readings (ω/ε or α/γ) and infer birefringence + optic character automatically.

-
+
+ + setValue('tolerance', v)} + /> + + )} +
+ + {mode === 'double' && ( + + +
+ +
+ ρwater at {tempC} °C = {waterDensity.toFixed(5)} g/cm³
{result !== null && ( @@ -111,8 +174,8 @@ export function SGCalculator() { )}
-

Example (Diamond): 3.52g in air, 2.52g in water = SG 3.52

-

Tip: Ensure the stone is fully submerged and free of air bubbles.

+

Example (Diamond): 3.52g in air, 2.52g in water at 20 °C = SG 3.52

+

Tip: Ensure the stone is fully submerged and free of air bubbles. Temperature correction matters most for low-SG materials (opal, amber, beryl).

); diff --git a/src/components/optical/OpticalTools.tsx b/src/components/optical/OpticalTools.tsx index cb79e4f..a0bed50 100644 --- a/src/components/optical/OpticalTools.tsx +++ b/src/components/optical/OpticalTools.tsx @@ -6,12 +6,14 @@ import { DichroscopeResults } from './DichroscopeResults'; import { PolariscopeGuide } from './PolariscopeGuide'; import { RefractometerSimulator } from './RefractometerSimulator'; +import { PleochroismReasoner } from './PleochroismReasoner'; import { ToolSection } from '../ui/ToolSection'; const ICON_PATHS = { eye: 'M15 12a3 3 0 11-6 0 3 3 0 016 0z M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z', adjust: 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z M15 12a3 3 0 11-6 0 3 3 0 016 0z', beaker: 'M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z', + prism: 'M12 2L2 22h20L12 2z M12 2v20', }; export function OpticalTools() { @@ -47,6 +49,16 @@ export function OpticalTools() { + + + + {/* Learn More section */}

Learn More

diff --git a/src/components/optical/PleochroismReasoner.tsx b/src/components/optical/PleochroismReasoner.tsx new file mode 100644 index 0000000..03e36c2 --- /dev/null +++ b/src/components/optical/PleochroismReasoner.tsx @@ -0,0 +1,260 @@ +/** + * Pleochroism Reasoner. + * + * A guided dichroscope-observation reasoner: the user reports how many + * distinct colours they saw, the colours themselves, and the perceived + * strength. The widget explains what the colour count implies (isotropic / + * uniaxial / biaxial) and ranks candidate species from the database. + */ + +import { useEffect, useMemo, useState } from 'react'; +import { getAllMinerals, type Mineral } from '../../lib/db'; +import { + matchPleochroism, + interpretColourCount, + type ObservedColourCount, + type PleochroismStrength, +} from '../../lib/pleochroism/match-pleochroism'; +import { FormField, Select } from '../form'; +import { usePagination } from '../../hooks/usePagination'; +import { Pagination } from '../ui'; + +const COLOUR_COUNT_OPTIONS = [ + { value: '1', label: '1 — single colour' }, + { value: '2', label: '2 — dichroic' }, + { value: '3', label: '3 — trichroic' }, +]; + +const STRENGTH_OPTIONS: { value: PleochroismStrength; label: string }[] = [ + { value: 'unknown', label: 'Not sure' }, + { value: 'weak', label: 'Weak' }, + { value: 'moderate', label: 'Moderate' }, + { value: 'strong', label: 'Strong' }, + { value: 'very_strong', label: 'Very strong' }, +]; + +const STRENGTH_BADGE: Record = { + very_strong: 'bg-red-100 text-red-700', + strong: 'bg-orange-100 text-orange-700', + moderate: 'bg-yellow-100 text-yellow-700', + weak: 'bg-slate-100 text-slate-600', +}; + +export function PleochroismReasoner() { + const [minerals, setMinerals] = useState([]); + const [loading, setLoading] = useState(true); + const [dbError, setDbError] = useState(null); + + const [colourCount, setColourCount] = useState(2); + const [c1, setC1] = useState(''); + const [c2, setC2] = useState(''); + const [c3, setC3] = useState(''); + const [strength, setStrength] = useState('unknown'); + + 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 interpretation = useMemo(() => interpretColourCount(colourCount), [colourCount]); + + const results = useMemo(() => { + if (minerals.length === 0) return []; + const colours = [c1, c2, c3] + .slice(0, colourCount) + .map((s) => s.trim()) + .filter(Boolean); + return matchPleochroism({ colourCount, colours, strength }, minerals); + }, [minerals, colourCount, c1, c2, c3, strength]); + + const { page, params, onPageChange, onPageSizeChange, resetPage } = usePagination({ + initialPageSize: 10, + }); + useEffect(() => { + resetPage(); + }, [results.length, resetPage]); + + const totalPages = Math.ceil(results.length / params.pageSize); + const startIndex = (page - 1) * params.pageSize; + const paginated = results.slice(startIndex, startIndex + params.pageSize); + const pagination = { + page, + pageSize: params.pageSize, + total: results.length, + totalPages, + hasNext: page < totalPages, + hasPrev: page > 1, + }; + + const observedColoursEntered = [c1, c2, c3].slice(0, colourCount).filter((s) => s.trim()).length; + + return ( +
+

+ Report what you saw through the dichroscope. The reasoner explains what your observation implies and ranks + candidate species from the mineral database. +

+ + {/* Step 1 — colour count */} +
+ + 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 */} +
+ + + + +
+

+ Observed clues ({selected.size} ticked of {availableClues.length}) +

+
+ {availableClues.map((clue) => { + const isOn = selected.has(clue.id); + return ( + + ); + })} +
+ {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/lab/LabTools.tsx b/src/components/lab/LabTools.tsx index 35ff554..4f9c202 100644 --- a/src/components/lab/LabTools.tsx +++ b/src/components/lab/LabTools.tsx @@ -5,13 +5,16 @@ import { ChelseaFilter } from './ChelseaFilter'; import { SpectroscopeCalculator } from './SpectroscopeCalculator'; +import { SpectroscopeBandMatcher } from './SpectroscopeBandMatcher'; import { HeavyLiquidReference } from './HeavyLiquidReference'; +import { UvFluorescenceLookup } from './UvFluorescenceLookup'; import { ToolSection } from '../ui/ToolSection'; const ICON_PATHS = { filter: 'M3 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V4z', spectrum: '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', beaker: 'M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z', + uv: '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', }; export function LabTools() { @@ -37,6 +40,26 @@ export function LabTools() { + + + + + + + + = { + violet: 'bg-violet-200 text-violet-900', + blue: 'bg-blue-200 text-blue-900', + cyan: 'bg-cyan-200 text-cyan-900', + green: 'bg-green-200 text-green-900', + yellow: 'bg-yellow-200 text-yellow-900', + orange: 'bg-orange-200 text-orange-900', + red: 'bg-red-200 text-red-900', +}; + +export function SpectroscopeBandMatcher() { + const [selected, setSelected] = useState>(new Set()); + const [customNm, setCustomNm] = useState(''); + const [tolerance, setTolerance] = useState('5'); + + const observed = useMemo(() => Array.from(selected).sort((a, b) => a - b), [selected]); + const matches = useMemo( + () => matchBands(observed, parseFloat(tolerance) || 5), + [observed, tolerance], + ); + + const toggle = (wl: number) => { + setSelected((s) => { + const next = new Set(s); + if (next.has(wl)) next.delete(wl); + else next.add(wl); + return next; + }); + }; + + const addCustom = () => { + const v = parseFloat(customNm); + if (isNaN(v) || v < 350 || v > 800) return; + setSelected((s) => new Set(s).add(Math.round(v))); + setCustomNm(''); + }; + + return ( +
+

+ Tick every absorption line you can see in the spectroscope. The reasoner ranks species + whose stored band patterns match. Selective (diagnostic) bands are weighted more heavily. +

+ +
+

Observed bands

+
+ {REFERENCE_BANDS.map((wl) => { + const isOn = selected.has(wl); + const swatch = COLOUR_SWATCH[colourFor(wl)]; + return ( + + ); + })} +
+ +
+ +
+ + +
+
+ + + setLwuvIntensity(v as UvIntensity)} + /> + + + setSwuvIntensity(v as UvIntensity)} + /> + + + setCharacter(v as OpticCharacterKind)} + /> + +
+ + {character === 'uniaxial' && ( +
+ + + + + + +
+ )} + + {character === 'biaxial' && ( +
+ + + + + + + + + +
+ )} + + {/* Computed-output panel */} + {character !== 'isotropic' && character !== 'aggregate' && ( +
+

Computed

+
+ Optic sign:{' '} + {SIGN_LABEL[computed.sign]} +
+ {computed.birefringence !== null && computed.birefringence > 0 && ( +
+ Birefringence: {computed.birefringence.toFixed(3)} +
+ )} + {computed.twoV !== null && ( +
+ 2V (Vz, Mallard): {computed.twoV.toFixed(1)}°{' '} + + (acute 2V around γ for biaxial+, around α for biaxial−) + +
+ )} +
+ )} + + {/* Candidate list */} + {dbError && ( +
+ Database unavailable — candidate ranking disabled. ({dbError}) +
+ )} + {loading ? ( +
+ Loading mineral database… +
+ ) : matches.length === 0 ? ( +
+ No species in the database match these readings. Try widening the tolerance, double-check + the optic character, or confirm the RI readings. +
+ ) : ( +
+

+ {matches.length} candidate species +

+ {paged.map(({ mineral, riOverlap, birefringenceOverlap }) => ( +
+
+
+ + {mineral.name} + + {mineral.optical_character && ( + + ({mineral.optical_character}) + + )} +
+
+ {riOverlap && ( + + RI ✓ + + )} + {birefringenceOverlap && ( + + BR ✓ + + )} +
+
+ {(mineral.ri_min !== undefined || mineral.birefringence !== undefined) && ( +
+ {mineral.ri_min !== undefined && + mineral.ri_max !== undefined && + `RI ${mineral.ri_min.toFixed(3)}–${mineral.ri_max.toFixed(3)}`} + {mineral.birefringence !== undefined && + ` · BR ${mineral.birefringence.toFixed(3)}`} +
+ )} +
+ ))} + {totalPages > 1 && ( + + )} +
+ )} +
+ ); +} diff --git a/src/components/optical/OpticalTools.tsx b/src/components/optical/OpticalTools.tsx index a0bed50..4ddb4b0 100644 --- a/src/components/optical/OpticalTools.tsx +++ b/src/components/optical/OpticalTools.tsx @@ -7,6 +7,7 @@ import { DichroscopeResults } from './DichroscopeResults'; import { PolariscopeGuide } from './PolariscopeGuide'; import { RefractometerSimulator } from './RefractometerSimulator'; import { PleochroismReasoner } from './PleochroismReasoner'; +import { OpticSignReasoner } from './OpticSignReasoner'; import { ToolSection } from '../ui/ToolSection'; const ICON_PATHS = { @@ -14,6 +15,7 @@ const ICON_PATHS = { adjust: 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z M15 12a3 3 0 11-6 0 3 3 0 016 0z', beaker: 'M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z', prism: 'M12 2L2 22h20L12 2z M12 2v20', + axes: 'M4 4l16 16M20 4L4 20M12 2v20M2 12h20', }; export function OpticalTools() { @@ -59,6 +61,16 @@ export function OpticalTools() { + + + + {/* Learn More section */}

Learn More

diff --git a/src/components/optical/index.ts b/src/components/optical/index.ts index 88642e8..936709d 100644 --- a/src/components/optical/index.ts +++ b/src/components/optical/index.ts @@ -5,4 +5,6 @@ export { DichroscopeResults } from './DichroscopeResults'; export { PolariscopeGuide } from './PolariscopeGuide'; export { RefractometerSimulator } from './RefractometerSimulator'; +export { PleochroismReasoner } from './PleochroismReasoner'; +export { OpticSignReasoner } from './OpticSignReasoner'; export { OpticalTools } from './OpticalTools'; diff --git a/src/lib/optic-sign/optic-character.test.ts b/src/lib/optic-sign/optic-character.test.ts new file mode 100644 index 0000000..2438c78 --- /dev/null +++ b/src/lib/optic-sign/optic-character.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect } from 'vitest'; +import { + parseOpticalCharacter, + uniaxialSign, + biaxialSign, + biaxial2V, + characterMatches, +} from './optic-character'; + +describe('parseOpticalCharacter', () => { + it('parses isotropic', () => { + expect(parseOpticalCharacter('Isotropic')).toMatchObject({ kind: 'isotropic', sign: 'n/a' }); + }); + it('parses uniaxial positive', () => { + expect(parseOpticalCharacter('Uniaxial +')).toMatchObject({ kind: 'uniaxial', sign: '+' }); + }); + it('parses uniaxial negative', () => { + expect(parseOpticalCharacter('Uniaxial -')).toMatchObject({ kind: 'uniaxial', sign: '-' }); + }); + it('parses biaxial both signs', () => { + expect(parseOpticalCharacter('Biaxial + or -')).toMatchObject({ + kind: 'biaxial', + sign: '+/-', + }); + }); + it('treats AGG as aggregate', () => { + expect(parseOpticalCharacter('AGG').kind).toBe('aggregate'); + }); + it('treats opaque metallic as opaque', () => { + expect(parseOpticalCharacter('Opaque (metallic)').kind).toBe('opaque'); + }); + it('returns unknown for empty string', () => { + expect(parseOpticalCharacter('')).toEqual({ kind: 'unknown', sign: 'n/a' }); + }); + it('handles "Isotropic to near-isotropic (AGG)" as isotropic', () => { + expect(parseOpticalCharacter('Isotropic to near-isotropic (AGG)').kind).toBe('isotropic'); + }); +}); + +describe('uniaxialSign', () => { + it('returns + when epsilon > omega (e.g. quartz: 1.544 / 1.553)', () => { + expect(uniaxialSign(1.544, 1.553)).toBe('+'); + }); + it('returns - when epsilon < omega (e.g. ruby: 1.770 / 1.762)', () => { + expect(uniaxialSign(1.77, 1.762)).toBe('-'); + }); +}); + +describe('biaxialSign', () => { + it('returns + when β closer to α (forsterite-end peridot ~ 1.635 / 1.651 / 1.670)', () => { + // γ-β = 0.019, β-α = 0.016 → β closer to α → biaxial positive + expect(biaxialSign(1.635, 1.651, 1.67)).toBe('+'); + }); + it('returns - when β closer to γ', () => { + // γ-β = 0.005, β-α = 0.030 → β closer to γ → biaxial negative + expect(biaxialSign(1.5, 1.53, 1.535)).toBe('-'); + }); +}); + +describe('biaxial2V', () => { + it('computes a sane positive angle from valid indices', () => { + const v = biaxial2V(1.635, 1.651, 1.67); + expect(v).not.toBeNull(); + expect(v!).toBeGreaterThan(0); + expect(v!).toBeLessThan(90); + }); + it('returns null on degenerate input', () => { + expect(biaxial2V(1.5, 1.5, 1.5)).toBeNull(); + }); +}); + +describe('characterMatches', () => { + it('matches identical character + sign', () => { + expect( + characterMatches('uniaxial', '+', { kind: 'uniaxial', sign: '+' }), + ).toBe(true); + }); + it('rejects character mismatch', () => { + expect( + characterMatches('uniaxial', '+', { kind: 'biaxial', sign: '+' }), + ).toBe(false); + }); + it('rejects sign mismatch', () => { + expect( + characterMatches('uniaxial', '+', { kind: 'uniaxial', sign: '-' }), + ).toBe(false); + }); + it('accepts +/- references for either sign', () => { + expect( + characterMatches('uniaxial', '+', { kind: 'uniaxial', sign: '+/-' }), + ).toBe(true); + expect( + characterMatches('uniaxial', '-', { kind: 'uniaxial', sign: '+/-' }), + ).toBe(true); + }); + it('accepts unknown observed sign as wildcard', () => { + expect( + characterMatches('biaxial', 'n/a', { kind: 'biaxial', sign: '-' }), + ).toBe(true); + }); +}); diff --git a/src/lib/optic-sign/optic-character.ts b/src/lib/optic-sign/optic-character.ts new file mode 100644 index 0000000..b7b5ace --- /dev/null +++ b/src/lib/optic-sign/optic-character.ts @@ -0,0 +1,138 @@ +/** + * Optic-character + 2V reasoner. + * + * Inputs from the user: + * - Optic character observed in the polariscope (isotropic / uniaxial / biaxial / aggregate). + * - For uniaxial: ω and ε refractive indices. + * - For biaxial: α, β, γ refractive indices (β optional — derived if missing). + * + * Outputs: + * - Computed optic sign and birefringence. + * - 2V band for biaxial gems (Mallard formula approximation). + * - Ranked candidate species filtered against the database, parsing the + * `optical_character` text field for each family. + * + * The mineral database stores `optical_character` as freeform text — see + * MineralFamily.optical_character. Values seen in the source YAML include: + * "Isotropic", "Uniaxial +", "Uniaxial -", "Uniaxial - (occasionally +)", + * "Biaxial +", "Biaxial -", "Biaxial + or -", "AGG", "Aggregate", + * "Isotropic to near-isotropic (AGG)", "Opaque (metallic)". + * + * This pure-TS layer parses that text into a discriminated `OpticalCharacter` + * so we don't need to touch the SQLite schema for v1. + */ + +export type OpticCharacterKind = + | 'isotropic' + | 'uniaxial' + | 'biaxial' + | 'aggregate' + | 'opaque' + | 'unknown'; + +export type OpticSign = '+' | '-' | '+/-' | 'n/a'; + +export interface OpticalCharacter { + kind: OpticCharacterKind; + sign: OpticSign; + /** Original raw text from the database. */ + raw?: string; +} + +/** + * Parse the freeform `optical_character` field into a discriminated record. + * + * Heuristic rules (case-insensitive): + * - Contains "isotropic" → kind isotropic, sign n/a. + * - Contains "uniaxial" → kind uniaxial. Sign from "+ or -" / "+" / "-". + * - Contains "biaxial" → kind biaxial. Sign from "+ or -" / "+" / "-". + * - Contains "aggregate" / "AGG" → kind aggregate. + * - Contains "opaque" → kind opaque. + * - Otherwise unknown. + */ +export function parseOpticalCharacter(text: string | undefined | null): OpticalCharacter { + if (!text || !text.trim()) return { kind: 'unknown', sign: 'n/a' }; + const raw = text.trim(); + const lower = raw.toLowerCase(); + + let sign: OpticSign = 'n/a'; + if (/[+\-] *or *[+\-]|both signs|\+\/-/.test(lower)) sign = '+/-'; + else if (/(?:^|[^a-z])(uniaxial|biaxial)\s*\+/.test(lower)) sign = '+'; + else if (/(?:^|[^a-z])(uniaxial|biaxial)\s*-/.test(lower)) sign = '-'; + + if (lower.includes('isotropic')) return { kind: 'isotropic', sign: 'n/a', raw }; + if (lower.includes('uniaxial')) return { kind: 'uniaxial', sign, raw }; + if (lower.includes('biaxial')) return { kind: 'biaxial', sign, raw }; + if (lower.includes('aggregate') || /\bagg\b/.test(lower)) { + return { kind: 'aggregate', sign: 'n/a', raw }; + } + if (lower.includes('opaque')) return { kind: 'opaque', sign: 'n/a', raw }; + return { kind: 'unknown', sign: 'n/a', raw }; +} + +/** + * Determine optic sign from uniaxial RI readings. + * Uniaxial positive: ε > ω (epsilon, the extraordinary ray, is larger). + * Uniaxial negative: ε < ω. + */ +export function uniaxialSign(omega: number, epsilon: number): OpticSign { + const diff = epsilon - omega; + if (Math.abs(diff) < 0.001) return 'n/a'; + return diff > 0 ? '+' : '-'; +} + +/** + * Determine optic sign from biaxial RI readings. + * Biaxial positive: γ - β > β - α (β closer to α). + * Biaxial negative: γ - β < β - α (β closer to γ). + */ +export function biaxialSign(alpha: number, beta: number, gamma: number): OpticSign { + if (alpha > gamma) { + // Caller swapped them; fix. + [alpha, gamma] = [gamma, alpha]; + } + const upper = gamma - beta; + const lower = beta - alpha; + if (Math.abs(upper - lower) < 0.0005) return '+/-'; + return upper > lower ? '+' : '-'; +} + +/** + * Approximate 2V using the Mallard formula: + * cos²(V_z) = (γ² · (β² - α²)) / (β² · (γ² - α²)) + * + * Returns Vz in degrees (the optic axial angle measured from γ for biaxial+ + * or from α for biaxial-). Caller decides which axis the angle refers to. + * + * Returns null if the inputs are degenerate. + */ +export function biaxial2V(alpha: number, beta: number, gamma: number): number | null { + if (alpha > gamma) [alpha, gamma] = [gamma, alpha]; + if (gamma <= alpha || beta <= alpha || beta >= gamma) return null; + const num = gamma * gamma * (beta * beta - alpha * alpha); + const den = beta * beta * (gamma * gamma - alpha * alpha); + if (den === 0) return null; + const cosSq = num / den; + if (cosSq < 0 || cosSq > 1) return null; + const vz = Math.acos(Math.sqrt(cosSq)) * (180 / Math.PI); + return vz; +} + +/** + * Compatibility check between an observed character and a parsed reference. + * Returns true if the species is compatible with the user's observation. + */ +export function characterMatches( + observedKind: OpticCharacterKind, + observedSign: OpticSign | undefined, + reference: OpticalCharacter, +): boolean { + if (reference.kind === 'unknown') return false; + // Aggregate often masks other characters — keep flexible. + if (observedKind === 'aggregate') return reference.kind === 'aggregate'; + if (reference.kind === 'aggregate') return false; + if (reference.kind !== observedKind) return false; + if (!observedSign || observedSign === 'n/a' || reference.sign === 'n/a') return true; + if (reference.sign === '+/-' || observedSign === '+/-') return true; + return reference.sign === observedSign; +} diff --git a/src/lib/spectroscope/match-bands.test.ts b/src/lib/spectroscope/match-bands.test.ts new file mode 100644 index 0000000..0eaad92 --- /dev/null +++ b/src/lib/spectroscope/match-bands.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from 'vitest'; +import { matchBands } from './match-bands'; + +describe('matchBands', () => { + it('ranks ruby top when chrome doublet observed', () => { + const r = matchBands([693, 555, 476], 5); + expect(r[0].reference.name).toMatch(/Ruby/); + expect(r[0].hasSelective).toBe(true); + }); + + it('ranks almandine top for the 504 / 527 / 576 trio', () => { + const r = matchBands([504, 527, 576], 4); + expect(r[0].reference.name).toMatch(/Almandine/); + }); + + it('returns empty for no observations', () => { + expect(matchBands([], 5)).toEqual([]); + }); + + it('respects tolerance — 700 nm does not match the 692 ruby line at ±5', () => { + const r = matchBands([700], 5); + expect(r.find((m) => m.reference.name.match(/Ruby/))).toBeUndefined(); + }); + + it('matches blue sapphire on the 450 nm Fe³⁺ line', () => { + const r = matchBands([450], 4); + expect(r[0].reference.name).toMatch(/sapphire/i); + }); + + it('matches zircon on the 653 nm uranium line', () => { + const r = matchBands([653], 4); + expect(r[0].reference.name).toMatch(/Zircon/); + }); +}); diff --git a/src/lib/spectroscope/match-bands.ts b/src/lib/spectroscope/match-bands.ts new file mode 100644 index 0000000..56d6eea --- /dev/null +++ b/src/lib/spectroscope/match-bands.ts @@ -0,0 +1,104 @@ +/** + * Pure matcher for the Spectroscope Band Matcher widget. + * + * Inputs: a set of observed band wavelengths (the user ticks them off in the + * UI) plus an optional tolerance in nm. For each species in the reference + * table, we count how many bands match, weighted by intensity and + * "selective" flag, and divide by the species' total band weight to get a + * coverage score. The result is a ranked candidate list. + */ + +import { + SPECTROSCOPE_REFERENCE, + type AbsorptionBand, + type SpectroscopeReference, +} from './reference-bands'; + +const INTENSITY_WEIGHT = { weak: 1, moderate: 2, strong: 3 } as const; + +export interface BandMatch { + reference: SpectroscopeReference; + /** 0..1 — fraction of the species' weighted bands the user observed. */ + coverage: number; + /** Bands that matched (with the observed wavelength that triggered them). */ + matched: { observed: number; band: AbsorptionBand }[]; + /** True if at least one matched band was flagged `selective`. */ + hasSelective: boolean; + /** Reasoning text for the UI. */ + reason: string; +} + +function bandWavelengths(b: AbsorptionBand): number[] { + return [b.wavelength, ...(b.also ?? [])]; +} + +function nearest(observed: number, target: number): number { + return Math.abs(observed - target); +} + +/** + * Match observed bands against the reference table. + * @param observed list of wavelengths the user ticked + * @param tolerance ± nm window for considering a band "matched" (default 5 nm) + */ +export function matchBands(observed: number[], tolerance = 5): BandMatch[] { + if (observed.length === 0) return []; + + const out: BandMatch[] = []; + for (const reference of SPECTROSCOPE_REFERENCE) { + let totalWeight = 0; + let matchedWeight = 0; + let hasSelective = false; + const matched: BandMatch['matched'] = []; + + for (const band of reference.bands) { + const w = INTENSITY_WEIGHT[band.intensity] * (band.selective ? 2 : 1); + totalWeight += w; + + const targets = bandWavelengths(band); + let bestObs: number | null = null; + let bestDist = Infinity; + for (const obs of observed) { + for (const target of targets) { + const d = nearest(obs, target); + if (d <= tolerance && d < bestDist) { + bestDist = d; + bestObs = obs; + } + } + } + if (bestObs !== null) { + matchedWeight += w; + matched.push({ observed: bestObs, band }); + if (band.selective) hasSelective = true; + } + } + + if (matchedWeight === 0) continue; + const coverage = matchedWeight / totalWeight; + + const reasonParts: string[] = []; + reasonParts.push(`${matched.length} of ${reference.bands.length} reference bands matched.`); + if (hasSelective) { + reasonParts.push('Includes a selective (diagnostic) band.'); + } + if (reference.observationNotes) { + reasonParts.push(reference.observationNotes); + } + + out.push({ + reference, + coverage, + matched, + hasSelective, + reason: reasonParts.join(' '), + }); + } + + out.sort((a, b) => { + // Selective matches dominate. + if (a.hasSelective !== b.hasSelective) return a.hasSelective ? -1 : 1; + return b.coverage - a.coverage; + }); + return out; +} diff --git a/src/lib/spectroscope/reference-bands.ts b/src/lib/spectroscope/reference-bands.ts new file mode 100644 index 0000000..02b4676 --- /dev/null +++ b/src/lib/spectroscope/reference-bands.ts @@ -0,0 +1,217 @@ +/** + * Reference absorption-band data for the most-tested gem species. + * + * Wavelengths are in nanometres, sourced from standard gemmological reference + * texts (GIA Coloured Stones manual, Hodgkinson's Visual Optics, Webster's + * Gems). Intensity is qualitative: `weak` (faint band, easy to miss), + * `moderate` (clearly visible), `strong` (sharp dark line). + * + * `selective` flags bands that uniquely identify the species (e.g. the + * 504 nm line in almandine garnet, the chrome doublet at 692/694 nm in ruby). + * + * Stored separately from the SQLite mineral database — adding a column there + * is a multi-day data-authoring effort. This TypeScript table is the + * pragmatic v1 implementation; a future migration can populate + * `minerals.absorption_bands_json` from this same data. + */ + +export interface AbsorptionBand { + /** Wavelength in nanometres. Use a single number for sharp lines; for broad bands list the centre. */ + wavelength: number; + /** Optional secondary wavelength for doublets / triplets. */ + also?: number[]; + intensity: 'weak' | 'moderate' | 'strong'; + /** Cause of the band (Cr³⁺, Fe²⁺, didymium, etc.). */ + cause?: string; + /** True when this band alone is highly diagnostic. */ + selective?: boolean; + /** Short note explaining the band. */ + note?: string; +} + +export interface SpectroscopeReference { + /** Mineral family id, matches `mineral_families.id`. */ + familyId: string; + /** Display name. */ + name: string; + bands: AbsorptionBand[]; + /** Light-source caveats or viewing tips. */ + observationNotes?: string; +} + +export const SPECTROSCOPE_REFERENCE: SpectroscopeReference[] = [ + // Chromophore: Cr³⁺ (chromium) — ruby, red spinel, alexandrite, emerald. + { + familyId: 'corundum', + name: 'Ruby (Cr-corundum)', + bands: [ + { wavelength: 692, also: [694], intensity: 'strong', cause: 'Cr³⁺ doublet (R-lines)', selective: true, note: 'Sharp red doublet at 692/694 nm — the textbook ruby fingerprint.' }, + { wavelength: 660, intensity: 'weak', cause: 'Cr³⁺' }, + { wavelength: 555, intensity: 'moderate', cause: 'Cr³⁺ broad absorption', note: 'Wide green absorption band.' }, + { wavelength: 476, intensity: 'moderate', cause: 'Cr³⁺' }, + { wavelength: 468, intensity: 'moderate', cause: 'Cr³⁺' }, + ], + observationNotes: 'Use a strong incandescent or fibre-optic source; the doublet is much easier in the red transmission band.', + }, + { + familyId: 'spinel', + name: 'Red spinel (Cr-spinel)', + bands: [ + { wavelength: 685, intensity: 'strong', cause: 'Cr³⁺ "organ-pipe"', selective: true, note: 'Series of fine lines 670–690 nm called the organ-pipe spectrum.' }, + { wavelength: 656, intensity: 'moderate', cause: 'Cr³⁺' }, + { wavelength: 540, intensity: 'moderate', cause: 'Cr³⁺' }, + ], + }, + { + familyId: 'chrysoberyl', + name: 'Alexandrite (Cr-chrysoberyl)', + bands: [ + { wavelength: 680, also: [678], intensity: 'strong', cause: 'Cr³⁺ doublet', selective: true }, + { wavelength: 645, intensity: 'weak', cause: 'Cr³⁺' }, + { wavelength: 580, intensity: 'moderate', cause: 'Cr³⁺' }, + { wavelength: 468, intensity: 'moderate', cause: 'Cr³⁺' }, + ], + observationNotes: 'Colour change is best seen by switching incandescent ↔ daylight, but the spectrum is identical in both.', + }, + { + familyId: 'beryl', + name: 'Emerald (Cr-beryl)', + bands: [ + { wavelength: 683, also: [680], intensity: 'strong', cause: 'Cr³⁺ doublet', selective: true }, + { wavelength: 637, intensity: 'weak', cause: 'Cr³⁺' }, + { wavelength: 606, intensity: 'weak', cause: 'Cr³⁺' }, + { wavelength: 477, intensity: 'moderate', cause: 'Cr³⁺ broad absorption' }, + ], + observationNotes: 'Polariscope ω-ray gives strongest red doublet.', + }, + // Chromophore: Fe³⁺ — blue sapphire, aquamarine. + { + familyId: 'corundum-blue', + name: 'Blue sapphire (Fe-corundum)', + bands: [ + { wavelength: 450, intensity: 'strong', cause: 'Fe³⁺', selective: true, note: 'Diagnostic 450 nm line in iron-rich blue sapphires.' }, + { wavelength: 460, intensity: 'moderate', cause: 'Fe³⁺' }, + { wavelength: 470, intensity: 'weak', cause: 'Fe³⁺' }, + ], + observationNotes: 'Synthetic flame-fusion sapphire often lacks the 450 nm line — useful diagnostic.', + }, + // Almandine garnet — Fe²⁺. + { + familyId: 'almandine', + name: 'Almandine garnet (Fe²⁺)', + bands: [ + { wavelength: 504, intensity: 'strong', cause: 'Fe²⁺', selective: true, note: 'The 504 nm line is the strongest Fe²⁺ band in almandine.' }, + { wavelength: 527, intensity: 'strong', cause: 'Fe²⁺' }, + { wavelength: 576, intensity: 'moderate', cause: 'Fe²⁺' }, + { wavelength: 423, intensity: 'weak', cause: 'Fe²⁺' }, + ], + observationNotes: 'Strongest broad-band Fe²⁺ spectrum among gems; visible even in dark stones.', + }, + // Didymium-bearing rare-earth gems. + { + familyId: 'apatite', + name: 'Yellow apatite (didymium)', + bands: [ + { wavelength: 580, intensity: 'strong', cause: 'didymium (Pr+Nd)', selective: true }, + { wavelength: 525, intensity: 'moderate', cause: 'didymium' }, + { wavelength: 512, intensity: 'moderate', cause: 'didymium' }, + { wavelength: 491, intensity: 'weak', cause: 'didymium' }, + ], + observationNotes: 'Sharp didymium lines also seen in some sphene and rare yellow zircon.', + }, + { + familyId: 'sphene', + name: 'Sphene / titanite', + bands: [ + { wavelength: 586, intensity: 'moderate', cause: 'didymium' }, + { wavelength: 536, intensity: 'weak', cause: 'didymium' }, + ], + }, + // Zircon — characteristic uranium-bearing spectrum. + { + familyId: 'zircon', + name: 'Zircon (high type)', + bands: [ + { wavelength: 653, intensity: 'strong', cause: 'U⁴⁺', selective: true, note: 'Classic uranium-line at 653.5 nm — strongest in heat-treated blue zircon.' }, + { wavelength: 659, intensity: 'moderate', cause: 'U⁴⁺' }, + { wavelength: 691, intensity: 'weak', cause: 'U⁴⁺' }, + { wavelength: 588, intensity: 'weak', cause: 'U⁴⁺' }, + { wavelength: 562, intensity: 'weak', cause: 'U⁴⁺' }, + { wavelength: 537, intensity: 'weak', cause: 'U⁴⁺' }, + { wavelength: 484, intensity: 'weak', cause: 'U⁴⁺' }, + ], + observationNotes: 'Low-type (metamict) zircon shows weakened or no spectrum.', + }, + // Peridot — Fe²⁺ triplet. + { + familyId: 'peridot', + name: 'Peridot (forsterite-fayalite)', + bands: [ + { wavelength: 493, intensity: 'strong', cause: 'Fe²⁺', selective: true, note: '"Three-lock-and-key" pattern: 493 / 473 / 453 nm.' }, + { wavelength: 473, intensity: 'strong', cause: 'Fe²⁺' }, + { wavelength: 453, intensity: 'strong', cause: 'Fe²⁺' }, + ], + }, + // Diamond — N3 / N2 systems. + { + familyId: 'diamond', + name: 'Cape-series diamond (N3)', + bands: [ + { wavelength: 415, intensity: 'strong', cause: 'N3 centre', selective: true, note: 'Cape line at 415.5 nm — diagnostic of natural type-Ia diamond.' }, + { wavelength: 478, intensity: 'weak', cause: 'N3 / H3' }, + { wavelength: 504, intensity: 'weak', cause: 'H3 (irradiated)' }, + ], + observationNotes: 'Best seen at low temperature with a strong UV-blocked white source.', + }, + // Synthetic/treated diamond — different N-V centres. + // Tourmaline — Cr/Fe/Mn pleochroic. + { + familyId: 'tourmaline', + name: 'Cr-tourmaline (chrome dravite)', + bands: [ + { wavelength: 685, intensity: 'moderate', cause: 'Cr³⁺ doublet', note: 'Less sharp than ruby.' }, + { wavelength: 460, intensity: 'moderate', cause: 'Cr³⁺' }, + ], + }, + // Demantoid garnet — Cr + Fe. + { + familyId: 'andradite', + name: 'Demantoid garnet', + bands: [ + { wavelength: 444, intensity: 'strong', cause: 'Fe³⁺', selective: true, note: 'Diagnostic Fe³⁺ band at 440 nm distinguishes demantoid from chrysoberyl/peridot.' }, + { wavelength: 622, intensity: 'weak', cause: 'Cr³⁺' }, + { wavelength: 685, intensity: 'weak', cause: 'Cr³⁺' }, + ], + }, + // Chrysoberyl yellow — Fe³⁺. + { + familyId: 'chrysoberyl-yellow', + name: 'Yellow chrysoberyl (Fe)', + bands: [ + { wavelength: 444, intensity: 'strong', cause: 'Fe³⁺', selective: true }, + ], + }, + // Aquamarine — light Fe²⁺. + { + familyId: 'beryl-aqua', + name: 'Aquamarine (Fe-beryl)', + bands: [ + { wavelength: 537, intensity: 'weak', cause: 'Fe²⁺' }, + { wavelength: 456, intensity: 'weak', cause: 'Fe³⁺' }, + { wavelength: 427, intensity: 'weak', cause: 'Fe³⁺' }, + ], + observationNotes: 'Spectrum is faint; lighter stones may show no bands.', + }, +]; + +/** All distinct wavelengths in the reference set, sorted ascending. */ +export function getAllReferenceBands(): number[] { + const seen = new Set(); + for (const ref of SPECTROSCOPE_REFERENCE) { + for (const b of ref.bands) { + seen.add(b.wavelength); + if (b.also) for (const w of b.also) seen.add(w); + } + } + return Array.from(seen).sort((a, b) => a - b); +} diff --git a/src/lib/treatments/wizard.test.ts b/src/lib/treatments/wizard.test.ts new file mode 100644 index 0000000..472ebdb --- /dev/null +++ b/src/lib/treatments/wizard.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect } from 'vitest'; +import { runWizard, cluesForKind, CLUES } from './wizard'; + +describe('runWizard', () => { + it('flags heat treatment when silk halos and chalky SWUV observed in corundum', () => { + const verdicts = runWizard({ + gemKind: 'corundum', + selectedClueIds: ['silk-halo', 'chalky-swuv'], + }); + const heat = verdicts.find((v) => v.treatment === 'heat'); + expect(heat).toBeDefined(); + expect(heat!.confidence).toMatch(/likely|very likely/); + expect(heat!.supportingClueIds).toContain('silk-halo'); + expect(heat!.supportingClueIds).toContain('chalky-swuv'); + }); + + it('rules out heat when sharp intact silk is observed', () => { + const verdicts = runWizard({ + gemKind: 'corundum', + selectedClueIds: ['rutile-silk-intact'], + }); + const heat = verdicts.find((v) => v.treatment === 'heat'); + expect(heat).toBeDefined(); + expect(heat!.score).toBeLessThan(0); + expect(heat!.contradictingClueIds).toContain('rutile-silk-intact'); + }); + + it('flags glass / oil filling when flash effect and surface bubbles seen', () => { + const verdicts = runWizard({ + gemKind: 'emerald', + selectedClueIds: ['flash-effect', 'surface-bubbles', 'sweat-test'], + }); + const top = verdicts[0]; + expect(['oil-resin', 'glass-filling']).toContain(top.treatment); + expect(top.confidence).toBe('very likely'); + }); + + it('flags HPHT for diamond decolourisation', () => { + const verdicts = runWizard({ + gemKind: 'diamond', + selectedClueIds: ['diamond-brown-to-colourless', 'graining-strong'], + }); + expect(verdicts[0].treatment).toBe('hpht'); + expect(verdicts[0].confidence).toBe('very likely'); + }); + + it('skips clues that do not apply to the chosen gem kind', () => { + const verdicts = runWizard({ + gemKind: 'topaz', + selectedClueIds: ['silk-halo', 'colour-fades-light'], + }); + expect(verdicts.find((v) => v.treatment === 'heat')).toBeUndefined(); + const irr = verdicts.find((v) => v.treatment === 'irradiation'); + expect(irr).toBeDefined(); + expect(irr!.supportingClueIds).toContain('colour-fades-light'); + }); + + it('returns empty list when no clues selected', () => { + expect(runWizard({ gemKind: 'corundum', selectedClueIds: [] })).toEqual([]); + }); + + it('flags bleaching+resin for acid-etched jadeite', () => { + const verdicts = runWizard({ + gemKind: 'jadeite', + selectedClueIds: ['jadeite-acid-etch', 'porous-or-low-density'], + }); + const bleach = verdicts.find((v) => v.treatment === 'bleaching'); + expect(bleach).toBeDefined(); + expect(bleach!.confidence).toMatch(/likely|very likely/); + }); +}); + +describe('cluesForKind', () => { + it('filters out gem-specific clues that do not apply', () => { + const topazClues = cluesForKind('topaz'); + expect(topazClues.find((c) => c.id === 'silk-halo')).toBeUndefined(); + expect(topazClues.find((c) => c.id === 'colour-fades-light')).toBeDefined(); + }); + + it('returns generic clues regardless of kind', () => { + const list = cluesForKind('opal'); + expect(list.find((c) => c.id === 'iridescent-surface')).toBeDefined(); + expect(list.find((c) => c.id === 'colour-removed-acetone')).toBeDefined(); + }); + + it('every clue with no applicableTo restriction appears for any gem', () => { + const generic = CLUES.filter((c) => !c.applicableTo); + const pearlList = cluesForKind('pearl'); + for (const g of generic) { + expect(pearlList.find((c) => c.id === g.id)).toBeDefined(); + } + }); +}); diff --git a/src/lib/treatments/wizard.ts b/src/lib/treatments/wizard.ts new file mode 100644 index 0000000..1f6378f --- /dev/null +++ b/src/lib/treatments/wizard.ts @@ -0,0 +1,239 @@ +/** + * Treatment-detection wizard reasoning. + * + * Maps observed clues to candidate treatments per species. Evidence-weighted: + * each clue contributes a positive (supports), neutral (consistent), or + * negative (rules out) signal toward each treatment hypothesis. + * + * Source: GIA gem identification course material, Hughes' Ruby & Sapphire, + * standard CIBJO Blue Book treatment definitions. + */ + +export type GemKind = + | 'corundum' + | 'emerald' + | 'beryl-other' + | 'tourmaline' + | 'topaz' + | 'quartz' + | 'amber' + | 'turquoise' + | 'jadeite' + | 'opal' + | 'pearl' + | 'diamond'; + +export type TreatmentId = + | 'heat' + | 'oil-resin' + | 'lattice-diffusion' + | 'surface-diffusion' + | 'irradiation' + | 'coating' + | 'glass-filling' + | 'dye' + | 'bleaching' + | 'hpht' + | 'flux-healing'; + +export interface ClueDef { + id: string; + label: string; + description?: string; + /** Per-treatment effect: positive supports, negative rules out, 0 means no impact. */ + effects: Partial>; + /** Restrict the clue to certain gem kinds (display filter). */ + applicableTo?: GemKind[]; +} + +export const TREATMENT_LABELS: Record = { + heat: 'Heat treatment', + 'oil-resin': 'Oiling / resin filling', + 'lattice-diffusion': 'Lattice (Be / Ti) diffusion', + 'surface-diffusion': 'Surface diffusion (colour skin)', + irradiation: 'Irradiation', + coating: 'Surface coating / lacquer', + 'glass-filling': 'Lead-glass filling', + dye: 'Dyeing', + bleaching: 'Bleaching', + hpht: 'HPHT colour modification', + 'flux-healing': 'Flux healing of fractures', +}; + +export const CLUES: ClueDef[] = [ + { + id: 'silk-halo', + label: 'Discoid halos / disrupted silk under magnification', + description: 'Snowball-like discs around former rutile silk; classic of heated corundum.', + effects: { heat: 3, 'lattice-diffusion': 1 }, + applicableTo: ['corundum'], + }, + { + id: 'rutile-silk-intact', + label: 'Sharp, intact fine rutile silk', + description: 'Long undamaged silk needles indicate the stone has not been strongly heated.', + effects: { heat: -3, 'lattice-diffusion': -2 }, + applicableTo: ['corundum'], + }, + { + id: 'colour-concentration-rim', + label: 'Colour concentration at facet edges / culet (rim of colour)', + description: 'Colour follows facet edges from a thin diffused layer.', + effects: { 'surface-diffusion': 4, 'lattice-diffusion': 2, dye: 1 }, + applicableTo: ['corundum'], + }, + { + id: 'chalky-swuv', + label: 'Chalky / cloudy short-wave UV reaction', + description: 'Chalky blue-white SW glow develops in heat-treated sapphires (especially geuda-derived).', + effects: { heat: 3, 'lattice-diffusion': 2 }, + applicableTo: ['corundum'], + }, + { + id: 'flash-effect', + label: 'Flash effect (blue/orange flash on tilt)', + description: 'Blue / orange flash of light along fractures viewed at certain angles.', + effects: { 'oil-resin': 4, 'glass-filling': 4 }, + applicableTo: ['emerald', 'corundum'], + }, + { + id: 'surface-bubbles', + label: 'Round gas bubbles in surface-reaching fissures', + description: 'Trapped during glass / resin filling.', + effects: { 'glass-filling': 4, 'oil-resin': 2 }, + applicableTo: ['corundum', 'emerald', 'turquoise'], + }, + { + id: 'sweat-test', + label: 'Residue / sweating after gentle warming', + description: 'Oil or resin sweats out of fissures when stone is warmed.', + effects: { 'oil-resin': 4 }, + applicableTo: ['emerald', 'beryl-other'], + }, + { + id: 'colour-zoning-strong', + label: 'Strong straight or angular colour zoning', + description: 'Natural growth zoning; rules out many homogenised treatments.', + effects: { heat: -1, 'lattice-diffusion': -1 }, + }, + { + id: 'colour-zoning-curved', + label: 'Curved (Plato) colour bands', + description: 'Curved striae indicate flame-fusion synthesis, not treatment.', + effects: {}, + }, + { + id: 'fingerprint-healed', + label: 'Healed fingerprint inclusions / partial healing', + description: 'Re-healed fractures from flux-assisted heating.', + effects: { 'flux-healing': 3, heat: 2 }, + applicableTo: ['corundum'], + }, + { + id: 'flux-residue', + label: 'Yellow / orange flux residue in fissures', + description: 'Borax-like residue trapped during flux heating.', + effects: { 'flux-healing': 4, heat: 1 }, + applicableTo: ['corundum'], + }, + { + id: 'colour-fades-light', + label: 'Colour fades with prolonged light or heat', + effects: { irradiation: 3, dye: 2, coating: 2 }, + applicableTo: ['topaz', 'tourmaline', 'beryl-other', 'quartz', 'turquoise'], + }, + { + id: 'colour-removed-acetone', + label: 'Colour rubs / dissolves with acetone or alcohol swab', + effects: { dye: 4, coating: 3 }, + }, + { + id: 'iridescent-surface', + label: 'Iridescent or oily surface sheen under reflection', + effects: { coating: 4 }, + }, + { + id: 'diamond-brown-to-colourless', + label: 'Diamond changed from brown to colourless / yellow → colourless', + effects: { hpht: 4 }, + applicableTo: ['diamond'], + }, + { + id: 'graining-strong', + label: 'Strong internal graining / strain (HPHT diamond)', + effects: { hpht: 2 }, + applicableTo: ['diamond'], + }, + { + id: 'porous-or-low-density', + label: 'Porous / chalky surface or unusually low SG', + effects: { dye: 2, bleaching: 3, 'oil-resin': 2 }, + applicableTo: ['turquoise', 'jadeite'], + }, + { + id: 'jadeite-acid-etch', + label: 'Jadeite — acid-etched / honeycomb surface texture', + effects: { bleaching: 4, 'oil-resin': 3 }, + applicableTo: ['jadeite'], + }, +]; + +export interface WizardCriteria { + gemKind: GemKind; + selectedClueIds: string[]; +} + +export interface TreatmentVerdict { + treatment: TreatmentId; + label: string; + /** Aggregate evidence score; positive favours, negative argues against. */ + score: number; + /** Verbal confidence band based on score. */ + confidence: 'unlikely' | 'possible' | 'likely' | 'very likely'; + /** Clues contributing positively to this verdict. */ + supportingClueIds: string[]; + /** Clues contributing negatively. */ + contradictingClueIds: string[]; +} + +const ALL_TREATMENTS: TreatmentId[] = Object.keys(TREATMENT_LABELS) as TreatmentId[]; + +export function runWizard(criteria: WizardCriteria): TreatmentVerdict[] { + const verdicts: Record = {} as Record; + for (const t of ALL_TREATMENTS) { + verdicts[t] = { + treatment: t, + label: TREATMENT_LABELS[t], + score: 0, + confidence: 'unlikely', + supportingClueIds: [], + contradictingClueIds: [], + }; + } + + for (const clueId of criteria.selectedClueIds) { + const clue = CLUES.find((c) => c.id === clueId); + if (!clue) continue; + if (clue.applicableTo && !clue.applicableTo.includes(criteria.gemKind)) continue; + for (const [tid, weight] of Object.entries(clue.effects) as [TreatmentId, number][]) { + verdicts[tid].score += weight; + if (weight > 0) verdicts[tid].supportingClueIds.push(clueId); + else if (weight < 0) verdicts[tid].contradictingClueIds.push(clueId); + } + } + + for (const v of Object.values(verdicts)) { + if (v.score >= 6) v.confidence = 'very likely'; + else if (v.score >= 3) v.confidence = 'likely'; + else if (v.score >= 1) v.confidence = 'possible'; + else v.confidence = 'unlikely'; + } + + return Object.values(verdicts) + .filter((v) => v.score > 0 || v.contradictingClueIds.length > 0) + .sort((a, b) => b.score - a.score); +} + +export function cluesForKind(kind: GemKind): ClueDef[] { + return CLUES.filter((c) => !c.applicableTo || c.applicableTo.includes(kind)); +} diff --git a/src/lib/uv-fluorescence/parse-fluorescence.test.ts b/src/lib/uv-fluorescence/parse-fluorescence.test.ts new file mode 100644 index 0000000..162e74e --- /dev/null +++ b/src/lib/uv-fluorescence/parse-fluorescence.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from 'vitest'; +import { parseFluorescence, scoreUvMatch } from './parse-fluorescence'; + +describe('parseFluorescence', () => { + it('returns inert for both bands when text is just "inert"', () => { + const fl = parseFluorescence('Inert'); + expect(fl?.lwuv?.intensity).toBe('inert'); + expect(fl?.swuv?.intensity).toBe('inert'); + }); + + it('extracts LW colour and intensity', () => { + const fl = parseFluorescence('LW: red strong; SW: inert'); + expect(fl?.lwuv?.color).toBe('red'); + expect(fl?.lwuv?.intensity).toBe('strong'); + expect(fl?.swuv?.intensity).toBe('inert'); + }); + + it('handles "long-wave" / "short-wave" wording', () => { + const fl = parseFluorescence('Long-wave UV strong red, short-wave weak'); + expect(fl?.lwuv?.intensity).toBe('strong'); + expect(fl?.swuv?.intensity).toBe('weak'); + }); + + it('returns null for empty input', () => { + expect(parseFluorescence('')).toBeNull(); + expect(parseFluorescence(null)).toBeNull(); + }); + + it('detects phosphorescence', () => { + const fl = parseFluorescence('LW: blue moderate; phosphoresces yellow'); + expect(fl?.phosphorescence).toMatch(/phosphoresc/); + }); +}); + +describe('scoreUvMatch', () => { + it('returns 0 for null fluorescence', () => { + expect( + scoreUvMatch( + { lwuvIntensity: 'strong', lwuvColor: 'red', swuvIntensity: 'inert', swuvColor: '' }, + null, + ), + ).toBe(0); + }); + + it('scores high for matching colour and intensity', () => { + const fl = parseFluorescence('LW: red strong; SW: inert')!; + const score = scoreUvMatch( + { lwuvIntensity: 'strong', lwuvColor: 'red', swuvIntensity: 'inert', swuvColor: '' }, + fl, + ); + expect(score).toBeGreaterThan(0.8); + }); + + it('scores low for mismatched colour', () => { + const fl = parseFluorescence('LW: red strong; SW: inert')!; + const score = scoreUvMatch( + { lwuvIntensity: 'strong', lwuvColor: 'green', swuvIntensity: 'inert', swuvColor: '' }, + fl, + ); + expect(score).toBeLessThanOrEqual(0.7); + }); +}); diff --git a/src/lib/uv-fluorescence/parse-fluorescence.ts b/src/lib/uv-fluorescence/parse-fluorescence.ts new file mode 100644 index 0000000..ac58379 --- /dev/null +++ b/src/lib/uv-fluorescence/parse-fluorescence.ts @@ -0,0 +1,140 @@ +/** + * UV-fluorescence parser. + * + * The mineral database stores fluorescence as a freeform text field + * ("LWUV: red strong; SWUV: weak red; phosphoresces"). To support a UV + * lookup widget without a SQL schema migration, we parse that string into + * structured `{lwuv, swuv, phosphorescence}` triples, then match against + * user-reported observations. + * + * The parser is forgiving — it handles many trade abbreviations (LW/SW, + * 365 nm/254 nm, "long-wave", "short-wave") and conservatively returns + * `undefined` per field when uncertain. + */ + +export type UvIntensity = 'inert' | 'weak' | 'moderate' | 'strong' | 'very_strong' | 'unknown'; + +export interface UvFluorescence { + lwuv?: { color?: string; intensity: UvIntensity }; + swuv?: { color?: string; intensity: UvIntensity }; + phosphorescence?: string; +} + +const INTENSITY_RE = /\b(inert|none|weak|moderate|medium|strong|very[\s-]?strong|intense)\b/i; + +const COLOR_RE = + /\b(red(?:dish)?|orange|yellow(?:ish)?|green(?:ish)?|blue(?:ish)?|violet|purple|pink|white|chalky|cream|salmon|brown(?:ish)?)\b/i; + +function intensityFrom(s: string): UvIntensity { + const m = s.match(INTENSITY_RE); + if (!m) return 'unknown'; + const w = m[1].toLowerCase().replace(/[\s-]/g, ''); + if (w === 'inert' || w === 'none') return 'inert'; + if (w === 'weak') return 'weak'; + if (w === 'moderate' || w === 'medium') return 'moderate'; + if (w === 'verystrong' || w === 'intense') return 'very_strong'; + if (w === 'strong') return 'strong'; + return 'unknown'; +} + +function colorFrom(s: string): string | undefined { + const m = s.match(COLOR_RE); + return m ? m[0].toLowerCase() : undefined; +} + +/** + * Parse a freeform fluorescence text into structured fields. + * Examples handled: + * "LW: red strong; SW: inert" + * "Long-wave UV strong red, short-wave weak" + * "365 nm: red moderate; 254 nm: chalky white weak" + * "Inert" + */ +export function parseFluorescence(text: string | null | undefined): UvFluorescence | null { + if (!text || !text.trim()) return null; + const t = text.toLowerCase(); + + if (/\b(inert|none)\b/i.test(t) && !/\b(lw|sw|long|short|365|254)\b/i.test(t)) { + return { lwuv: { intensity: 'inert' }, swuv: { intensity: 'inert' } }; + } + + // Split on common separators that signal LW vs SW segments. + const segments = t + .split(/[;.\n]|(?:,| and )\s*(?=(?:lw|sw|long|short|365|254))/i) + .map((s) => s.trim()) + .filter(Boolean); + + let lwuv: UvFluorescence['lwuv']; + let swuv: UvFluorescence['swuv']; + let phosphorescence: string | undefined; + + for (const seg of segments) { + if (/phosphoresc/.test(seg)) phosphorescence = seg.trim(); + + const isLW = /\b(lw|long[-\s]?wave|365)/.test(seg); + const isSW = /\b(sw|short[-\s]?wave|254)/.test(seg); + const intensity = intensityFrom(seg); + const color = colorFrom(seg); + + if (isLW && intensity !== 'unknown') lwuv = { intensity, color }; + if (isSW && intensity !== 'unknown') swuv = { intensity, color }; + // Untagged segment with both intensity+color: assume LW (the trade default). + if (!isLW && !isSW && intensity !== 'unknown' && !lwuv) { + lwuv = { intensity, color }; + } + } + + if (!lwuv && !swuv && !phosphorescence) return null; + return { lwuv, swuv, phosphorescence }; +} + +export interface UvObservation { + lwuvIntensity: UvIntensity; + lwuvColor: string; + swuvIntensity: UvIntensity; + swuvColor: string; +} + +/** + * Score how well a mineral's parsed fluorescence matches an observation. + * Returns 0..1; values below 0.25 are filtered out by callers. + */ +export function scoreUvMatch(obs: UvObservation, fl: UvFluorescence | null): number { + if (!fl) return 0; + let score = 0; + let weight = 0; + + const cmpField = ( + obsIntensity: UvIntensity, + obsColor: string, + field: { intensity: UvIntensity; color?: string } | undefined, + ) => { + if (obsIntensity === 'unknown') return; + weight += 1; + if (!field) return; + // Intensity similarity (rank distance). + const rank: Record = { + inert: 0, + weak: 1, + moderate: 2, + strong: 3, + very_strong: 4, + unknown: 2, + }; + const intensityDiff = Math.abs(rank[obsIntensity] - rank[field.intensity]); + const intensityScore = Math.max(0, 1 - intensityDiff * 0.25); + let colorScore = 0.5; + if (obsColor && field.color) { + colorScore = obsColor.toLowerCase() === field.color.toLowerCase() ? 1 : 0; + } else if (!obsColor) { + colorScore = 1; + } + score += 0.4 * intensityScore + 0.6 * colorScore; + }; + + cmpField(obs.lwuvIntensity, obs.lwuvColor, fl.lwuv); + cmpField(obs.swuvIntensity, obs.swuvColor, fl.swuv); + + if (weight === 0) return 0; + return score / weight; +}