Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src/components/advanced/AdvancedTools.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
};

Expand All @@ -25,6 +27,16 @@ export function AdvancedTools() {
<TreatmentDetection />
</ToolSection>

<ToolSection
id="treatment-wizard"
title="Treatment Wizard"
description="Pick the gem kind, tick observed clues, get ranked treatment likelihoods"
iconPath={ICON_PATHS.wizard}
accent="cyan"
>
<TreatmentWizard />
</ToolSection>

<ToolSection
id="proportion"
title="Proportion Analyzer"
Expand Down
131 changes: 131 additions & 0 deletions src/components/advanced/ProportionAnalyzer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,44 @@
*/

import { useState } from 'react';
import {
gradeRoundBrilliant,
type GirdleThickness,
type CuletSize,
type CutGradeBand,
} from '../../lib/calculator/cut-grades';

const GIRDLE_OPTIONS: { value: GirdleThickness | ''; label: string }[] = [
{ value: '', label: '— not measured —' },
{ value: 'extremely-thin', label: 'Extremely thin (chipping risk)' },
{ value: 'very-thin', label: 'Very thin' },
{ value: 'thin', label: 'Thin' },
{ value: 'medium', label: 'Medium' },
{ value: 'slightly-thick', label: 'Slightly thick' },
{ value: 'thick', label: 'Thick' },
{ value: 'very-thick', label: 'Very thick' },
{ value: 'extremely-thick', label: 'Extremely thick (carat-weight penalty)' },
];

const CULET_OPTIONS: { value: CuletSize | ''; label: string }[] = [
{ value: '', label: '— not measured —' },
{ value: 'none', label: 'None / pointed' },
{ value: 'very-small', label: 'Very small' },
{ value: 'small', label: 'Small' },
{ value: 'medium', label: 'Medium' },
{ value: 'slightly-large', label: 'Slightly large' },
{ value: 'large', label: 'Large' },
{ value: 'very-large', label: 'Very large' },
{ value: 'extremely-large', label: 'Extremely large' },
];

const GIA_BADGE: Record<CutGradeBand, string> = {
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;
Expand Down Expand Up @@ -130,6 +168,8 @@ export function ProportionAnalyzer() {
const [depth, setDepth] = useState('');
const [crownAngle, setCrownAngle] = useState('');
const [pavilionAngle, setPavilionAngle] = useState('');
const [girdle, setGirdle] = useState<GirdleThickness | ''>('');
const [culet, setCulet] = useState<CuletSize | ''>('');

const selectedStandard = PROPORTION_STANDARDS.find(
s => `${s.cut} (${s.gem})` === selectedCut
Expand Down Expand Up @@ -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 (
<div className="space-y-6">
<div>
Expand Down Expand Up @@ -336,6 +390,83 @@ export function ProportionAnalyzer() {
</div>
</div>

{isDiamondRoundBrilliant && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Girdle thickness (optional)
</label>
<select
value={girdle}
onChange={(e) => 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) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Culet size (optional)
</label>
<select
value={culet}
onChange={(e) => setCulet(e.target.value as CuletSize | '')}
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"
>
{CULET_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
</div>
)}

{giaResult && giaInputCount > 0 && (
<div className={`p-4 rounded-lg border-2 ${
giaResult.grade === 'Excellent' ? 'bg-green-50 border-green-300' :
giaResult.grade === 'Very Good' ? 'bg-blue-50 border-blue-300' :
giaResult.grade === 'Good' ? 'bg-slate-50 border-slate-300' :
giaResult.grade === 'Fair' ? 'bg-amber-50 border-amber-300' :
'bg-red-50 border-red-300'
}`}>
<div className="flex items-start justify-between gap-3 mb-2">
<h5 className="text-lg font-bold text-slate-900">
GIA-style Cut Grade: {giaResult.grade}
</h5>
<span className={`text-xs px-2 py-1 rounded font-medium ${GIA_BADGE[giaResult.grade]}`}>
Diamond round brilliant
</span>
</div>
{giaResult.limitingParameter && giaResult.grade !== 'Excellent' && (
<p className="text-sm text-slate-800 mb-2">
<strong>Limiting parameter:</strong> {giaResult.limitingParameter.comment}
</p>
)}
<div className="text-xs text-slate-700">
<strong>Per-parameter:</strong>
<ul className="mt-1 space-y-0.5">
{giaResult.parameterGrades.map((p, idx) => (
<li key={idx}>
<span className={`inline-block px-1.5 py-0.5 rounded mr-2 text-[10px] font-medium ${GIA_BADGE[p.grade]}`}>
{p.grade}
</span>
{p.parameter} = {p.value}
</li>
))}
</ul>
</div>
<p className="text-xs text-slate-600 mt-2 italic">
Educational reference only — formal GIA grading also weighs symmetry, polish, and overall appeal.
</p>
</div>
)}

{analysis && (
<div className={`p-4 rounded-lg border-2 ${
analysis.grade.grade === 'Excellent' ? 'bg-green-50 border-green-300' :
Expand Down
184 changes: 184 additions & 0 deletions src/components/advanced/TreatmentWizard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
/**
* Treatment-detection wizard.
*
* Pick the gem kind, tick observed clues, get a ranked likelihood of which
* treatments the stone has undergone. Evidence-weighted: each clue can support
* or rule out individual treatments. Sister widget to the static
* TreatmentDetection reference table.
*/

import { useMemo, useState } from 'react';
import {
cluesForKind,
runWizard,
type GemKind,
type TreatmentVerdict,
} from '../../lib/treatments/wizard';
import { FormField, Select } from '../form';

const GEM_KIND_OPTIONS: { value: GemKind; label: string }[] = [
{ value: 'corundum', label: 'Corundum (ruby / sapphire)' },
{ value: 'emerald', label: 'Emerald' },
{ value: 'beryl-other', label: 'Other beryl (aqua, morganite, heliodor…)' },
{ value: 'tourmaline', label: 'Tourmaline' },
{ value: 'topaz', label: 'Topaz' },
{ value: 'quartz', label: 'Quartz / amethyst / citrine' },
{ value: 'amber', label: 'Amber' },
{ value: 'turquoise', label: 'Turquoise' },
{ value: 'jadeite', label: 'Jadeite' },
{ value: 'opal', label: 'Opal' },
{ value: 'pearl', label: 'Pearl' },
{ value: 'diamond', label: 'Diamond' },
];

const CONFIDENCE_BADGE: Record<TreatmentVerdict['confidence'], string> = {
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<GemKind>('corundum');
const [selected, setSelected] = useState<Set<string>>(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 (
<div className="space-y-6">
<p className="text-sm text-slate-600">
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.
</p>

<FormField name="treatment-gem-kind" label="Gem kind">
<Select
options={GEM_KIND_OPTIONS}
value={gemKind}
onChange={handleGemChange}
/>
</FormField>

<div className="rounded-lg border border-slate-200 bg-slate-50 p-4">
<h4 className="text-sm font-semibold text-slate-700 mb-3">
Observed clues ({selected.size} ticked of {availableClues.length})
</h4>
<div className="space-y-2">
{availableClues.map((clue) => {
const isOn = selected.has(clue.id);
return (
<label
key={clue.id}
className={`flex gap-3 items-start p-2.5 rounded border cursor-pointer transition ${
isOn
? 'bg-cyan-50 border-cyan-300'
: 'bg-white border-slate-200 hover:border-slate-300'
}`}
>
<input
type="checkbox"
checked={isOn}
onChange={() => toggle(clue.id)}
className="mt-1 accent-cyan-600"
/>
<div className="text-sm">
<div className="font-medium text-slate-800">{clue.label}</div>
{clue.description && (
<div className="text-xs text-slate-600 mt-0.5">{clue.description}</div>
)}
</div>
</label>
);
})}
</div>
{selected.size > 0 && (
<button
type="button"
onClick={() => setSelected(new Set())}
className="mt-3 text-xs text-slate-600 hover:text-slate-900 underline"
>
Clear all clues
</button>
)}
</div>

{selected.size === 0 ? (
<div className="p-3 rounded-lg bg-slate-50 border border-slate-200 text-slate-600 text-sm text-center">
Tick at least one clue above to see ranked treatments.
</div>
) : verdicts.length === 0 ? (
<div className="p-3 rounded-lg bg-emerald-50 border border-emerald-200 text-emerald-700 text-sm text-center">
Selected clues do not point to any common treatment — likely natural / untreated within
the limits of these observations.
</div>
) : (
<div className="space-y-3">
<h4 className="text-sm font-medium text-slate-700">
{verdicts.length} candidate treatment{verdicts.length === 1 ? '' : 's'}
</h4>
{verdicts.map((v) => (
<div
key={v.treatment}
className="p-3 rounded-lg bg-white border border-slate-200"
>
<div className="flex items-start justify-between gap-3">
<div className="font-semibold text-slate-800">{v.label}</div>
<span
className={`text-xs px-2 py-0.5 rounded ${CONFIDENCE_BADGE[v.confidence]}`}
>
{v.confidence} (score {v.score >= 0 ? '+' : ''}
{v.score})
</span>
</div>
{v.supportingClueIds.length > 0 && (
<div className="mt-2 text-xs text-slate-700">
<span className="font-medium text-emerald-700">Supports:</span>{' '}
{v.supportingClueIds
.map((id) => availableClues.find((c) => c.id === id)?.label ?? id)
.join('; ')}
</div>
)}
{v.contradictingClueIds.length > 0 && (
<div className="mt-1 text-xs text-slate-700">
<span className="font-medium text-rose-700">Argues against:</span>{' '}
{v.contradictingClueIds
.map((id) => availableClues.find((c) => c.id === id)?.label ?? id)
.join('; ')}
</div>
)}
</div>
))}
</div>
)}

<div className="text-xs text-slate-500 italic">
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.
</div>
</div>
);
}
1 change: 1 addition & 0 deletions src/components/advanced/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@
*/

export { TreatmentDetection } from './TreatmentDetection';
export { TreatmentWizard } from './TreatmentWizard';
export { ProportionAnalyzer } from './ProportionAnalyzer';
export { AdvancedTools } from './AdvancedTools';
2 changes: 1 addition & 1 deletion src/components/calculator/BirefringenceCalc.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export function BirefringenceCalc() {
</p>
</div>

<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
name="ri-max"
label="RI Maximum"
Expand Down
Loading
Loading