feature/treatment #16

Merged
admin merged 5 commits from feature/treatment into master 2026-05-08 12:45:25 +03:30
7 changed files with 22 additions and 234 deletions
Showing only changes of commit b6b408e41e - Show all commits

View File

@@ -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>

View File

@@ -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)' : '',

View File

@@ -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'];

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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: [] };
}
}