feature/treatment #16
@@ -1,10 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useId, useState } from 'react';
|
||||
import { useId } from 'react';
|
||||
import { FDI_LOWER_LEFT_TO_RIGHT, FDI_UPPER_LEFT_TO_RIGHT, getToothShapeKind } from '@/components/treatment/fdiToothMeta';
|
||||
import { ToothGlyph } from '@/components/ui/treatment/ToothGlyph';
|
||||
import { Dropdown } from '@/components/ui/common/Dropdown';
|
||||
import { FDI_CHART_DESIGNS, type FdiChartDesignId } from '@/components/ui/treatment/fdiChartDesigns';
|
||||
import type { FdiToothId } from '@/types/treatment';
|
||||
|
||||
const TOOTH_NUMBER_GAP = 'mt-1';
|
||||
@@ -29,10 +27,9 @@ interface FdiToothChartProps {
|
||||
export function FdiToothChart({ selected, onToggle, disabled }: FdiToothChartProps) {
|
||||
const uid = useId().replace(/:/g, '');
|
||||
const archPeak = 10;
|
||||
const [chartDesign, setChartDesign] = useState<FdiChartDesignId>('clinical');
|
||||
|
||||
const TOOTH_TWEAKS: Record<FdiToothId, { glyph: string; offset: number; rotate: number }> = {
|
||||
'18': { glyph: 'w-[1.9rem] h-[4.5rem]', offset: 7, rotate: -11 },
|
||||
'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 },
|
||||
@@ -47,9 +44,9 @@ export function FdiToothChart({ selected, onToggle, disabled }: FdiToothChartPro
|
||||
'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.9rem] h-[4.5rem]', offset: 7, rotate: 11 },
|
||||
'28': { glyph: 'w-[1.98rem] h-[4.65rem]', offset: 7, rotate: 11 },
|
||||
|
||||
'48': { glyph: 'w-[1.9rem] h-[4.55rem]', 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 },
|
||||
@@ -64,7 +61,7 @@ export function FdiToothChart({ selected, onToggle, disabled }: FdiToothChartPro
|
||||
'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.9rem] h-[4.55rem]', offset: -7, rotate: 11 },
|
||||
'38': { glyph: 'w-[1.98rem] h-[4.7rem]', offset: -7, rotate: 11 },
|
||||
};
|
||||
|
||||
const toothSizeClass = (fdi: FdiToothId) => {
|
||||
@@ -133,7 +130,6 @@ export function FdiToothChart({ selected, onToggle, disabled }: FdiToothChartPro
|
||||
aria-label={`FDI tooth ${fdi}${isSel ? ', selected' : ''}`}
|
||||
>
|
||||
<ToothGlyph
|
||||
variant={chartDesign}
|
||||
fdi={fdi}
|
||||
kind={kind}
|
||||
gradientId={gid}
|
||||
@@ -161,17 +157,6 @@ export function FdiToothChart({ selected, onToggle, disabled }: FdiToothChartPro
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:items-end w-full sm:max-w-xs">
|
||||
<Dropdown
|
||||
label="Chart style (temporary)"
|
||||
value={chartDesign}
|
||||
onChange={(e) => setChartDesign(e.target.value as FdiChartDesignId)}
|
||||
>
|
||||
{FDI_CHART_DESIGNS.map((d) => (
|
||||
<option key={d.id} value={d.id}>
|
||||
{d.label}
|
||||
</option>
|
||||
))}
|
||||
</Dropdown>
|
||||
<p className="text-xs text-text-secondary tabular-nums text-right">
|
||||
Selected: {selected.size === 0 ? '—' : [...selected].sort().join(', ')}
|
||||
</p>
|
||||
|
||||
@@ -3,12 +3,7 @@
|
||||
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;
|
||||
@@ -19,7 +14,6 @@ interface ToothGlyphProps {
|
||||
mirrored?: boolean;
|
||||
upsideDown?: boolean;
|
||||
className?: string;
|
||||
variant?: FdiChartDesignId;
|
||||
}
|
||||
|
||||
function filledSilhouette(
|
||||
@@ -45,49 +39,18 @@ function detailOnly(model: ToothPathModel, stroke: string, strokeW: number): Rea
|
||||
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;
|
||||
function renderClinicalFill(model: ToothPathModel, selected: boolean, gradientId: string): ReactNode {
|
||||
const stroke = selected ? 'var(--color-primary)' : '#1e293b';
|
||||
const strokeW = selected ? 2.4 : 1.35;
|
||||
const fill = `url(#${gradientId})`;
|
||||
return (
|
||||
<>
|
||||
{filledSilhouette(model, crownFill, rootFill, stroke, strokeW)}
|
||||
{filledSilhouette(model, fill, fill, 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). */
|
||||
/** FDI chart tooth: clinical gradient + roots only. */
|
||||
export const ToothGlyph = memo(function ToothGlyph({
|
||||
fdi,
|
||||
kind,
|
||||
@@ -97,15 +60,9 @@ export const ToothGlyph = memo(function ToothGlyph({
|
||||
mirrored,
|
||||
upsideDown,
|
||||
className = 'w-8 h-[4.85rem]',
|
||||
variant = 'clinical',
|
||||
}: ToothGlyphProps) {
|
||||
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 model: ToothPathModel | null = getToothPathModel(fdi, kind, upper);
|
||||
const filter = selected ? 'drop-shadow(0 0 6px rgba(9, 169, 188, 0.65))' : undefined;
|
||||
|
||||
if (!model) {
|
||||
return (
|
||||
@@ -117,7 +74,7 @@ export const ToothGlyph = memo(function ToothGlyph({
|
||||
);
|
||||
}
|
||||
|
||||
const body = structuralMode === 'wireframe' ? renderWireframe(model, paint, kind) : renderClinicalFill(model, paint);
|
||||
const body = renderClinicalFill(model, selected, gradientId);
|
||||
|
||||
return (
|
||||
<svg
|
||||
@@ -126,7 +83,6 @@ export const ToothGlyph = memo(function ToothGlyph({
|
||||
style={{ filter }}
|
||||
aria-hidden
|
||||
>
|
||||
{showClinicalGradient && (
|
||||
<defs>
|
||||
<radialGradient id={gradientId} cx="45%" cy="35%" r="65%">
|
||||
<stop offset="0%" stopColor={selected ? '#cffafe' : '#ffffff'} />
|
||||
@@ -134,7 +90,6 @@ export const ToothGlyph = memo(function ToothGlyph({
|
||||
<stop offset="100%" stopColor={selected ? '#0e7490' : '#93c5fd'} />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
)}
|
||||
<g
|
||||
transform={[
|
||||
mirrored ? 'translate(36 0) scale(-1 1)' : '',
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
export const FDI_CHART_DESIGNS = [
|
||||
{ id: 'clinical', label: 'Clinical (gradient + roots)' },
|
||||
{ id: 'wireframeSculpt', label: 'Wireframe shells (line sculpture)' },
|
||||
] as const;
|
||||
|
||||
export type FdiChartDesignId = (typeof FDI_CHART_DESIGNS)[number]['id'];
|
||||
@@ -1,53 +0,0 @@
|
||||
import type { FdiChartDesignId } from '@/components/ui/treatment/fdiChartDesigns';
|
||||
|
||||
export type ToothStructuralMode = 'fill' | 'wireframe';
|
||||
|
||||
export interface ToothGlyphPaint {
|
||||
crownFill: string;
|
||||
rootFill: string;
|
||||
stroke: string;
|
||||
strokeW: number;
|
||||
showClinicalGradient: boolean;
|
||||
structuralMode: ToothStructuralMode;
|
||||
filter?: string;
|
||||
wireOuter?: string;
|
||||
wireInner?: string;
|
||||
}
|
||||
|
||||
export function getToothGlyphPaint(
|
||||
variant: FdiChartDesignId,
|
||||
gradientId: string,
|
||||
selected: boolean,
|
||||
): ToothGlyphPaint {
|
||||
const strokeSel = 'var(--color-primary)';
|
||||
const strokeDefault = '#1e293b';
|
||||
|
||||
if (variant === 'clinical') {
|
||||
const g = `url(#${gradientId})`;
|
||||
return {
|
||||
crownFill: g,
|
||||
rootFill: g,
|
||||
stroke: selected ? strokeSel : strokeDefault,
|
||||
strokeW: selected ? 2.4 : 1.35,
|
||||
showClinicalGradient: true,
|
||||
structuralMode: 'fill',
|
||||
filter: selected ? 'drop-shadow(0 0 6px rgba(9, 169, 188, 0.65))' : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (variant === 'wireframeSculpt') {
|
||||
return {
|
||||
crownFill: 'none',
|
||||
rootFill: 'none',
|
||||
stroke: selected ? strokeSel : '#0f172a',
|
||||
strokeW: selected ? 1.65 : 1.05,
|
||||
showClinicalGradient: false,
|
||||
structuralMode: 'wireframe',
|
||||
wireOuter: selected ? '#ecfeff' : '#f8fafc',
|
||||
wireInner: '#64748b',
|
||||
};
|
||||
}
|
||||
|
||||
const _exhaustive: never = variant;
|
||||
return _exhaustive;
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import type { ToothShapeKind } from '@/components/treatment/fdiToothMeta';
|
||||
|
||||
/** Inner “shell” contours: scaled copies of closed paths (wireframe). */
|
||||
export function wireframeInnerScale(kind: ToothShapeKind): number {
|
||||
if (kind === 'molar') return 0.9;
|
||||
if (kind === 'premolar') return 0.88;
|
||||
if (kind === 'canine') return 0.87;
|
||||
return 0.85;
|
||||
}
|
||||
|
||||
/** Approximate transform origin per region for scaled wireframe rings. */
|
||||
export function wireframeOriginY(part: 'crown' | 'root', kind: ToothShapeKind): number {
|
||||
if (part === 'crown') return kind === 'canine' ? 16 : 18;
|
||||
return kind === 'molar' ? 50 : kind === 'premolar' ? 46 : 48;
|
||||
}
|
||||
@@ -8,7 +8,7 @@ export interface ToothPathModel {
|
||||
detailStroke?: { d: string; widthScale: number };
|
||||
}
|
||||
|
||||
/** FDI-specific closed paths for the clinical (gradient) chart. Wireframe uses `getTextbookToothPathModel`. */
|
||||
/** FDI-specific closed paths for the clinical (gradient + roots) chart. */
|
||||
export function getToothPathModel(fdi: FdiToothId, kind: ToothShapeKind, upper?: boolean): ToothPathModel | null {
|
||||
const lastDigit = Number(fdi[1]);
|
||||
const isThirdMolar = lastDigit === 8;
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
import type { ToothShapeKind } from '@/components/treatment/fdiToothMeta';
|
||||
import type { ToothPathModel } from '@/components/ui/treatment/toothPathModel';
|
||||
|
||||
/**
|
||||
* Textbook-style silhouettes (chisel incisor, spear canine, bicuspid premolar, blocky molar).
|
||||
* Used **only** for “Wireframe shells (line sculpture)” so shapes read like the first presentation,
|
||||
* distinct from the clinical FDI-accurate paths.
|
||||
*/
|
||||
export function getTextbookToothPathModel(
|
||||
kind: ToothShapeKind,
|
||||
upper: boolean | undefined,
|
||||
lastDigit: number,
|
||||
): ToothPathModel {
|
||||
switch (kind) {
|
||||
case 'incisor': {
|
||||
const lateral = lastDigit === 2;
|
||||
if (lateral) {
|
||||
return {
|
||||
crown:
|
||||
'M13 8 L23 8 L24 11 L24.5 26 Q23 30.5 18 31.5 Q13 30.5 11.5 26 L12 11 Z',
|
||||
roots: ['M15.5 31.5 L20.5 31.5 L20 48 L18.5 69 L17.5 69 L16 48 Z'],
|
||||
};
|
||||
}
|
||||
return {
|
||||
crown:
|
||||
'M12 8 L24 8 L25 11 L25.5 27 Q24 31 18 32 Q12 31 10.5 27 L11 11 Z',
|
||||
roots: ['M15 32 L21 32 L20.2 50 L18 70 L17 70 L15.8 50 Z'],
|
||||
};
|
||||
}
|
||||
case 'canine':
|
||||
return {
|
||||
crown:
|
||||
'M18 6 L22 11 L24.5 18 L24 28 L21.5 32 L14.5 32 L12 28 L11.5 18 L14 11 Z',
|
||||
roots: ['M15 32 L21 32 L20.5 52 L19 73 L17 73 L15.5 52 Z'],
|
||||
};
|
||||
case 'premolar':
|
||||
return {
|
||||
crown:
|
||||
'M8 14 L13 9 Q15.5 7.5 18 9.5 Q20.5 7.5 23 9 L28 14 L29.5 25 L27 32 L9 32 L6.5 25 Z',
|
||||
roots: [
|
||||
'M13 31.5 L16.5 31.5 L15 52 L12.5 70 L10.5 69 L12 52 Z',
|
||||
'M19.5 31.5 L23 31.5 L24.5 52 L25.5 69 L23.5 70 L22 52 Z',
|
||||
],
|
||||
};
|
||||
case 'molar': {
|
||||
const crown =
|
||||
'M6 14 L8 9 L12.5 7.5 Q18 9.5 23.5 7.5 L28 9 L30 14 L31 25 L29 32.5 L7 32.5 L5 25 Z';
|
||||
const upperMolar = upper === true && lastDigit >= 6 && lastDigit <= 8;
|
||||
if (upperMolar) {
|
||||
return {
|
||||
crown,
|
||||
roots: [
|
||||
'M8.5 32.5 L12 32.5 L10.5 54 L9 71 L7 69.5 L8.5 54 Z',
|
||||
'M15.5 32.5 L20.5 32.5 L19 56 L18 72 L16.5 72 L15.5 56 Z',
|
||||
'M24 32.5 L27.5 32.5 L27.5 54 L29 71 L27 69.5 L26 54 Z',
|
||||
],
|
||||
detailStroke: {
|
||||
d: 'M11 10.5 Q18 12.5 25 10.5 M10 18 Q18 20 26 18',
|
||||
widthScale: 0.55,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
crown,
|
||||
roots: [
|
||||
'M9 32.5 L15 32.5 L13 56 L10.5 71 L8 69.5 L10 56 Z',
|
||||
'M21 32.5 L27 32.5 L26 56 L27.5 71 L25 69.5 L24 56 Z',
|
||||
],
|
||||
detailStroke: {
|
||||
d: 'M12 11 Q18 13.5 24 11 M11 19 Q18 21.5 25 19 M16 62 Q18 65 20 62',
|
||||
widthScale: 0.5,
|
||||
},
|
||||
};
|
||||
}
|
||||
default:
|
||||
return { crown: 'M10 10 L26 10 L26 60 L10 60 Z', roots: [] };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user