'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 ( ); } 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 ? (
) : 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 ? (
{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 ? ( ) : 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')}

) : null}
); }