feature: FDI tooth chart UI updated.
This commit is contained in:
@@ -1,140 +1,150 @@
|
||||
'use client';
|
||||
|
||||
import { memo } from 'react';
|
||||
import { memo, type ReactNode } from 'react';
|
||||
import type { FdiToothId } from '@/types/treatment';
|
||||
import type { ToothShapeKind } from '@/components/treatment/fdiToothMeta';
|
||||
import type { FdiChartDesignId } from '@/components/ui/treatment/fdiChartDesigns';
|
||||
import { getToothGlyphPaint } from '@/components/ui/treatment/toothGlyphPaint';
|
||||
import type { ToothGlyphPaint } from '@/components/ui/treatment/toothGlyphPaint';
|
||||
import { getToothPathModel, type ToothPathModel } from '@/components/ui/treatment/toothPathModel';
|
||||
import { getTextbookToothPathModel } from '@/components/ui/treatment/toothPathModelTextbook';
|
||||
import { wireframeInnerScale, wireframeOriginY } from '@/components/ui/treatment/toothGlyphStructural';
|
||||
|
||||
interface ToothGlyphProps {
|
||||
fdi: FdiToothId;
|
||||
kind: ToothShapeKind;
|
||||
gradientId: string;
|
||||
selected: boolean;
|
||||
upper?: boolean;
|
||||
mirrored?: boolean;
|
||||
upsideDown?: boolean;
|
||||
className?: string;
|
||||
variant?: FdiChartDesignId;
|
||||
}
|
||||
|
||||
/** Stylized single tooth for FDI chart (outline + radial fill). Crown above, root(s) below. */
|
||||
function filledSilhouette(
|
||||
model: ToothPathModel,
|
||||
crownFill: string,
|
||||
rootFill: string,
|
||||
stroke: string,
|
||||
strokeW: number,
|
||||
): ReactNode {
|
||||
return (
|
||||
<>
|
||||
<path d={model.crown} fill={crownFill} stroke={stroke} strokeWidth={strokeW} strokeLinejoin="round" />
|
||||
{model.roots.map((d, i) => (
|
||||
<path key={i} d={d} fill={rootFill} stroke={stroke} strokeWidth={strokeW} strokeLinejoin="round" />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function detailOnly(model: ToothPathModel, stroke: string, strokeW: number): ReactNode {
|
||||
if (!model.detailStroke) return null;
|
||||
const { d, widthScale } = model.detailStroke;
|
||||
return <path d={d} fill="none" stroke={stroke} strokeWidth={strokeW * widthScale} strokeLinecap="round" />;
|
||||
}
|
||||
|
||||
function renderClinicalFill(model: ToothPathModel, paint: ToothGlyphPaint): ReactNode {
|
||||
const { crownFill, rootFill, stroke, strokeW } = paint;
|
||||
return (
|
||||
<>
|
||||
{filledSilhouette(model, crownFill, rootFill, stroke, strokeW)}
|
||||
{detailOnly(model, stroke, strokeW)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function renderWireframe(model: ToothPathModel, paint: ToothGlyphPaint, kind: ToothShapeKind): ReactNode {
|
||||
const { stroke, strokeW } = paint;
|
||||
const outer = paint.wireOuter ?? '#f8fafc';
|
||||
const innerCol = paint.wireInner ?? '#64748b';
|
||||
const s = wireframeInnerScale(kind);
|
||||
const crownOy = wireframeOriginY('crown', kind);
|
||||
const rootOy = wireframeOriginY('root', kind);
|
||||
const rootScale = (i: number) => s - (model.roots.length > 1 ? i * 0.018 : 0);
|
||||
|
||||
return (
|
||||
<>
|
||||
<g>
|
||||
<path d={model.crown} fill="none" stroke={outer} strokeWidth={strokeW + 2.2} strokeLinejoin="round" />
|
||||
<path d={model.crown} fill="none" stroke={stroke} strokeWidth={strokeW} strokeLinejoin="round" />
|
||||
<g transform={`translate(18 ${crownOy}) scale(${s}) translate(-18 ${-crownOy})`}>
|
||||
<path d={model.crown} fill="none" stroke={innerCol} strokeWidth="0.6" strokeLinejoin="round" opacity={0.7} />
|
||||
</g>
|
||||
</g>
|
||||
{model.roots.map((d, i) => (
|
||||
<g key={i}>
|
||||
<path d={d} fill="none" stroke={outer} strokeWidth={strokeW + 2.2} strokeLinejoin="round" />
|
||||
<path d={d} fill="none" stroke={stroke} strokeWidth={strokeW} strokeLinejoin="round" />
|
||||
<g transform={`translate(18 ${rootOy}) scale(${rootScale(i)}) translate(-18 ${-rootOy})`}>
|
||||
<path d={d} fill="none" stroke={innerCol} strokeWidth="0.6" strokeLinejoin="round" opacity={0.65} />
|
||||
</g>
|
||||
</g>
|
||||
))}
|
||||
{detailOnly(model, stroke, strokeW)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Clinical uses FDI-specific paths; wireframe uses textbook silhouettes + line sculpture (original combo). */
|
||||
export const ToothGlyph = memo(function ToothGlyph({
|
||||
fdi,
|
||||
kind,
|
||||
gradientId,
|
||||
selected,
|
||||
upper,
|
||||
mirrored,
|
||||
upsideDown,
|
||||
className = 'w-8 h-[4.85rem]',
|
||||
variant = 'clinical',
|
||||
}: ToothGlyphProps) {
|
||||
const stroke = selected ? 'var(--color-primary)' : '#1e293b';
|
||||
const strokeW = selected ? 2.4 : 1.35;
|
||||
const filter = selected ? 'drop-shadow(0 0 6px rgba(9, 169, 188, 0.65))' : undefined;
|
||||
const lastDigit = Number(fdi[1]);
|
||||
const model: ToothPathModel | null =
|
||||
variant === 'clinical'
|
||||
? getToothPathModel(fdi, kind, upper)
|
||||
: getTextbookToothPathModel(kind, upper, lastDigit);
|
||||
const paint = getToothGlyphPaint(variant, gradientId, selected);
|
||||
const { showClinicalGradient, filter, structuralMode } = paint;
|
||||
|
||||
const inner = () => {
|
||||
switch (kind) {
|
||||
case 'incisor':
|
||||
return (
|
||||
<>
|
||||
<path
|
||||
d="M10 7h16q3 0 3 3v22q0 3-3 3h-16q-3 0-3-3v-22q0-3 3-3z"
|
||||
fill={`url(#${gradientId})`}
|
||||
stroke={stroke}
|
||||
strokeWidth={strokeW}
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M14 35v26q4 10 8 0v-26"
|
||||
fill={`url(#${gradientId})`}
|
||||
stroke={stroke}
|
||||
strokeWidth={strokeW}
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
case 'canine':
|
||||
return (
|
||||
<>
|
||||
<path
|
||||
d="M18 6 L26 14 L24 35 H12 L10 14 Z"
|
||||
fill={`url(#${gradientId})`}
|
||||
stroke={stroke}
|
||||
strokeWidth={strokeW}
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M14 35 L12 62 Q18 74 24 62 L22 35 Z"
|
||||
fill={`url(#${gradientId})`}
|
||||
stroke={stroke}
|
||||
strokeWidth={strokeW}
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
case 'premolar':
|
||||
return (
|
||||
<>
|
||||
<path
|
||||
d="M8 12 Q18 6 28 12 L30 22 Q31 28 26 34 H10 Q5 28 6 22 Z"
|
||||
fill={`url(#${gradientId})`}
|
||||
stroke={stroke}
|
||||
strokeWidth={strokeW}
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M11 34 L10 58 Q18 68 26 58 L25 34 Q18 30 11 34 Z"
|
||||
fill={`url(#${gradientId})`}
|
||||
stroke={stroke}
|
||||
strokeWidth={strokeW}
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
case 'molar':
|
||||
return (
|
||||
<>
|
||||
<path
|
||||
d="M6 14 Q10 8 14 10 Q18 7 22 10 Q26 8 30 14 L31 26 Q32 32 27 36 H9 Q4 32 5 26 Z"
|
||||
fill={`url(#${gradientId})`}
|
||||
stroke={stroke}
|
||||
strokeWidth={strokeW}
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M10 36 L8 56 Q14 66 18 58 Q22 66 28 56 L26 36 Q18 32 10 36 Z"
|
||||
fill={`url(#${gradientId})`}
|
||||
stroke={stroke}
|
||||
strokeWidth={strokeW}
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M12 58 Q18 72 24 58"
|
||||
fill="none"
|
||||
stroke={stroke}
|
||||
strokeWidth={strokeW * 0.85}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
if (!model) {
|
||||
return (
|
||||
<svg viewBox="0 0 36 78" className={`${className} shrink-0`} aria-hidden>
|
||||
<text x="4" y="40" fontSize="8" fill="currentColor" opacity="0.35">
|
||||
?
|
||||
</text>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const body = structuralMode === 'wireframe' ? renderWireframe(model, paint, kind) : renderClinicalFill(model, paint);
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 36 78"
|
||||
className="w-8 h-[4.85rem] shrink-0"
|
||||
className={`${className} shrink-0`}
|
||||
style={{ filter }}
|
||||
aria-hidden
|
||||
>
|
||||
<defs>
|
||||
<radialGradient id={gradientId} cx="45%" cy="35%" r="65%">
|
||||
<stop
|
||||
offset="0%"
|
||||
stopColor={selected ? '#cffafe' : '#ffffff'}
|
||||
/>
|
||||
<stop
|
||||
offset="55%"
|
||||
stopColor={selected ? '#5eead4' : '#e0f2fe'}
|
||||
/>
|
||||
<stop
|
||||
offset="100%"
|
||||
stopColor={selected ? '#0e7490' : '#93c5fd'}
|
||||
/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<g transform={mirrored ? 'translate(36 0) scale(-1 1)' : undefined}>{inner()}</g>
|
||||
{showClinicalGradient && (
|
||||
<defs>
|
||||
<radialGradient id={gradientId} cx="45%" cy="35%" r="65%">
|
||||
<stop offset="0%" stopColor={selected ? '#cffafe' : '#ffffff'} />
|
||||
<stop offset="55%" stopColor={selected ? '#5eead4' : '#e0f2fe'} />
|
||||
<stop offset="100%" stopColor={selected ? '#0e7490' : '#93c5fd'} />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
)}
|
||||
<g
|
||||
transform={[
|
||||
mirrored ? 'translate(36 0) scale(-1 1)' : '',
|
||||
upsideDown ? 'translate(0 78) scale(1 -1)' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ') || undefined}
|
||||
>
|
||||
{body}
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user