'use client'; import { useId, type CSSProperties, type ReactNode } from 'react'; import { useTranslations } from 'next-intl'; import { FDI_LOWER_LEFT_TO_RIGHT, FDI_UPPER_LEFT_TO_RIGHT, getToothShapeKind } from '@/components/treatment/fdiToothMeta'; import { ToothGlyph } from '@/components/ui/treatment/ToothGlyph'; import type { FdiToothId } from '@/types/treatment'; const TOOTH_NUMBER_GAP = 'mt-1'; /** Six teeth each side of the midline (anteriors): slightly larger glyph + wrapper. */ const MIDLINE_SIX = new Set([ '13', '12', '11', '21', '22', '23', '43', '42', '41', '31', '32', '33', ]); function quadrantMirrored(fdi: FdiToothId): boolean { const q = fdi[0]; return q === '2' || q === '3'; } interface FdiToothChartProps { selected: ReadonlySet; /** Teeth that belong to a connected selection span (dots above/below). */ connectedTeeth?: ReadonlySet; onToggle?: (fdi: FdiToothId, event: { shiftKey: boolean }) => void; disabled?: boolean; /** Non-interactive display (Cases / connection history). */ readOnly?: boolean; /** Visual scale via CSS zoom (0.5 = half size). */ scale?: number; /** Per-tooth accent color for selected glow (prosthesis / treatment-type palettes). */ toothColors?: Partial>; /** Extra control rendered in the chart header (e.g. whole-plan checkbox). */ headerControl?: ReactNode; /** Compact card for embedded case detail panels. */ compact?: boolean; className?: string; } export function FdiToothChart({ selected, connectedTeeth, onToggle, disabled, readOnly = false, scale = 1, toothColors, headerControl, compact = false, className = '', }: FdiToothChartProps) { const t = useTranslations('treatment'); const uid = useId().replace(/:/g, ''); const archPeak = 10; const interactive = !readOnly && Boolean(onToggle); const isDisabled = disabled || readOnly; const TOOTH_TWEAKS: Record = { '18': { glyph: 'w-[1.98rem] h-[4.65rem]', offset: 7, rotate: -11 }, '17': { glyph: 'w-[2rem] h-[4.75rem]', offset: 8, rotate: -8 }, '16': { glyph: 'w-[2.1rem] h-[4.95rem]', offset: 9, rotate: -6 }, '15': { glyph: 'w-[1.8rem] h-[4.8rem]', offset: 9, rotate: -4 }, '14': { glyph: 'w-[1.8rem] h-[4.85rem]', offset: 9, rotate: -2 }, '13': { glyph: 'w-[1.9rem] h-[5.28rem]', offset: 8, rotate: -1 }, '12': { glyph: 'w-[1.8rem] h-[5.02rem]', offset: 7, rotate: -1 }, '11': { glyph: 'w-[1.8rem] h-[5.08rem]', offset: 7, rotate: -0.5 }, '21': { glyph: 'w-[1.8rem] h-[5.08rem]', offset: 7, rotate: 0.5 }, '22': { glyph: 'w-[1.8rem] h-[5.02rem]', offset: 7, rotate: 1 }, '23': { glyph: 'w-[1.9rem] h-[5.28rem]', offset: 8, rotate: 1 }, '24': { glyph: 'w-[1.8rem] h-[4.85rem]', offset: 9, rotate: 2 }, '25': { glyph: 'w-[1.8rem] h-[4.8rem]', offset: 9, rotate: 4 }, '26': { glyph: 'w-[2.1rem] h-[4.95rem]', offset: 9, rotate: 6 }, '27': { glyph: 'w-[2rem] h-[4.75rem]', offset: 8, rotate: 8 }, '28': { glyph: 'w-[1.98rem] h-[4.65rem]', offset: 7, rotate: 11 }, '48': { glyph: 'w-[1.98rem] h-[4.7rem]', offset: -7, rotate: -11 }, '47': { glyph: 'w-[2rem] h-[4.8rem]', offset: -8, rotate: -8 }, '46': { glyph: 'w-[2.15rem] h-[5rem]', offset: -9, rotate: -6 }, '45': { glyph: 'w-[1.82rem] h-[4.85rem]', offset: -9, rotate: -4 }, '44': { glyph: 'w-[1.82rem] h-[4.9rem]', offset: -9, rotate: -2 }, '43': { glyph: 'w-[1.88rem] h-[5.34rem]', offset: -8, rotate: -1 }, '42': { glyph: 'w-[1.76rem] h-[5.04rem]', offset: -7, rotate: -1 }, '41': { glyph: 'w-[1.74rem] h-[4.98rem]', offset: -7, rotate: -0.5 }, '31': { glyph: 'w-[1.74rem] h-[4.98rem]', offset: -7, rotate: 0.5 }, '32': { glyph: 'w-[1.76rem] h-[5.04rem]', offset: -7, rotate: 1 }, '33': { glyph: 'w-[1.88rem] h-[5.34rem]', offset: -8, rotate: 1 }, '34': { glyph: 'w-[1.82rem] h-[4.9rem]', offset: -9, rotate: 2 }, '35': { glyph: 'w-[1.82rem] h-[4.85rem]', offset: -9, rotate: 4 }, '36': { glyph: 'w-[2.15rem] h-[5rem]', offset: -9, rotate: 6 }, '37': { glyph: 'w-[2rem] h-[4.8rem]', offset: -8, rotate: 8 }, '38': { glyph: 'w-[1.98rem] h-[4.7rem]', offset: -7, rotate: 11 }, }; const toothSizeClass = (fdi: FdiToothId) => { const kind = getToothShapeKind(fdi); const mid = MIDLINE_SIX.has(fdi); switch (kind) { case 'incisor': return mid ? { wrapper: 'w-[2.1rem]', glyph: 'w-[1.68rem] h-[4.78rem]' } : { wrapper: 'w-[2rem]', glyph: 'w-[1.6rem] h-[4.6rem]' }; case 'canine': return mid ? { wrapper: 'w-[2.2rem]', glyph: 'w-[1.78rem] h-[5.05rem]' } : { wrapper: 'w-[2.1rem]', glyph: 'w-[1.7rem] h-[4.9rem]' }; case 'premolar': return mid ? { wrapper: 'w-[2.34rem]', glyph: 'w-[1.93rem] h-[5.02rem]' } : { wrapper: 'w-[2.25rem]', glyph: 'w-[1.85rem] h-[4.85rem]' }; case 'molar': return mid ? { wrapper: 'w-[2.52rem]', glyph: 'w-[2.12rem] h-[5.1rem]' } : { wrapper: 'w-[2.45rem]', glyph: 'w-[2.05rem] h-[4.95rem]' }; default: return { wrapper: 'w-[2.25rem]', glyph: 'w-[1.85rem] h-[4.85rem]' }; } }; const archOffset = (index: number, count: number, upper?: boolean) => { const center = (count - 1) / 2; const dist = Math.abs(index - center) / center; const curve = (1 - Math.pow(dist, 1.45)) * archPeak; return upper ? curve : -curve; }; const archRotate = (index: number, count: number) => { const center = (count - 1) / 2; const normalized = (index - center) / center; return normalized * 6; }; const toothAccent = (fdi: FdiToothId) => toothColors?.[fdi]; const numberColorClass = (fdi: FdiToothId, isSel: boolean) => { if (!isSel) return 'text-text-muted'; const accent = toothAccent(fdi); return accent ? '' : 'text-primary'; }; const numberStyle = (fdi: FdiToothId, isSel: boolean): CSSProperties | undefined => { if (!isSel) return undefined; const accent = toothAccent(fdi); return accent ? { color: accent } : undefined; }; /** Dots + thin links — only for Shift-connected spans (not plain singles). */ const ConnectedRail = ({ teeth, upper }: { teeth: FdiToothId[]; upper?: boolean }) => { const hasConnected = teeth.some((fdi) => connectedTeeth?.has(fdi)); if (!hasConnected) return null; return (
{teeth.map((fdi, i) => { const size = toothSizeClass(fdi); const isConnected = Boolean(connectedTeeth?.has(fdi)); const next = teeth[i + 1]; const linkToNext = Boolean(isConnected && next && connectedTeeth?.has(next)); return (
{linkToNext ? ( ) : null} {isConnected ? ( ) : ( )}
); })}
); }; const Row = ({ teeth, upper }: { teeth: FdiToothId[]; upper?: boolean }) => (
{upper ? : null}
{teeth.map((fdi, i) => { const kind = getToothShapeKind(fdi); const gid = `${uid}-g-${fdi}-${i}`; const isSel = selected.has(fdi); const isConnected = Boolean(connectedTeeth?.has(fdi)); const size = toothSizeClass(fdi); const tweak = TOOTH_TWEAKS[fdi]; const offsetY = tweak ? tweak.offset : archOffset(i, teeth.length, upper); const rotate = tweak ? tweak.rotate : archRotate(i, teeth.length); const alignItems = upper ? 'items-end' : 'items-start'; const accent = toothAccent(fdi); const glyph = ( ); return (
{interactive ? ( ) : (
{glyph}
)}
); })}
{!upper ? : null}
); const cardClass = compact ? 'rounded-lg border border-border/60 bg-background-secondary/30 p-2 space-y-2' : 'surface-card p-3 space-y-3'; const chartBody = ( <>

{t('upperArch')}

{FDI_UPPER_LEFT_TO_RIGHT.map((fdi) => { const size = toothSizeClass(fdi); const isSel = selected.has(fdi); return ( {fdi} ); })}
{FDI_LOWER_LEFT_TO_RIGHT.map((fdi) => { const size = toothSizeClass(fdi); const isSel = selected.has(fdi); return ( {fdi} ); })}

{t('lowerArch')}

); return (

{compact ? t('toothChartTitleCompact') : t('toothChartTitle')}

{!compact && (

{t('toothChartHint')}

)}
{headerControl}

{t('selectedLabel')}{' '} {selected.size === 0 ? t('selectedEmpty') : [...selected].sort().join(', ')}

{scale !== 1 ? (
{chartBody}
) : ( chartBody )}
); }