|
| 1 | +import "./styles.css"; |
| 2 | +import { teethPaths } from "./data"; |
| 3 | +import { useCallback, useState } from "react"; |
| 4 | + |
| 5 | +interface TeethProps { |
| 6 | + name: string; |
| 7 | + outlinePath: string; |
| 8 | + shadowPath: string; |
| 9 | + lineHighlightPath: string | string[]; |
| 10 | + selected?: boolean; |
| 11 | + onClick?: (name: string) => void; |
| 12 | + onKeyDown?: (e: React.KeyboardEvent<SVGGElement>, name: string) => void; |
| 13 | +} |
| 14 | + |
| 15 | +const Teeth = ({ |
| 16 | + name, |
| 17 | + outlinePath, |
| 18 | + shadowPath, |
| 19 | + lineHighlightPath, |
| 20 | + selected, |
| 21 | + onClick, |
| 22 | + onKeyDown, |
| 23 | +}: TeethProps) => ( |
| 24 | + <g |
| 25 | + className={`${name} ${selected ? "selected" : ""}`} |
| 26 | + tabIndex={0} |
| 27 | + onClick={() => onClick?.(name)} |
| 28 | + onKeyDown={(e) => onKeyDown?.(e, name)} |
| 29 | + role="button" |
| 30 | + aria-pressed={selected} |
| 31 | + aria-label={`Tooth ${name}`} |
| 32 | + style={{ |
| 33 | + cursor: "pointer", |
| 34 | + outline: "none", |
| 35 | + touchAction: "manipulation", |
| 36 | + transition: "all 0.2s ease", |
| 37 | + }} |
| 38 | + > |
| 39 | + <title>{name}</title> |
| 40 | + <path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" d={outlinePath} /> |
| 41 | + <path fill="currentColor" d={shadowPath} /> |
| 42 | + {Array.isArray(lineHighlightPath) |
| 43 | + ? lineHighlightPath.map((d, i) => ( |
| 44 | + <path key={i} stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" d={d} /> |
| 45 | + )) |
| 46 | + : <path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" d={lineHighlightPath} />} |
| 47 | + </g> |
| 48 | +); |
| 49 | + |
| 50 | +export interface OdontogramProps { |
| 51 | + defaultSelected?: string[]; |
| 52 | + onChange?: (selected: string[]) => void; |
| 53 | + className?: string; |
| 54 | + selectedColor?: string; |
| 55 | + hoverColor?: string; |
| 56 | +} |
| 57 | + |
| 58 | +const Odontogram: React.FC<OdontogramProps> = ({ |
| 59 | + defaultSelected = [], |
| 60 | + onChange, |
| 61 | + className = "", |
| 62 | + selectedColor = "#1E90FF", |
| 63 | + hoverColor = "#60A5FA", |
| 64 | +}) => { |
| 65 | + const [selected, setSelected] = useState<Set<string>>(new Set(defaultSelected)); |
| 66 | + |
| 67 | + const handleToggle = useCallback( |
| 68 | + (name: string) => { |
| 69 | + setSelected((prev) => { |
| 70 | + const updated = new Set(prev); |
| 71 | + updated.has(name) ? updated.delete(name) : updated.add(name); |
| 72 | + onChange?.(Array.from(updated)); |
| 73 | + return updated; |
| 74 | + }); |
| 75 | + }, |
| 76 | + [onChange] |
| 77 | + ); |
| 78 | + |
| 79 | + const handleKeyDown = useCallback( |
| 80 | + (e: React.KeyboardEvent<SVGGElement>, name: string) => { |
| 81 | + if (e.key === "Enter" || e.key === " ") { |
| 82 | + e.preventDefault(); |
| 83 | + handleToggle(name); |
| 84 | + } |
| 85 | + }, |
| 86 | + [handleToggle] |
| 87 | + ); |
| 88 | + |
| 89 | + const quadrants = [ |
| 90 | + { name: "first", transform: "" }, |
| 91 | + { name: "second", transform: "scale(-1, 1) translate(-409, 0)" }, |
| 92 | + { name: "third", transform: "scale(1, -1) translate(0, -694)" }, |
| 93 | + { name: "fourth", transform: "scale(-1, -1) translate(-409, -694)" }, |
| 94 | + ]; |
| 95 | + |
| 96 | + const renderTeeth = (prefix: string) => |
| 97 | + teethPaths.map((tooth) => { |
| 98 | + const id = `${prefix}${tooth.name}`; |
| 99 | + return ( |
| 100 | + <Teeth |
| 101 | + key={id} |
| 102 | + {...tooth} |
| 103 | + name={id} |
| 104 | + selected={selected.has(id)} |
| 105 | + onClick={handleToggle} |
| 106 | + onKeyDown={handleKeyDown} |
| 107 | + /> |
| 108 | + ); |
| 109 | + }); |
| 110 | + |
| 111 | + return ( |
| 112 | + <div |
| 113 | + className={`OdontogramWrapper ${className}`} |
| 114 | + style={{ |
| 115 | + width: "100%", |
| 116 | + maxWidth: 300, |
| 117 | + margin: "0 auto", |
| 118 | + display: "flex", |
| 119 | + justifyContent: "center", |
| 120 | + alignItems: "center", |
| 121 | + }} |
| 122 | + > |
| 123 | + <svg |
| 124 | + xmlns="http://www.w3.org/2000/svg" |
| 125 | + fill="none" |
| 126 | + viewBox="0 0 409 694" |
| 127 | + className="Odontogram" |
| 128 | + style={{ |
| 129 | + width: "100%", |
| 130 | + height: "auto", |
| 131 | + userSelect: "none", |
| 132 | + touchAction: "manipulation", |
| 133 | + }} |
| 134 | + > |
| 135 | + |
| 136 | + |
| 137 | + <g name="upper"> |
| 138 | + {quadrants.slice(0, 2).map(({ name, transform }, index) => ( |
| 139 | + <g key={name} name={name} transform={transform}> |
| 140 | + {renderTeeth(`teeth-${index + 1}`)} |
| 141 | + </g> |
| 142 | + ))} |
| 143 | + </g> |
| 144 | + |
| 145 | + <g name="lower"> |
| 146 | + {quadrants.slice(2).map(({ name, transform }, index) => ( |
| 147 | + <g key={name} name={name} transform={transform}> |
| 148 | + {renderTeeth(`teeth-${index + 3}`)} |
| 149 | + </g> |
| 150 | + ))} |
| 151 | + </g> |
| 152 | + </svg> |
| 153 | + </div> |
| 154 | + ); |
| 155 | +}; |
| 156 | + |
| 157 | +export default Odontogram; |
0 commit comments