hpo-react-visualizer 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +59 -0
- package/dist/index.cjs +1483 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +244 -0
- package/dist/index.d.ts +244 -0
- package/dist/index.js +1473 -0
- package/dist/index.js.map +1 -0
- package/package.json +60 -0
- package/src/HpoVisualizer.tsx +210 -0
- package/src/OrganSvg.tsx +141 -0
- package/src/__tests__/colorScheme.test.tsx +146 -0
- package/src/__tests__/createStrictColorPalette.test.ts +135 -0
- package/src/__tests__/renderOrder.test.tsx +146 -0
- package/src/__tests__/setup.ts +1 -0
- package/src/constants.ts +158 -0
- package/src/index.ts +34 -0
- package/src/lib/createStrictColorPalette.ts +57 -0
- package/src/lib/index.ts +1 -0
- package/src/svg/Blood.tsx +23 -0
- package/src/svg/Body.tsx +24 -0
- package/src/svg/Breast.tsx +42 -0
- package/src/svg/Cell.tsx +79 -0
- package/src/svg/Constitutional.tsx +29 -0
- package/src/svg/Digestive.tsx +28 -0
- package/src/svg/Ear.tsx +23 -0
- package/src/svg/Endocrine.tsx +32 -0
- package/src/svg/Eye.tsx +23 -0
- package/src/svg/Growth.tsx +23 -0
- package/src/svg/Head.tsx +51 -0
- package/src/svg/Heart.tsx +23 -0
- package/src/svg/Immune.tsx +32 -0
- package/src/svg/Integument.tsx +58 -0
- package/src/svg/Kidney.tsx +41 -0
- package/src/svg/Limbs.tsx +46 -0
- package/src/svg/Lung.tsx +23 -0
- package/src/svg/Metabolism.tsx +41 -0
- package/src/svg/Muscle.tsx +225 -0
- package/src/svg/Neoplasm.tsx +23 -0
- package/src/svg/Nervous.tsx +49 -0
- package/src/svg/Prenatal.tsx +23 -0
- package/src/svg/ThoracicCavity.tsx +28 -0
- package/src/svg/Voice.tsx +23 -0
- package/src/svg/index.ts +54 -0
- package/src/types.ts +162 -0
- package/src/useOrganInteraction.ts +130 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { type CSSProperties, useId, useMemo } from "react";
|
|
2
|
+
import { BODY_VIEWBOX, DEFAULT_COLOR_NAME, ORGAN_IDS } from "./constants";
|
|
3
|
+
import { createStrictColorPalette } from "./lib";
|
|
4
|
+
import { OrganSvg } from "./OrganSvg";
|
|
5
|
+
import { Body } from "./svg/Body";
|
|
6
|
+
import type { HpoVisualizerProps, OrganConfig, OrganId, StrictColorPalette } from "./types";
|
|
7
|
+
import { useOrganInteraction } from "./useOrganInteraction";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Default positions and viewBox dimensions for each organ in the visualizer
|
|
11
|
+
* Positions are relative to Body SVG coordinate system (122x325 viewBox)
|
|
12
|
+
* viewBox: original coordinate system of the organ SVG
|
|
13
|
+
*/
|
|
14
|
+
const ORGAN_POSITIONS: Record<
|
|
15
|
+
OrganId,
|
|
16
|
+
{ x: number; y: number; width: number; height: number; viewBox: string }
|
|
17
|
+
> = {
|
|
18
|
+
growth: { x: 104, y: 1, width: 12, height: 349, viewBox: "0 0 12 349" },
|
|
19
|
+
constitutional: { x: 0, y: 0, width: 122, height: 358, viewBox: "0 0 122 358" },
|
|
20
|
+
thoracicCavity: { x: 31, y: 79, width: 60, height: 50, viewBox: "0 0 60 50" },
|
|
21
|
+
lung: { x: 35, y: 85, width: 52, height: 41, viewBox: "0 0 52 41" },
|
|
22
|
+
breast: { x: 26, y: 103, width: 70, height: 30, viewBox: "0 0 70 30" },
|
|
23
|
+
heart: { x: 50, y: 91, width: 22, height: 23, viewBox: "0 0 22 23" },
|
|
24
|
+
digestive: { x: 41, y: 119, width: 42, height: 86, viewBox: "0 0 42 86" },
|
|
25
|
+
kidney: { x: 49, y: 162, width: 24, height: 30, viewBox: "0 0 24 30" },
|
|
26
|
+
limbs: { x: 31, y: 146, width: 87, height: 203, viewBox: "0 0 87 203" },
|
|
27
|
+
integument: { x: 6, y: 91, width: 90, height: 235, viewBox: "0 0 90 235" },
|
|
28
|
+
head: { x: 35, y: 4, width: 52, height: 68, viewBox: "0 0 52 68" },
|
|
29
|
+
eye: { x: 69, y: 24, width: 12, height: 12, viewBox: "0 0 12 12" },
|
|
30
|
+
ear: { x: 34, y: 30, width: 13, height: 13, viewBox: "0 0 13 13" },
|
|
31
|
+
nervous: { x: 27, y: 4, width: 68, height: 185, viewBox: "0 0 68 185" },
|
|
32
|
+
voice: { x: 54, y: 56, width: 14, height: 22, viewBox: "0 0 14 22" },
|
|
33
|
+
metabolism: { x: 50, y: 128, width: 22, height: 21, viewBox: "0 0 22 21" },
|
|
34
|
+
cell: { x: 27, y: 185, width: 11, height: 18, viewBox: "0 0 11 18" },
|
|
35
|
+
endocrine: { x: 50, y: 64, width: 26, height: 99, viewBox: "0 0 26 99" },
|
|
36
|
+
neoplasm: { x: 81, y: 80, width: 11, height: 12, viewBox: "0 0 11 12" },
|
|
37
|
+
immune: { x: 57, y: 81, width: 30, height: 58, viewBox: "0 0 30 58" },
|
|
38
|
+
muscle: { x: 13, y: 79, width: 83, height: 182, viewBox: "0 0 83 182" },
|
|
39
|
+
prenatal: { x: 44, y: 170, width: 34, height: 24, viewBox: "0 0 34 24" },
|
|
40
|
+
blood: { x: 6, y: 182, width: 6, height: 10, viewBox: "0 0 6 10" },
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const BACKGROUND_ORGANS: readonly OrganId[] = ["growth"] as const;
|
|
44
|
+
const FOREGROUND_ORGANS: readonly OrganId[] = [
|
|
45
|
+
"constitutional",
|
|
46
|
+
"thoracicCavity",
|
|
47
|
+
"lung",
|
|
48
|
+
"breast",
|
|
49
|
+
"heart",
|
|
50
|
+
"digestive",
|
|
51
|
+
"kidney",
|
|
52
|
+
"limbs",
|
|
53
|
+
"integument",
|
|
54
|
+
"head",
|
|
55
|
+
"eye",
|
|
56
|
+
"ear",
|
|
57
|
+
"nervous",
|
|
58
|
+
"voice",
|
|
59
|
+
"metabolism",
|
|
60
|
+
"cell",
|
|
61
|
+
"endocrine",
|
|
62
|
+
"neoplasm",
|
|
63
|
+
"immune",
|
|
64
|
+
"muscle",
|
|
65
|
+
"prenatal",
|
|
66
|
+
"blood",
|
|
67
|
+
] as const;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* HPO Visualizer - Interactive human organ visualization component
|
|
71
|
+
*
|
|
72
|
+
* Features:
|
|
73
|
+
* - Displays organs as interactive SVG graphics
|
|
74
|
+
* - Hover effect with visual highlighting
|
|
75
|
+
* - Click to select/deselect organs
|
|
76
|
+
* - Hover effects work independently even when an organ is selected
|
|
77
|
+
* - Exposes hover and selection state via callbacks
|
|
78
|
+
*/
|
|
79
|
+
export function HpoVisualizer({
|
|
80
|
+
organs,
|
|
81
|
+
visibleOrgans,
|
|
82
|
+
hoveredOrgan: controlledHovered,
|
|
83
|
+
selectedOrgan: controlledSelected,
|
|
84
|
+
onHover,
|
|
85
|
+
onSelect,
|
|
86
|
+
colorPalette: inputColorPalette,
|
|
87
|
+
width = BODY_VIEWBOX.width,
|
|
88
|
+
height = BODY_VIEWBOX.height,
|
|
89
|
+
className,
|
|
90
|
+
style,
|
|
91
|
+
}: HpoVisualizerProps) {
|
|
92
|
+
const visualizerID = useId();
|
|
93
|
+
|
|
94
|
+
// Create strict color palette with all required colors and alpha values
|
|
95
|
+
const colorPalette: StrictColorPalette = useMemo(
|
|
96
|
+
() => createStrictColorPalette(inputColorPalette),
|
|
97
|
+
[inputColorPalette],
|
|
98
|
+
);
|
|
99
|
+
// Determine which organs are visible based on visibleOrgans prop
|
|
100
|
+
// Priority: visibleOrgans > organs > all organs
|
|
101
|
+
// - visibleOrgans (array): only the specified organs are visible
|
|
102
|
+
// - visibleOrgans (empty array): no organs are visible
|
|
103
|
+
// - visibleOrgans (undefined) + organs (array): organs from organs prop are visible
|
|
104
|
+
// - both undefined: all organs are visible
|
|
105
|
+
const visibleOrganIds = useMemo((): OrganId[] => {
|
|
106
|
+
if (visibleOrgans !== undefined) {
|
|
107
|
+
return visibleOrgans;
|
|
108
|
+
}
|
|
109
|
+
if (organs !== undefined) {
|
|
110
|
+
return organs.map((o) => o.id);
|
|
111
|
+
}
|
|
112
|
+
return [...ORGAN_IDS];
|
|
113
|
+
}, [visibleOrgans, organs]);
|
|
114
|
+
|
|
115
|
+
// Create organ config map for quick lookup
|
|
116
|
+
const organConfigMap = useMemo((): Map<OrganId, OrganConfig> => {
|
|
117
|
+
const map = new Map<OrganId, OrganConfig>();
|
|
118
|
+
if (organs) {
|
|
119
|
+
for (const config of organs) {
|
|
120
|
+
map.set(config.id, config);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return map;
|
|
124
|
+
}, [organs]);
|
|
125
|
+
|
|
126
|
+
// Use the interaction hook
|
|
127
|
+
const { handlers, isHovered, isSelected } = useOrganInteraction({
|
|
128
|
+
hoveredOrgan: controlledHovered,
|
|
129
|
+
selectedOrgan: controlledSelected,
|
|
130
|
+
onHover,
|
|
131
|
+
onSelect,
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const containerStyle: CSSProperties = {
|
|
135
|
+
display: "flex",
|
|
136
|
+
justifyContent: "center",
|
|
137
|
+
alignItems: "flex-end",
|
|
138
|
+
userSelect: "none",
|
|
139
|
+
position: "relative",
|
|
140
|
+
width: width,
|
|
141
|
+
height: height,
|
|
142
|
+
...style,
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const viewBox = `0 0 ${BODY_VIEWBOX.width} ${BODY_VIEWBOX.height}`;
|
|
146
|
+
const scale = Math.min(Number(width) / BODY_VIEWBOX.width, Number(height) / BODY_VIEWBOX.height);
|
|
147
|
+
const translateX = (Number(width) - BODY_VIEWBOX.width * scale) / 2;
|
|
148
|
+
|
|
149
|
+
// Helper function to render an organ
|
|
150
|
+
const renderOrgan = (organId: OrganId, isVisible: boolean) => {
|
|
151
|
+
const position = ORGAN_POSITIONS[organId];
|
|
152
|
+
const config = organConfigMap.get(organId);
|
|
153
|
+
|
|
154
|
+
// Check if organ should be visible based on config style (different from visibleOrgans)
|
|
155
|
+
if (config?.style?.visible === false) {
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const x = translateX + position.x * scale;
|
|
160
|
+
const y = position.y * scale;
|
|
161
|
+
const width = position.width * scale;
|
|
162
|
+
const height = position.height * scale;
|
|
163
|
+
|
|
164
|
+
return (
|
|
165
|
+
<OrganSvg
|
|
166
|
+
key={`${visualizerID}-${organId}`}
|
|
167
|
+
organId={organId}
|
|
168
|
+
config={config}
|
|
169
|
+
colorPalette={colorPalette}
|
|
170
|
+
isHovered={isHovered(organId)}
|
|
171
|
+
isSelected={isSelected(organId)}
|
|
172
|
+
onMouseEnter={() => handlers.handleMouseEnter(organId)}
|
|
173
|
+
onMouseLeave={handlers.handleMouseLeave}
|
|
174
|
+
onClick={() => handlers.handleClick(organId)}
|
|
175
|
+
x={x}
|
|
176
|
+
y={y}
|
|
177
|
+
width={width}
|
|
178
|
+
height={height}
|
|
179
|
+
viewBox={position.viewBox}
|
|
180
|
+
showOutline={config?.showOutline}
|
|
181
|
+
isVisible={isVisible}
|
|
182
|
+
/>
|
|
183
|
+
);
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
return (
|
|
187
|
+
<div className={className} style={containerStyle}>
|
|
188
|
+
{BACKGROUND_ORGANS.map((organId) => renderOrgan(organId, visibleOrganIds.includes(organId)))}
|
|
189
|
+
{/* Background Body - rendered as nested SVG for flat structure */}
|
|
190
|
+
<svg
|
|
191
|
+
x={translateX}
|
|
192
|
+
y="0"
|
|
193
|
+
width={width}
|
|
194
|
+
height={height}
|
|
195
|
+
viewBox={viewBox}
|
|
196
|
+
style={{ position: "absolute", top: "0", left: "0" }}
|
|
197
|
+
pointerEvents="none"
|
|
198
|
+
>
|
|
199
|
+
<title>Human body</title>
|
|
200
|
+
<Body
|
|
201
|
+
colorScale={colorPalette[DEFAULT_COLOR_NAME]}
|
|
202
|
+
style={{
|
|
203
|
+
fill: "#fff",
|
|
204
|
+
}}
|
|
205
|
+
/>
|
|
206
|
+
</svg>
|
|
207
|
+
{FOREGROUND_ORGANS.map((organId) => renderOrgan(organId, visibleOrganIds.includes(organId)))}
|
|
208
|
+
</div>
|
|
209
|
+
);
|
|
210
|
+
}
|
package/src/OrganSvg.tsx
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { type CSSProperties, useMemo } from "react";
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_COLOR_NAME,
|
|
4
|
+
DEFAULT_STROKE_COLOR,
|
|
5
|
+
DEFAULT_STROKE_WIDTH,
|
|
6
|
+
TRANSITION_STYLE,
|
|
7
|
+
} from "./constants";
|
|
8
|
+
import { ORGAN_COMPONENTS } from "./svg";
|
|
9
|
+
import type { OrganConfig, OrganId, OrganStyle, StrictColorPalette } from "./types";
|
|
10
|
+
|
|
11
|
+
export interface OrganSvgWrapperProps {
|
|
12
|
+
/** Organ identifier */
|
|
13
|
+
organId: OrganId;
|
|
14
|
+
/** Organ configuration */
|
|
15
|
+
config?: OrganConfig;
|
|
16
|
+
/** Color palette to use for this organ (with guaranteed alpha values) */
|
|
17
|
+
colorPalette: StrictColorPalette;
|
|
18
|
+
/** Whether the organ is currently hovered */
|
|
19
|
+
isHovered: boolean;
|
|
20
|
+
/** Whether the organ is currently selected */
|
|
21
|
+
isSelected: boolean;
|
|
22
|
+
/** Mouse enter handler */
|
|
23
|
+
onMouseEnter: () => void;
|
|
24
|
+
/** Mouse leave handler */
|
|
25
|
+
onMouseLeave: () => void;
|
|
26
|
+
/** Click handler */
|
|
27
|
+
onClick: () => void;
|
|
28
|
+
/** X position in parent coordinate system */
|
|
29
|
+
x: number;
|
|
30
|
+
/** Y position in parent coordinate system */
|
|
31
|
+
y: number;
|
|
32
|
+
/** Width of the organ SVG viewport */
|
|
33
|
+
width: number;
|
|
34
|
+
/** Height of the organ SVG viewport */
|
|
35
|
+
height: number;
|
|
36
|
+
/** ViewBox for the organ SVG (local coordinate system) */
|
|
37
|
+
viewBox: string;
|
|
38
|
+
/** Show red outline around this organ */
|
|
39
|
+
showOutline?: boolean;
|
|
40
|
+
/** Whether the organ is visible (controls opacity for animation) */
|
|
41
|
+
isVisible?: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Merge multiple OrganStyle objects with priority (later objects override earlier)
|
|
46
|
+
*/
|
|
47
|
+
function mergeStyles(...styles: (OrganStyle | undefined)[]): OrganStyle {
|
|
48
|
+
const result: OrganStyle = {};
|
|
49
|
+
for (const style of styles) {
|
|
50
|
+
if (style) {
|
|
51
|
+
if (style.fill !== undefined) result.fill = style.fill;
|
|
52
|
+
if (style.stroke !== undefined) result.stroke = style.stroke;
|
|
53
|
+
if (style.strokeWidth !== undefined) result.strokeWidth = style.strokeWidth;
|
|
54
|
+
if (style.opacity !== undefined) result.opacity = style.opacity;
|
|
55
|
+
if (style.visible !== undefined) result.visible = style.visible;
|
|
56
|
+
if (style.blur !== undefined) result.blur = style.blur;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return result;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Wrapper component for organ SVG with interaction handling
|
|
64
|
+
*/
|
|
65
|
+
export function OrganSvg({
|
|
66
|
+
organId,
|
|
67
|
+
config,
|
|
68
|
+
colorPalette,
|
|
69
|
+
isHovered,
|
|
70
|
+
isSelected,
|
|
71
|
+
onMouseEnter,
|
|
72
|
+
onMouseLeave,
|
|
73
|
+
onClick,
|
|
74
|
+
x,
|
|
75
|
+
y,
|
|
76
|
+
width,
|
|
77
|
+
height,
|
|
78
|
+
viewBox,
|
|
79
|
+
showOutline = false,
|
|
80
|
+
isVisible = true,
|
|
81
|
+
}: OrganSvgWrapperProps) {
|
|
82
|
+
const OrganComponent = ORGAN_COMPONENTS[organId];
|
|
83
|
+
const isActive = isHovered || isSelected;
|
|
84
|
+
|
|
85
|
+
// Compute the final style based on state and colorScheme
|
|
86
|
+
const computedStyle = useMemo((): OrganStyle => {
|
|
87
|
+
const userStyle = config?.style;
|
|
88
|
+
const strokeStyle = {
|
|
89
|
+
stroke: DEFAULT_STROKE_COLOR,
|
|
90
|
+
strokeWidth: showOutline ? DEFAULT_STROKE_WIDTH : 0,
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
return mergeStyles(userStyle, strokeStyle);
|
|
94
|
+
}, [config?.style, showOutline]);
|
|
95
|
+
|
|
96
|
+
const svgStyle: CSSProperties = {
|
|
97
|
+
position: "absolute",
|
|
98
|
+
left: x,
|
|
99
|
+
top: y,
|
|
100
|
+
transition: `${TRANSITION_STYLE}, visibility 0s`,
|
|
101
|
+
zIndex: isSelected ? 1 : 0,
|
|
102
|
+
};
|
|
103
|
+
const filter = isActive ? "blur(1px)" : undefined;
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<svg
|
|
107
|
+
width={width}
|
|
108
|
+
height={height}
|
|
109
|
+
viewBox={viewBox}
|
|
110
|
+
style={svgStyle}
|
|
111
|
+
filter={filter}
|
|
112
|
+
aria-label={organId}
|
|
113
|
+
overflow="visible"
|
|
114
|
+
pointerEvents="none"
|
|
115
|
+
opacity={isVisible ? 1 : 0}
|
|
116
|
+
visibility={isVisible ? "visible" : "hidden"}
|
|
117
|
+
>
|
|
118
|
+
<title>{organId}</title>
|
|
119
|
+
<g
|
|
120
|
+
role="menuitem"
|
|
121
|
+
onMouseEnter={onMouseEnter}
|
|
122
|
+
onMouseLeave={onMouseLeave}
|
|
123
|
+
onClick={onClick}
|
|
124
|
+
onKeyDown={(e) => {
|
|
125
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
126
|
+
e.preventDefault();
|
|
127
|
+
onClick();
|
|
128
|
+
}
|
|
129
|
+
}}
|
|
130
|
+
cursor="pointer"
|
|
131
|
+
pointerEvents="auto"
|
|
132
|
+
>
|
|
133
|
+
<OrganComponent
|
|
134
|
+
isActive={isActive}
|
|
135
|
+
style={computedStyle}
|
|
136
|
+
colorScale={colorPalette[config?.colorName ?? DEFAULT_COLOR_NAME]}
|
|
137
|
+
/>
|
|
138
|
+
</g>
|
|
139
|
+
</svg>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { DEFAULT_COLOR_PALETTE } from "../constants";
|
|
4
|
+
import { createStrictColorPalette } from "../lib";
|
|
5
|
+
import { OrganSvg } from "../OrganSvg";
|
|
6
|
+
|
|
7
|
+
const strictColorPalette = createStrictColorPalette(DEFAULT_COLOR_PALETTE);
|
|
8
|
+
|
|
9
|
+
describe("COLOR_PALETTES", () => {
|
|
10
|
+
it("should have blue, yellow, and gray color schemes", () => {
|
|
11
|
+
expect(DEFAULT_COLOR_PALETTE.blue).toBeDefined();
|
|
12
|
+
expect(DEFAULT_COLOR_PALETTE.yellow).toBeDefined();
|
|
13
|
+
expect(DEFAULT_COLOR_PALETTE.gray).toBeDefined();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("each palette should have 100, 200, and 300 colors", () => {
|
|
17
|
+
for (const scheme of ["blue", "yellow", "gray"] as const) {
|
|
18
|
+
const palette = DEFAULT_COLOR_PALETTE[scheme];
|
|
19
|
+
expect(palette[100]).toBeDefined();
|
|
20
|
+
expect(palette[200]).toBeDefined();
|
|
21
|
+
expect(palette[300]).toBeDefined();
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe("OrganSvg", () => {
|
|
27
|
+
const defaultProps = {
|
|
28
|
+
organId: "heart" as const,
|
|
29
|
+
isHovered: false,
|
|
30
|
+
isSelected: false,
|
|
31
|
+
onMouseEnter: () => {},
|
|
32
|
+
onMouseLeave: () => {},
|
|
33
|
+
onClick: () => {},
|
|
34
|
+
x: 0,
|
|
35
|
+
y: 0,
|
|
36
|
+
width: 22,
|
|
37
|
+
height: 23,
|
|
38
|
+
viewBox: "0 0 22 23",
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
it("should render with default blue color scheme when no colorScheme is provided", () => {
|
|
42
|
+
render(
|
|
43
|
+
<svg>
|
|
44
|
+
<title>Heart</title>
|
|
45
|
+
<OrganSvg colorPalette={strictColorPalette} {...defaultProps} />
|
|
46
|
+
</svg>,
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
const organElement = screen.getByLabelText("heart");
|
|
50
|
+
expect(organElement).toBeInTheDocument();
|
|
51
|
+
|
|
52
|
+
// Check that the path has the blue base color
|
|
53
|
+
const path = organElement.querySelector("path");
|
|
54
|
+
expect(path).toHaveAttribute("fill", DEFAULT_COLOR_PALETTE.blue[200]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("should use yellow color scheme when specified", () => {
|
|
58
|
+
render(
|
|
59
|
+
<svg>
|
|
60
|
+
<title>Heart</title>
|
|
61
|
+
<OrganSvg
|
|
62
|
+
colorPalette={strictColorPalette}
|
|
63
|
+
{...defaultProps}
|
|
64
|
+
config={{ id: "heart", colorName: "yellow" }}
|
|
65
|
+
/>
|
|
66
|
+
</svg>,
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
const organElement = screen.getByLabelText("heart");
|
|
70
|
+
const path = organElement.querySelector("path");
|
|
71
|
+
expect(path).toHaveAttribute("fill", DEFAULT_COLOR_PALETTE.yellow[200]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("should use gray color scheme when specified", () => {
|
|
75
|
+
render(
|
|
76
|
+
<svg>
|
|
77
|
+
<title>Heart</title>
|
|
78
|
+
<OrganSvg
|
|
79
|
+
colorPalette={strictColorPalette}
|
|
80
|
+
{...defaultProps}
|
|
81
|
+
config={{ id: "heart", colorName: "gray" }}
|
|
82
|
+
/>
|
|
83
|
+
</svg>,
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
const organElement = screen.getByLabelText("heart");
|
|
87
|
+
const path = organElement.querySelector("path");
|
|
88
|
+
expect(path).toHaveAttribute("fill", DEFAULT_COLOR_PALETTE.gray[200]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("should use hover color when isHovered is true", () => {
|
|
92
|
+
render(
|
|
93
|
+
<svg>
|
|
94
|
+
<title>Heart</title>
|
|
95
|
+
<OrganSvg
|
|
96
|
+
colorPalette={strictColorPalette}
|
|
97
|
+
{...defaultProps}
|
|
98
|
+
isHovered={true}
|
|
99
|
+
config={{ id: "heart", colorName: "blue" }}
|
|
100
|
+
/>
|
|
101
|
+
</svg>,
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
const organElement = screen.getByLabelText("heart");
|
|
105
|
+
const path = organElement.querySelector("path");
|
|
106
|
+
expect(path).toHaveAttribute("fill", DEFAULT_COLOR_PALETTE.blue[300]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("should use active color when isSelected is true", () => {
|
|
110
|
+
render(
|
|
111
|
+
<svg>
|
|
112
|
+
<title>Heart</title>
|
|
113
|
+
<OrganSvg
|
|
114
|
+
colorPalette={strictColorPalette}
|
|
115
|
+
{...defaultProps}
|
|
116
|
+
isSelected={true}
|
|
117
|
+
config={{ id: "heart", colorName: "yellow" }}
|
|
118
|
+
/>
|
|
119
|
+
</svg>,
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
const organElement = screen.getByLabelText("heart");
|
|
123
|
+
const path = organElement.querySelector("path");
|
|
124
|
+
expect(path).toHaveAttribute("fill", DEFAULT_COLOR_PALETTE.yellow[300]);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("should allow style override via config.style", () => {
|
|
128
|
+
const customFill = "#ff0000";
|
|
129
|
+
render(
|
|
130
|
+
<svg>
|
|
131
|
+
<title>Heart</title>
|
|
132
|
+
<OrganSvg
|
|
133
|
+
colorPalette={strictColorPalette}
|
|
134
|
+
{...defaultProps}
|
|
135
|
+
config={{ id: "heart", colorName: "blue", style: { fill: customFill } }}
|
|
136
|
+
/>
|
|
137
|
+
</svg>,
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
const organElement = screen.getByLabelText("heart");
|
|
141
|
+
const path = organElement.querySelector("path");
|
|
142
|
+
// When not hovered/selected, user style is applied after base default
|
|
143
|
+
// But since it's merged, fill should still be customFill
|
|
144
|
+
expect(path).toHaveAttribute("fill", customFill);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { DEFAULT_COLOR_PALETTE } from "../constants";
|
|
3
|
+
import { createStrictColorPalette } from "../lib/createStrictColorPalette";
|
|
4
|
+
import type { ColorPalette, StrictColorPalette } from "../types";
|
|
5
|
+
|
|
6
|
+
describe("createStrictColorPalette", () => {
|
|
7
|
+
describe("when palette is undefined", () => {
|
|
8
|
+
it("should return StrictColorPalette with all colors from DEFAULT_COLOR_PALETTE", () => {
|
|
9
|
+
const result = createStrictColorPalette(undefined);
|
|
10
|
+
|
|
11
|
+
expect(result.blue[100]).toBe(DEFAULT_COLOR_PALETTE.blue[100]);
|
|
12
|
+
expect(result.blue[200]).toBe(DEFAULT_COLOR_PALETTE.blue[200]);
|
|
13
|
+
expect(result.blue[300]).toBe(DEFAULT_COLOR_PALETTE.blue[300]);
|
|
14
|
+
expect(result.yellow[100]).toBe(DEFAULT_COLOR_PALETTE.yellow[100]);
|
|
15
|
+
expect(result.gray[100]).toBe(DEFAULT_COLOR_PALETTE.gray[100]);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("should generate alpha colors using color-mix", () => {
|
|
19
|
+
const result = createStrictColorPalette(undefined);
|
|
20
|
+
|
|
21
|
+
expect(result.blue.alpha[100]).toBe(
|
|
22
|
+
`color-mix(in srgb, ${DEFAULT_COLOR_PALETTE.blue[100]} 50%, transparent)`,
|
|
23
|
+
);
|
|
24
|
+
expect(result.blue.alpha[200]).toBe(
|
|
25
|
+
`color-mix(in srgb, ${DEFAULT_COLOR_PALETTE.blue[200]} 50%, transparent)`,
|
|
26
|
+
);
|
|
27
|
+
expect(result.blue.alpha[300]).toBe(
|
|
28
|
+
`color-mix(in srgb, ${DEFAULT_COLOR_PALETTE.blue[300]} 50%, transparent)`,
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("when palette is empty object", () => {
|
|
34
|
+
it("should return StrictColorPalette with all colors from DEFAULT_COLOR_PALETTE", () => {
|
|
35
|
+
const result = createStrictColorPalette({});
|
|
36
|
+
|
|
37
|
+
expect(result.blue[100]).toBe(DEFAULT_COLOR_PALETTE.blue[100]);
|
|
38
|
+
expect(result.yellow[200]).toBe(DEFAULT_COLOR_PALETTE.yellow[200]);
|
|
39
|
+
expect(result.gray[300]).toBe(DEFAULT_COLOR_PALETTE.gray[300]);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("when palette has partial colors", () => {
|
|
44
|
+
it("should use provided colors and fallback to DEFAULT for missing ones", () => {
|
|
45
|
+
const customBlue = {
|
|
46
|
+
100: "#custom100",
|
|
47
|
+
200: "#custom200",
|
|
48
|
+
300: "#custom300",
|
|
49
|
+
};
|
|
50
|
+
const palette: Partial<ColorPalette> = {
|
|
51
|
+
blue: customBlue,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const result = createStrictColorPalette(palette);
|
|
55
|
+
|
|
56
|
+
// Custom blue colors should be used
|
|
57
|
+
expect(result.blue[100]).toBe("#custom100");
|
|
58
|
+
expect(result.blue[200]).toBe("#custom200");
|
|
59
|
+
expect(result.blue[300]).toBe("#custom300");
|
|
60
|
+
|
|
61
|
+
// Missing yellow and gray should fallback to DEFAULT
|
|
62
|
+
expect(result.yellow[100]).toBe(DEFAULT_COLOR_PALETTE.yellow[100]);
|
|
63
|
+
expect(result.gray[100]).toBe(DEFAULT_COLOR_PALETTE.gray[100]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("should generate alpha for custom colors using color-mix", () => {
|
|
67
|
+
const customBlue = {
|
|
68
|
+
100: "#custom100",
|
|
69
|
+
200: "#custom200",
|
|
70
|
+
300: "#custom300",
|
|
71
|
+
};
|
|
72
|
+
const palette: Partial<ColorPalette> = {
|
|
73
|
+
blue: customBlue,
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const result = createStrictColorPalette(palette);
|
|
77
|
+
|
|
78
|
+
expect(result.blue.alpha[100]).toBe("color-mix(in srgb, #custom100 50%, transparent)");
|
|
79
|
+
expect(result.blue.alpha[200]).toBe("color-mix(in srgb, #custom200 50%, transparent)");
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("when palette has alpha colors", () => {
|
|
84
|
+
it("should use provided alpha colors instead of generating", () => {
|
|
85
|
+
const customAlpha = {
|
|
86
|
+
100: "#alpha100",
|
|
87
|
+
200: "#alpha200",
|
|
88
|
+
300: "#alpha300",
|
|
89
|
+
};
|
|
90
|
+
const palette: Partial<ColorPalette> = {
|
|
91
|
+
blue: {
|
|
92
|
+
100: "#blue100",
|
|
93
|
+
200: "#blue200",
|
|
94
|
+
300: "#blue300",
|
|
95
|
+
alpha: customAlpha,
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const result = createStrictColorPalette(palette);
|
|
100
|
+
|
|
101
|
+
expect(result.blue.alpha[100]).toBe("#alpha100");
|
|
102
|
+
expect(result.blue.alpha[200]).toBe("#alpha200");
|
|
103
|
+
expect(result.blue.alpha[300]).toBe("#alpha300");
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe("return type validation", () => {
|
|
108
|
+
it("should return a valid StrictColorPalette", () => {
|
|
109
|
+
const result = createStrictColorPalette(undefined);
|
|
110
|
+
|
|
111
|
+
// Check that all required properties exist
|
|
112
|
+
const colorNames = ["blue", "yellow", "gray"] as const;
|
|
113
|
+
const steps = [100, 200, 300] as const;
|
|
114
|
+
|
|
115
|
+
for (const colorName of colorNames) {
|
|
116
|
+
expect(result[colorName]).toBeDefined();
|
|
117
|
+
|
|
118
|
+
for (const step of steps) {
|
|
119
|
+
expect(result[colorName][step]).toBeDefined();
|
|
120
|
+
expect(typeof result[colorName][step]).toBe("string");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
expect(result[colorName].alpha).toBeDefined();
|
|
124
|
+
for (const step of steps) {
|
|
125
|
+
expect(result[colorName].alpha[step]).toBeDefined();
|
|
126
|
+
expect(typeof result[colorName].alpha[step]).toBe("string");
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Type check - this should compile without errors
|
|
131
|
+
const _typeCheck: StrictColorPalette = result;
|
|
132
|
+
expect(_typeCheck).toBeDefined();
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
});
|