'use client'; import { useId, useState, type CSSProperties, type ReactNode } from 'react'; import { useTranslations } from 'next-intl'; import { Info } from 'lucide-react'; import { Button } from '@/components/ui/shared/Button'; import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton'; import { ResponsiveDialogOverlay, ResponsiveDialogPanel, } from '@/components/ui/shared/ResponsiveDialog'; import { FDI_LOWER_LEFT_TO_RIGHT, FDI_UPPER_LEFT_TO_RIGHT, getToothShapeKind } from '@/components/treatment/fdiToothMeta'; import { getToothColumnWidthRem, getToothGlyphWidthRem, } from '@/components/treatment/fdiToothScale'; import { getRealisticToothAsset } from '@/components/treatment/realisticToothAssets'; import { toothEdgeKey } from '@/components/treatment/toothSelectionGroups'; import { ToothGlyph } from '@/components/ui/treatment/ToothGlyph'; import type { FdiToothId } from '@/types/treatment'; const TOOTH_NUMBER_GAP = 'mt-1'; /** Extra crown↔number space for realistic SVGs (outside arch transform so it is not cancelled). */ const REALISTIC_NUMBER_GAP = '2mm'; /** Tight interproximal gap between tooth columns. */ const TOOTH_GAP = 'gap-x-px'; /** * Centers a mark on the inline-end edge of a tooth column (between this tooth * and the next in flex order). Logical `end` + 0-width flex stays correct in LTR and RTL. */ const EDGE_MARK_ANCHOR = 'absolute inset-y-0 end-0 z-10 w-0 flex items-center justify-center'; function quadrantMirrored(fdi: FdiToothId): boolean { const q = fdi[0]; return q === '2' || q === '3'; } /** Cartoon glyph height factor vs width (realistic assets use aspect ratio from viewBox). */ function cartoonHeightRem(fdi: FdiToothId, widthRem: number): number { const kind = getToothShapeKind(fdi); const factor = kind === 'canine' ? 2.85 : kind === 'molar' ? 2.45 : 2.65; return Number((widthRem * factor).toFixed(3)); } function toothSize(fdi: FdiToothId): { columnWidthRem: number; glyphWidthRem: number; glyphHeightRem: number | 'auto'; } { const glyphWidthRem = getToothGlyphWidthRem(fdi); const columnWidthRem = getToothColumnWidthRem(fdi); const realistic = Boolean(getRealisticToothAsset(fdi)); return { columnWidthRem, glyphWidthRem, glyphHeightRem: realistic ? 'auto' : cartoonHeightRem(fdi, glyphWidthRem), }; } function ToothNumber({ fdi, selected, widthRem, colorClass, style, interactive, disabled, onActivate, }: { fdi: FdiToothId; selected: boolean; widthRem: number; colorClass: string; style?: CSSProperties; interactive: boolean; disabled: boolean; onActivate: (shiftKey: boolean) => void; }) { const className = `text-[10px] tabular-nums text-center leading-none ${colorClass}`; const boxStyle: CSSProperties = { width: `${widthRem}rem`, ...style }; if (!interactive) { return ( {fdi} ); } return ( { if (e.shiftKey) e.preventDefault(); }} onPointerDown={(e) => { if (e.button !== 0) return; e.preventDefault(); onActivate(e.shiftKey); }} onKeyDown={(e) => { if (e.key !== 'Enter' && e.key !== ' ') return; e.preventDefault(); onActivate(e.shiftKey); }} aria-pressed={selected} className={`${className} bg-transparent select-none touch-manipulation focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 ${ disabled ? 'cursor-not-allowed' : 'cursor-pointer' }`} style={boxStyle} > {fdi} ); } interface FdiToothChartProps { selected: ReadonlySet; /** * Legacy / read-only: teeth in a connected bridge. * When `linkedEdges` is omitted, filled marks are shown between adjacent * selected teeth that both appear in this set. */ connectedTeeth?: ReadonlySet; /** Filled connection edges (`toothEdgeKey(a,b)`). Preferred over `connectedTeeth` for edit mode. */ linkedEdges?: ReadonlySet; onToggle?: (fdi: FdiToothId, event: { shiftKey: boolean }) => void; /** Toggle connect/disconnect for an adjacent selected pair. */ onToggleLink?: (a: FdiToothId, b: FdiToothId) => 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; /** Nested in the treatment editor — no extra card chrome. */ embedded?: boolean; className?: string; } /** * Distal tip for arch look — gentler than pre-SVG angles so realistic glyphs stay readable. * Positive rotate = clockwise. */ const TOOTH_TWEAKS: Partial> = { '18': { offset: 5, rotate: -7 }, '17': { offset: 6, rotate: -5 }, '16': { offset: 6, rotate: -3.5 }, '15': { offset: 6, rotate: -2 }, '14': { offset: 5, rotate: -1 }, '13': { offset: 5.5, rotate: -0.5 }, '12': { offset: 4, rotate: -0.25 }, '11': { offset: 5, rotate: 0 }, '21': { offset: 5, rotate: 0 }, '22': { offset: 4, rotate: 0.25 }, '23': { offset: 5.5, rotate: 0.5 }, '24': { offset: 5, rotate: 1 }, '25': { offset: 6, rotate: 2 }, '26': { offset: 6, rotate: 3.5 }, '27': { offset: 6, rotate: 5 }, '28': { offset: 5, rotate: 7 }, '48': { offset: -5, rotate: -4 }, '47': { offset: -6, rotate: -3 }, '46': { offset: -6, rotate: -2 }, '45': { offset: -6, rotate: -2 }, '44': { offset: -5, rotate: -1 }, '43': { offset: -4, rotate: -0.5 }, '42': { offset: -4, rotate: -0.25 }, '41': { offset: -3, rotate: 0 }, '31': { offset: -3, rotate: 0 }, '32': { offset: -4, rotate: 0.25 }, '33': { offset: -4, rotate: 0.5 }, '34': { offset: -5, rotate: 1 }, '35': { offset: -6, rotate: 2 }, '36': { offset: -6, rotate: 2 }, '37': { offset: -6, rotate: 3 }, '38': { offset: -5, rotate: 4 }, }; export function FdiToothChart({ selected, connectedTeeth, linkedEdges, onToggle, onToggleLink, disabled, readOnly = false, scale = 1, toothColors, headerControl, compact = false, embedded = false, className = '', }: FdiToothChartProps) { const t = useTranslations('treatment'); const tCommon = useTranslations('common'); const uid = useId().replace(/:/g, ''); const [hintOpen, setHintOpen] = useState(false); const archPeak = 8; const interactive = !readOnly && Boolean(onToggle); const linkInteractive = interactive && Boolean(onToggleLink); const isDisabled = disabled || readOnly; const toothSizeClass = (fdi: FdiToothId) => toothSize(fdi); 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 * 4; }; 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; }; const edgeIsLinked = (a: FdiToothId, b: FdiToothId): boolean => { const key = toothEdgeKey(a, b); if (linkedEdges) return linkedEdges.has(key); // Read-only fallback: both teeth marked connected. return Boolean(connectedTeeth?.has(a) && connectedTeeth?.has(b)); }; /** Circles sit between neighbors, on the crown side (toward FDI numbers). */ const EdgeRail = ({ teeth }: { teeth: FdiToothId[] }) => { const anyVisible = teeth.some((fdi, i) => { const next = teeth[i + 1]; if (!next || !selected.has(fdi) || !selected.has(next)) return false; const linked = edgeIsLinked(fdi, next); return linkInteractive || linked; }); if (!anyVisible) return null; return ( {teeth.map((fdi, i) => { const size = toothSizeClass(fdi); const prev = i > 0 ? teeth[i - 1] : undefined; const next = teeth[i + 1]; const showEdge = Boolean(next && selected.has(fdi) && selected.has(next)); const linked = showEdge ? edgeIsLinked(fdi, next!) : false; const renderEdge = showEdge && (linkInteractive || linked); // Narrow link across this column when both neighboring edges are filled. const bridgeLine = Boolean( prev && next && selected.has(prev) && selected.has(fdi) && selected.has(next) && edgeIsLinked(prev, fdi) && edgeIsLinked(fdi, next), ); return ( {bridgeLine ? ( ) : null} {renderEdge ? ( {linkInteractive ? ( { e.preventDefault(); e.stopPropagation(); onToggleLink?.(fdi, next!); }} className={` h-3.5 w-3.5 shrink-0 rounded-full border-2 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 ${ linked ? 'border-primary bg-primary shadow-sm' : 'border-primary bg-background-secondary hover:bg-primary/15' } ${isDisabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'} `} /> ) : ( )} ) : null} ); })} ); }; const Row = ({ teeth, upper }: { teeth: FdiToothId[]; upper?: boolean }) => ( {/* Lower: crowns face numbers above → rail above teeth */} {!upper ? : null} {teeth.map((fdi, i) => { const kind = getToothShapeKind(fdi); const gid = `${uid}-g-${fdi}-${i}`; const isSel = selected.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 realistic = Boolean(getRealisticToothAsset(fdi)); const glyph = ( ); /** Cover crown/root shift toward the numbers without overlapping neighbors. */ const hitOverflowPx = Math.abs(offsetY); const activateTooth = (shiftKey: boolean) => { if (!interactive || isDisabled) return; onToggle?.(fdi, { shiftKey }); }; return ( {!upper && realistic ? ( ) : null} {interactive ? ( { if (e.shiftKey) e.preventDefault(); }} onPointerDown={(e) => { if (e.button !== 0) return; e.preventDefault(); e.stopPropagation(); activateTooth(e.shiftKey); }} onKeyDown={(e) => { if (e.key !== 'Enter' && e.key !== ' ') return; e.preventDefault(); activateTooth(e.shiftKey); }} className={` absolute inset-y-[6%] z-10 rounded-[var(--radius-sm)] bg-transparent select-none touch-manipulation focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 ${isDisabled ? 'cursor-not-allowed' : 'cursor-pointer'} `} style={{ left: '22%', right: '22%' }} aria-pressed={isSel} aria-label={ isSel ? `${t('toothAria', { fdi })}${t('toothSelectedSuffix')}` : t('toothAria', { fdi }) } /> ) : null} {glyph} {upper && realistic ? ( ) : null} ); })} {/* Upper: crowns face numbers below → rail below teeth */} {upper ? : null} ); const cardClass = embedded ? 'space-y-2' : 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) => ( onToggle?.(fdi, { shiftKey })} /> ))} {FDI_LOWER_LEFT_TO_RIGHT.map((fdi) => ( onToggle?.(fdi, { shiftKey })} /> ))} {t('lowerArch')} > ); return ( {compact || embedded ? t('toothChartTitleCompact') : t('toothChartTitle')} {!compact && !embedded ? ( setHintOpen(true)} className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-text-muted hover:bg-background-card hover:text-text-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50" aria-label={t('toothChartHelpAria')} > ) : null} {headerControl} {t('selectedLabel')}{' '} {selected.size === 0 ? t('selectedEmpty') : [...selected].sort().join(', ')} {scale !== 1 ? ( {chartBody} ) : ( chartBody )} {hintOpen ? ( setHintOpen(false)}> {t('toothChartHelpAria')} setHintOpen(false)} /> {t('toothChartHint')} setHintOpen(false)}> {tCommon('close')} ) : null} ); }
{t('upperArch')}
{t('lowerArch')}
{t('selectedLabel')}{' '} {selected.size === 0 ? t('selectedEmpty') : [...selected].sort().join(', ')}
{t('toothChartHint')}