@reshape-biotech/design-system 1.2.5 → 1.2.7

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.
Files changed (175) hide show
  1. package/README.md +3 -1
  2. package/dist/app.css +97 -97
  3. package/dist/components/activity/Activity.stories.svelte +104 -104
  4. package/dist/components/activity/Activity.svelte +112 -112
  5. package/dist/components/avatar/Avatar.stories.svelte +23 -23
  6. package/dist/components/avatar/Avatar.svelte +54 -54
  7. package/dist/components/banner/Banner.stories.svelte +105 -105
  8. package/dist/components/banner/Banner.svelte +42 -42
  9. package/dist/components/button/Button.stories.svelte +61 -61
  10. package/dist/components/button/Button.svelte +95 -95
  11. package/dist/components/card/Card.stories.svelte +112 -112
  12. package/dist/components/card/Card.svelte +18 -18
  13. package/dist/components/checkbox/Checkbox.stories.svelte +8 -8
  14. package/dist/components/checkbox/Checkbox.svelte +17 -17
  15. package/dist/components/collapsible/Collapsible.stories.svelte +26 -26
  16. package/dist/components/collapsible/components/collapsible-content.svelte +20 -20
  17. package/dist/components/collapsible/components/collapsible-trigger.svelte +12 -12
  18. package/dist/components/combobox/Combobox.stories.svelte +412 -412
  19. package/dist/components/combobox/components/combobox-add.svelte +8 -8
  20. package/dist/components/combobox/components/combobox-content.svelte +39 -39
  21. package/dist/components/combobox/components/combobox-indicator.svelte +1 -1
  22. package/dist/components/datepicker/DatePicker.stories.svelte +196 -196
  23. package/dist/components/datepicker/DatePicker.svelte +173 -173
  24. package/dist/components/divider/Divider.stories.svelte +7 -7
  25. package/dist/components/divider/Divider.svelte +9 -9
  26. package/dist/components/drawer/Drawer.stories.svelte +51 -51
  27. package/dist/components/drawer/Drawer.svelte +33 -33
  28. package/dist/components/drawer/DrawerLabel.svelte +10 -10
  29. package/dist/components/dropdown/Dropdown.stories.svelte +210 -210
  30. package/dist/components/dropdown/Dropdown.svelte +57 -57
  31. package/dist/components/dropdown/components/DropdownContent.svelte +16 -16
  32. package/dist/components/dropdown/components/DropdownMenu.svelte +10 -10
  33. package/dist/components/dropdown/components/DropdownTrigger.svelte +37 -37
  34. package/dist/components/dropdown/components/OutlinedButton.svelte +9 -9
  35. package/dist/components/empty-content/EmptyContent.stories.svelte +38 -38
  36. package/dist/components/empty-content/EmptyContent.svelte +12 -12
  37. package/dist/components/graphs/bar-chart/BarChart.stories.svelte +91 -91
  38. package/dist/components/graphs/bar-chart/BarChart.svelte +147 -147
  39. package/dist/components/graphs/bar-chart/StackedBarChart.stories.svelte +57 -57
  40. package/dist/components/graphs/bar-chart/StackedBarChart.svelte +198 -199
  41. package/dist/components/graphs/chart/Chart.stories.svelte +96 -96
  42. package/dist/components/graphs/chart/Chart.svelte +207 -207
  43. package/dist/components/graphs/line/LineChart.stories.svelte +138 -138
  44. package/dist/components/graphs/line/LineChart.svelte +140 -142
  45. package/dist/components/graphs/matrix/Matrix.stories.svelte +117 -117
  46. package/dist/components/graphs/matrix/Matrix.svelte +141 -141
  47. package/dist/components/graphs/multiline/MultiLineChart.stories.svelte +168 -168
  48. package/dist/components/graphs/multiline/MultiLineChart.svelte +236 -236
  49. package/dist/components/graphs/scatterplot/Scatterplot.stories.svelte +84 -84
  50. package/dist/components/graphs/scatterplot/Scatterplot.svelte +302 -302
  51. package/dist/components/graphs/utils/duration.d.ts +1 -0
  52. package/dist/components/graphs/utils/duration.js +33 -0
  53. package/dist/components/graphs/utils/tooltipFormatter.js +1 -1
  54. package/dist/components/icon-button/IconButton.stories.svelte +64 -64
  55. package/dist/components/icon-button/IconButton.svelte +88 -88
  56. package/dist/components/icons/AnalysisIcon.stories.svelte +18 -18
  57. package/dist/components/icons/AnalysisIcon.svelte +96 -96
  58. package/dist/components/icons/Icon.stories.svelte +111 -111
  59. package/dist/components/icons/Icon.svelte +17 -17
  60. package/dist/components/icons/PrincipalIcon.svelte +59 -59
  61. package/dist/components/icons/custom/Halo.svelte +31 -31
  62. package/dist/components/icons/custom/Well.svelte +27 -27
  63. package/dist/components/icons/index.d.ts +3 -2
  64. package/dist/components/icons/index.js +3 -1
  65. package/dist/components/image/Image.svelte +42 -42
  66. package/dist/components/input/Input.stories.svelte +55 -55
  67. package/dist/components/input/Input.svelte +121 -121
  68. package/dist/components/label/Label.stories.svelte +18 -18
  69. package/dist/components/label/Label.svelte +11 -11
  70. package/dist/components/list/List.stories.svelte +84 -84
  71. package/dist/components/list/List.svelte +20 -20
  72. package/dist/components/logo/Logo.stories.svelte +15 -15
  73. package/dist/components/logo/Logo.svelte +30 -30
  74. package/dist/components/manual-cfu-counter/ManualCFUCounter.stories.svelte +102 -102
  75. package/dist/components/manual-cfu-counter/ManualCFUCounter.svelte +557 -565
  76. package/dist/components/manual-cfu-counter/test/ManualCFUCounterTestWrapper.svelte +11 -11
  77. package/dist/components/markdown/Markdown.stories.svelte +10 -10
  78. package/dist/components/markdown/Markdown.svelte +6 -6
  79. package/dist/components/modal/Modal.stories.svelte +29 -29
  80. package/dist/components/modal/Modal.svelte +71 -71
  81. package/dist/components/multi-cfu-counter/MultiCFUCounter.stories.svelte +215 -0
  82. package/dist/components/multi-cfu-counter/MultiCFUCounter.stories.svelte.d.ts +3 -0
  83. package/dist/components/multi-cfu-counter/MultiCFUCounter.svelte +662 -0
  84. package/dist/components/multi-cfu-counter/MultiCFUCounter.svelte.d.ts +32 -0
  85. package/dist/components/multi-cfu-counter/index.d.ts +1 -0
  86. package/dist/components/multi-cfu-counter/index.js +1 -0
  87. package/dist/components/multi-cfu-counter/test/MultiCFUCounterTestWrapper.svelte +28 -0
  88. package/dist/components/multi-cfu-counter/test/MultiCFUCounterTestWrapper.svelte.d.ts +20 -0
  89. package/dist/components/notification-popup/NotificationPopup.stories.svelte +18 -18
  90. package/dist/components/notification-popup/NotificationPopup.svelte +26 -26
  91. package/dist/components/notifications/Notifications.stories.svelte +101 -101
  92. package/dist/components/notifications/Notifications.svelte +9 -9
  93. package/dist/components/pill/Pill.stories.svelte +8 -8
  94. package/dist/components/pill/Pill.svelte +27 -27
  95. package/dist/components/progress-circle/ProgressCircle.stories.svelte +8 -8
  96. package/dist/components/progress-circle/ProgressCircle.svelte +54 -54
  97. package/dist/components/required-status-indicator/RequiredStatusIndicator.stories.svelte +18 -18
  98. package/dist/components/required-status-indicator/RequiredStatusIndicator.svelte +14 -14
  99. package/dist/components/segmented-control-buttons/ControlButton.svelte +36 -36
  100. package/dist/components/segmented-control-buttons/SegmentedControlButtons.stories.svelte +35 -35
  101. package/dist/components/segmented-control-buttons/SegmentedControlButtons.svelte +13 -13
  102. package/dist/components/select/Select.stories.svelte +77 -77
  103. package/dist/components/select/Select.svelte +114 -114
  104. package/dist/components/select-new/Select.stories.svelte +188 -188
  105. package/dist/components/select-new/components/Group.svelte +17 -17
  106. package/dist/components/select-new/components/MultiSelectTrigger.svelte +57 -57
  107. package/dist/components/select-new/components/SelectContent.svelte +22 -22
  108. package/dist/components/select-new/components/SelectGroupHeading.svelte +10 -10
  109. package/dist/components/select-new/components/SelectItem.svelte +25 -25
  110. package/dist/components/select-new/components/SelectTrigger.svelte +38 -38
  111. package/dist/components/sjsf-wrappers/SjsfNumberInputWrapper.svelte +76 -76
  112. package/dist/components/sjsf-wrappers/SjsfNumberInputWrapper.svelte.d.ts +1 -1
  113. package/dist/components/sjsf-wrappers/SjsfTextInputWrapper.svelte +53 -53
  114. package/dist/components/sjsf-wrappers/SjsfTextInputWrapper.svelte.d.ts +1 -1
  115. package/dist/components/sjsf-wrappers/sjsfCustomTheme.js +1 -1
  116. package/dist/components/skeleton-loader/SkeletonLoader.stories.svelte +32 -32
  117. package/dist/components/skeleton-loader/SkeletonLoader.svelte +10 -10
  118. package/dist/components/skeleton-loader/StatcardSkeleton.svelte +9 -9
  119. package/dist/components/skeleton-loader/components/Skeleton.svelte +7 -7
  120. package/dist/components/skeleton-loader/components/SkeletonImage.svelte +12 -12
  121. package/dist/components/slider/Slider.stories.svelte +23 -23
  122. package/dist/components/slider/Slider.svelte +107 -107
  123. package/dist/components/spinner/Spinner.stories.svelte +8 -8
  124. package/dist/components/spinner/Spinner.svelte +18 -18
  125. package/dist/components/stat-card/StatCard.stories.svelte +26 -26
  126. package/dist/components/stat-card/StatCard.svelte +128 -128
  127. package/dist/components/status-badge/StatusBadge.stories.svelte +365 -365
  128. package/dist/components/status-badge/StatusBadge.svelte +54 -54
  129. package/dist/components/stepper/Stepper.stories.svelte +219 -219
  130. package/dist/components/stepper/components/stepper-root.svelte +12 -12
  131. package/dist/components/stepper/components/stepper-step.svelte +83 -83
  132. package/dist/components/table/Table.stories.svelte +87 -87
  133. package/dist/components/table/Table.svelte +32 -32
  134. package/dist/components/table/components/TBody.svelte +7 -7
  135. package/dist/components/table/components/THead.svelte +7 -7
  136. package/dist/components/table/components/Td.svelte +8 -8
  137. package/dist/components/table/components/Th.svelte +8 -8
  138. package/dist/components/table/components/Tr.svelte +11 -11
  139. package/dist/components/tabs/Tabs.stories.svelte +20 -20
  140. package/dist/components/tabs/Tabs.svelte +8 -8
  141. package/dist/components/tabs/components/Content.svelte +8 -8
  142. package/dist/components/tabs/components/Tab.svelte +14 -14
  143. package/dist/components/tabs/components/Tabs.svelte +7 -7
  144. package/dist/components/tag/Tag.stories.svelte +57 -57
  145. package/dist/components/tag/Tag.svelte +95 -95
  146. package/dist/components/textarea/Textarea.stories.svelte +70 -70
  147. package/dist/components/textarea/Textarea.svelte +76 -76
  148. package/dist/components/toast/Toast.stories.svelte +204 -204
  149. package/dist/components/toast/Toast.svelte +53 -53
  150. package/dist/components/toggle/Toggle.stories.svelte +9 -9
  151. package/dist/components/toggle/Toggle.svelte +53 -53
  152. package/dist/components/toggle-icon-button/ToggleIconButton.stories.svelte +152 -152
  153. package/dist/components/toggle-icon-button/ToggleIconButton.svelte +99 -99
  154. package/dist/components/tooltip/Tooltip.stories.svelte +105 -105
  155. package/dist/components/tooltip/Tooltip.svelte +26 -26
  156. package/dist/fonts/index.js +1 -1
  157. package/dist/index.d.ts +4 -1
  158. package/dist/index.js +5 -1
  159. package/dist/notifications.d.ts +1 -4
  160. package/dist/notifications.js +1 -1
  161. package/dist/styles.d.ts +1 -0
  162. package/dist/styles.js +4 -0
  163. package/dist/tailwind-safelist.js +406 -398
  164. package/dist/tailwind.preset.d.ts +4 -0
  165. package/dist/tailwind.preset.js +10 -10
  166. package/dist/tokens/colors.d.ts +246 -0
  167. package/dist/tokens/colors.js +139 -0
  168. package/dist/tokens/index.d.ts +3 -0
  169. package/dist/tokens/index.js +5 -0
  170. package/dist/tokens/typography.d.ts +48 -0
  171. package/dist/tokens/typography.js +48 -0
  172. package/dist/tokens.d.ts +16 -252
  173. package/dist/tokens.js +33 -164
  174. package/dist/types/fonts.d.ts +2 -2
  175. package/package.json +398 -78
@@ -0,0 +1,662 @@
1
+ <script lang="ts">
2
+ import { onMount } from 'svelte';
3
+ import { Button } from '../button';
4
+ import { textColor } from '../../tokens';
5
+ import { Icon, type IconColor } from '../icons';
6
+ import IconButton from '../icon-button/IconButton.svelte';
7
+ import Divider from '../divider/Divider.svelte';
8
+
9
+ const BASE_IMAGE_SIZE = 464;
10
+ const BASE_MARKER_SIZE = 8;
11
+ const BASE_MARKER_FONT_SIZE = 8;
12
+ const MAX_ZOOM = 10;
13
+ const MIN_ZOOM = 1;
14
+ const ZOOM_STEP = 0.001;
15
+ const MARKER_COLOR = textColor['icon-blue'];
16
+ const TEXT_COLOR = textColor['primary-inverse'];
17
+ const DRAG_THRESHOLD = 5;
18
+
19
+ function getMarkerColorHex(semanticColor: string): string {
20
+ switch (semanticColor) {
21
+ case 'icon-blue':
22
+ return textColor['icon-blue'];
23
+ case 'icon-orange':
24
+ return textColor['icon-orange'];
25
+ case 'icon-pink':
26
+ return textColor['icon-pink'];
27
+ case 'icon-lime':
28
+ return textColor['icon-lime'];
29
+ default:
30
+ return textColor['icon-blue'];
31
+ }
32
+ }
33
+
34
+ interface ConfiguratedMark {
35
+ x: number;
36
+ y: number;
37
+ configIndex: number;
38
+ color: string;
39
+ id?: string;
40
+ }
41
+
42
+ interface Props {
43
+ imageUrl: string;
44
+ marks?: Array<{ x: number; y: number }>;
45
+ onclick?: (event: MouseEvent, marks: Array<{ x: number; y: number }>) => void;
46
+ onMarksChange?: (marks: Array<{ x: number; y: number }>) => void;
47
+ disabled?: boolean;
48
+ hideMarkers?: boolean;
49
+ allMarks?: ConfiguratedMark[];
50
+ activeMarkerColor?: string;
51
+ activeMarkerName?: string;
52
+ editableConfigIndex?: number | null;
53
+ showMultiConfig?: boolean;
54
+ }
55
+
56
+ let {
57
+ imageUrl,
58
+ onclick,
59
+ onMarksChange,
60
+ disabled = false,
61
+ hideMarkers = false,
62
+ marks = $bindable([]),
63
+ allMarks = [],
64
+ activeMarkerColor = MARKER_COLOR,
65
+ activeMarkerName = '',
66
+ editableConfigIndex = null,
67
+ showMultiConfig = false,
68
+ }: Props = $props();
69
+
70
+ let previousConfigIndex = $state<number | null>(null);
71
+
72
+ let resolvedActiveMarkerColor = $derived(() => {
73
+ if (!activeMarkerColor) return MARKER_COLOR;
74
+
75
+ // If it starts with #, it's already a hex color
76
+ if (activeMarkerColor.startsWith('#')) {
77
+ return activeMarkerColor;
78
+ }
79
+
80
+ return getMarkerColorHex(activeMarkerColor);
81
+ });
82
+
83
+ let svgElement: SVGSVGElement;
84
+ let viewport: SVGGraphicsElement;
85
+ let dotsGroup: SVGElement;
86
+ let container: HTMLDivElement;
87
+
88
+ let containerW = BASE_IMAGE_SIZE;
89
+ let containerH = BASE_IMAGE_SIZE;
90
+
91
+ let transform = $state({ x: 0, y: 0, scale: 1 });
92
+
93
+ let panningState = $state<null | 'ready' | 'active'>(null);
94
+ let startPoint = $state({ x: 0, y: 0 });
95
+ let isShiftPressed = $state(false);
96
+ let resizeObserver: ResizeObserver;
97
+
98
+ let imageAspectRatio = $state(1);
99
+ let imageDisplayWidth = $state(0);
100
+ let imageDisplayHeight = $state(0);
101
+
102
+ function handleResize(entries: ResizeObserverEntry[]) {
103
+ const entry = entries[0];
104
+ if (entry) {
105
+ const oldWidth = imageDisplayWidth || containerW;
106
+ const oldHeight = imageDisplayHeight || containerH;
107
+
108
+ containerW = entry.contentRect.width;
109
+ containerH = entry.contentRect.height;
110
+
111
+ if (oldWidth > 0 && oldHeight > 0 && marks.length > 0) {
112
+ marks = marks.map((mark) => ({
113
+ x: (mark.x / oldWidth) * containerW,
114
+ y: (mark.y / oldHeight) * containerH,
115
+ }));
116
+ }
117
+
118
+ updateImageDimensions();
119
+ }
120
+ }
121
+
122
+ function onImageLoad() {
123
+ if (container) {
124
+ containerW = container.clientWidth;
125
+ containerH = container.clientHeight;
126
+
127
+ const img = new Image();
128
+ img.src = imageUrl;
129
+ img.onload = () => {
130
+ imageAspectRatio = img.naturalWidth / img.naturalHeight;
131
+ updateImageDimensions();
132
+ };
133
+
134
+ if (img.complete && img.naturalWidth) {
135
+ imageAspectRatio = img.naturalWidth / img.naturalHeight;
136
+ updateImageDimensions();
137
+ }
138
+ }
139
+ }
140
+
141
+ function updateImageDimensions() {
142
+ const containerRatio = containerW / containerH;
143
+
144
+ if (containerRatio > imageAspectRatio) {
145
+ imageDisplayHeight = containerH;
146
+ imageDisplayWidth = containerH * imageAspectRatio;
147
+ } else {
148
+ imageDisplayWidth = containerW;
149
+ imageDisplayHeight = containerW / imageAspectRatio;
150
+ }
151
+
152
+ updateTransform();
153
+ }
154
+
155
+ function clampTransform() {
156
+ const maxX = 0;
157
+ const maxY = 0;
158
+ // Prevent division by zero or unexpected behavior if container dimensions aren't ready
159
+ const safeContainerW = containerW || 1;
160
+ const safeContainerH = containerH || 1;
161
+ const minX = safeContainerW * (1 - transform.scale);
162
+ const minY = safeContainerH * (1 - transform.scale);
163
+
164
+ if (transform.scale <= 1) {
165
+ transform.scale = 1;
166
+ transform.x = 0;
167
+ transform.y = 0;
168
+ } else {
169
+ transform.x = Math.max(minX, Math.min(maxX, transform.x));
170
+ transform.y = Math.max(minY, Math.min(maxY, transform.y));
171
+ }
172
+ }
173
+
174
+ function updateTransform() {
175
+ if (!viewport) return;
176
+
177
+ viewport.setAttribute(
178
+ 'transform',
179
+ `translate(${transform.x}, ${transform.y}) scale(${transform.scale})`
180
+ );
181
+
182
+ const imageElement = viewport.querySelector('image');
183
+ if (imageElement) {
184
+ imageElement.setAttribute('width', String(imageDisplayWidth));
185
+ imageElement.setAttribute('height', String(imageDisplayHeight));
186
+
187
+ // center the image in the container
188
+ const offsetX = (containerW - imageDisplayWidth) / 2;
189
+ const offsetY = (containerH - imageDisplayHeight) / 2;
190
+
191
+ imageElement.setAttribute('x', String(offsetX));
192
+ imageElement.setAttribute('y', String(offsetY));
193
+ }
194
+
195
+ renderMarkers();
196
+ }
197
+
198
+ function getSvgPoint(event: MouseEvent) {
199
+ if (!svgElement || !container) return { x: 0, y: 0 };
200
+
201
+ const rect = container.getBoundingClientRect();
202
+
203
+ const relativeX = event.clientX - rect.left;
204
+ const relativeY = event.clientY - rect.top;
205
+
206
+ const svgX = (relativeX - transform.x) / transform.scale;
207
+ const svgY = (relativeY - transform.y) / transform.scale;
208
+
209
+ const offsetX = (containerW - imageDisplayWidth) / 2;
210
+ const offsetY = (containerH - imageDisplayHeight) / 2;
211
+
212
+ return {
213
+ x: svgX - offsetX,
214
+ y: svgY - offsetY,
215
+ };
216
+ }
217
+
218
+ function renderMarkers() {
219
+ if (!dotsGroup) return;
220
+
221
+ while (dotsGroup.firstChild) {
222
+ dotsGroup.removeChild(dotsGroup.firstChild);
223
+ }
224
+
225
+ const offsetX = (containerW - imageDisplayWidth) / 2;
226
+ const offsetY = (containerH - imageDisplayHeight) / 2;
227
+
228
+ const svgns = 'http://www.w3.org/2000/svg';
229
+ const adjustedMarkerSize = BASE_MARKER_SIZE / transform.scale;
230
+ const adjustedFontSize = BASE_MARKER_FONT_SIZE / transform.scale;
231
+
232
+ if (showMultiConfig && allMarks && allMarks.length > 0) {
233
+ // Group marks by configuration to number them separately
234
+ const marksByConfig = new Map<number, ConfiguratedMark[]>();
235
+ allMarks.forEach((mark) => {
236
+ if (!marksByConfig.has(mark.configIndex)) {
237
+ marksByConfig.set(mark.configIndex, []);
238
+ }
239
+ marksByConfig.get(mark.configIndex)!.push(mark);
240
+ });
241
+
242
+ // Render multi-configuration marks with per-configuration numbering
243
+ marksByConfig.forEach((configMarks, configIndex) => {
244
+ configMarks.forEach((mark, configMarkIndex) => {
245
+ const group = document.createElementNS(svgns, 'g');
246
+ group.setAttribute(
247
+ 'data-testid',
248
+ `config-marker-${mark.configIndex}-${configMarkIndex + 1}`
249
+ );
250
+
251
+ const markX = mark.x + offsetX;
252
+ const markY = mark.y + offsetY;
253
+
254
+ // Resolve mark color - supports both hex colors and semantic color names
255
+ const resolvedMarkColor = mark.color.startsWith('#')
256
+ ? mark.color
257
+ : getMarkerColorHex(mark.color);
258
+
259
+ const circle = document.createElementNS(svgns, 'circle');
260
+ circle.setAttribute('cx', String(markX));
261
+ circle.setAttribute('cy', String(markY));
262
+ circle.setAttribute('r', String(adjustedMarkerSize));
263
+ circle.setAttribute('fill', resolvedMarkColor);
264
+ circle.setAttribute('class', 'drop-shadow-sm');
265
+ group.appendChild(circle);
266
+
267
+ const text = document.createElementNS(svgns, 'text');
268
+ text.setAttribute('x', String(markX));
269
+ text.setAttribute('y', String(markY));
270
+ text.setAttribute('text-anchor', 'middle');
271
+ text.setAttribute('dominant-baseline', 'central');
272
+ text.setAttribute('fill', TEXT_COLOR);
273
+ text.setAttribute('font-size', String(adjustedFontSize));
274
+ text.setAttribute('font-weight', 'bold');
275
+ text.textContent = String(configMarkIndex + 1); // Number per configuration
276
+ group.appendChild(text);
277
+
278
+ dotsGroup.appendChild(group);
279
+ });
280
+ });
281
+ } else {
282
+ // Render single-configuration marks (existing behavior)
283
+ marks.forEach((mark, index) => {
284
+ const group = document.createElementNS(svgns, 'g');
285
+ group.setAttribute('data-testid', `marker-${index + 1}`);
286
+
287
+ const markX = mark.x + offsetX;
288
+ const markY = mark.y + offsetY;
289
+
290
+ const circle = document.createElementNS(svgns, 'circle');
291
+ circle.setAttribute('cx', String(markX));
292
+ circle.setAttribute('cy', String(markY));
293
+ circle.setAttribute('r', String(adjustedMarkerSize));
294
+ circle.setAttribute('fill', resolvedActiveMarkerColor());
295
+ circle.setAttribute('class', 'drop-shadow-sm');
296
+ group.appendChild(circle);
297
+
298
+ const text = document.createElementNS(svgns, 'text');
299
+ text.setAttribute('x', String(markX));
300
+ text.setAttribute('y', String(markY));
301
+ text.setAttribute('text-anchor', 'middle');
302
+ text.setAttribute('dominant-baseline', 'central');
303
+ text.setAttribute('fill', TEXT_COLOR);
304
+ text.setAttribute('font-size', String(adjustedFontSize));
305
+ text.setAttribute('font-weight', 'bold');
306
+ text.textContent = String(index + 1);
307
+ group.appendChild(text);
308
+
309
+ dotsGroup.appendChild(group);
310
+ });
311
+ }
312
+ }
313
+
314
+ function handleClick(event: MouseEvent) {
315
+ if (disabled || hideMarkers || !dotsGroup || isShiftPressed || panningState !== null) {
316
+ panningState = null;
317
+ return;
318
+ }
319
+
320
+ // In multi-config mode, only allow editing if there's an active editable configuration
321
+ if (showMultiConfig && editableConfigIndex === null) {
322
+ return;
323
+ }
324
+
325
+ const pt = getSvgPoint(event);
326
+ marks.push({ x: pt.x, y: pt.y });
327
+
328
+ renderMarkers();
329
+
330
+ if (onclick) {
331
+ onclick(event, marks);
332
+ }
333
+
334
+ onMarksChange?.(marks);
335
+ }
336
+
337
+ function handleKeyDown(event: KeyboardEvent) {
338
+ if (event.shiftKey) {
339
+ isShiftPressed = true;
340
+ }
341
+ }
342
+
343
+ function handleKeyUp(event: KeyboardEvent) {
344
+ let shouldResetReadyState = false;
345
+
346
+ if (!event.shiftKey && isShiftPressed) {
347
+ isShiftPressed = false;
348
+ shouldResetReadyState = true;
349
+ }
350
+
351
+ if (shouldResetReadyState && panningState === 'ready') {
352
+ panningState = null;
353
+ }
354
+ }
355
+
356
+ function handleMouseDown(event: MouseEvent) {
357
+ const isPanningTrigger = event.button === 2 || (event.button === 0 && isShiftPressed);
358
+ if (!isPanningTrigger) return;
359
+
360
+ event.preventDefault();
361
+ panningState = 'ready';
362
+ startPoint = { x: event.clientX, y: event.clientY };
363
+ }
364
+
365
+ function handleMouseMove(event: MouseEvent) {
366
+ if (panningState === null) return;
367
+
368
+ const dx = event.clientX - startPoint.x;
369
+ const dy = event.clientY - startPoint.y;
370
+
371
+ if (panningState === 'ready' && Math.sqrt(dx * dx + dy * dy) > DRAG_THRESHOLD) {
372
+ panningState = 'active';
373
+ }
374
+
375
+ if (panningState === 'active') {
376
+ startPoint = { x: event.clientX, y: event.clientY };
377
+
378
+ transform.x += dx;
379
+ transform.y += dy;
380
+
381
+ clampTransform();
382
+ updateTransform();
383
+ }
384
+ }
385
+
386
+ function handleMouseUp() {
387
+ panningState = null;
388
+ }
389
+
390
+ function handleMouseLeave() {
391
+ panningState = null;
392
+ }
393
+
394
+ function handleContextMenu(event: MouseEvent) {
395
+ event.preventDefault();
396
+ }
397
+
398
+ function handleWheel(event: WheelEvent) {
399
+ event.preventDefault();
400
+
401
+ if (!svgElement || !viewport || !container) return;
402
+
403
+ containerW = container.clientWidth;
404
+ containerH = container.clientHeight;
405
+
406
+ const zoomIntensity = ZOOM_STEP;
407
+ const delta = event.deltaY;
408
+ const zoomFactor = 1 - delta * zoomIntensity;
409
+ const currentScale = transform.scale;
410
+
411
+ let potentialNewScale = currentScale * zoomFactor;
412
+
413
+ let finalScale = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, potentialNewScale));
414
+
415
+ if (finalScale <= MIN_ZOOM) {
416
+ transform.scale = MIN_ZOOM;
417
+ transform.x = 0;
418
+ transform.y = 0;
419
+ } else {
420
+ const rect = container.getBoundingClientRect();
421
+ const mouseX = event.clientX - rect.left;
422
+ const mouseY = event.clientY - rect.top;
423
+
424
+ const distX = mouseX - transform.x;
425
+ const distY = mouseY - transform.y;
426
+
427
+ const scaleRatio = finalScale / currentScale;
428
+ const newDistX = distX * scaleRatio;
429
+ const newDistY = distY * scaleRatio;
430
+
431
+ transform.x = mouseX - newDistX;
432
+ transform.y = mouseY - newDistY;
433
+ transform.scale = finalScale;
434
+ }
435
+
436
+ clampTransform();
437
+ updateTransform();
438
+ }
439
+
440
+ function undo() {
441
+ if (marks.length === 0) return;
442
+
443
+ marks.pop();
444
+ marks = marks;
445
+ renderMarkers();
446
+
447
+ onMarksChange?.(marks);
448
+ }
449
+
450
+ function reset() {
451
+ if (marks.length === 0) return;
452
+
453
+ marks = [];
454
+ renderMarkers();
455
+ onMarksChange?.(marks);
456
+ }
457
+
458
+ function applyZoom(zoomFactor: number) {
459
+ if (!container) return;
460
+
461
+ const currentScale = transform.scale;
462
+ const potentialNewScale = currentScale * zoomFactor;
463
+ const finalScale = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, potentialNewScale));
464
+
465
+ if (finalScale === currentScale) return;
466
+
467
+ const centerX = containerW / 2;
468
+ const centerY = containerH / 2;
469
+
470
+ const distX = centerX - transform.x;
471
+ const distY = centerY - transform.y;
472
+
473
+ const scaleRatio = finalScale / currentScale;
474
+ const newDistX = distX * scaleRatio;
475
+ const newDistY = distY * scaleRatio;
476
+
477
+ transform.x = centerX - newDistX;
478
+ transform.y = centerY - newDistY;
479
+ transform.scale = finalScale;
480
+
481
+ clampTransform();
482
+ updateTransform();
483
+ }
484
+
485
+ function zoomIn() {
486
+ applyZoom(1.2);
487
+ }
488
+
489
+ function zoomOut() {
490
+ applyZoom(1 / 1.2);
491
+ }
492
+
493
+ $effect(() => {
494
+ if (editableConfigIndex !== previousConfigIndex) {
495
+ previousConfigIndex = editableConfigIndex;
496
+ }
497
+ });
498
+
499
+ onMount(() => {
500
+ if (container) {
501
+ containerW = container.clientWidth;
502
+ containerH = container.clientHeight;
503
+
504
+ resizeObserver = new ResizeObserver(handleResize);
505
+ resizeObserver.observe(container);
506
+ }
507
+
508
+ window.addEventListener('keydown', handleKeyDown);
509
+ window.addEventListener('keyup', handleKeyUp);
510
+
511
+ updateTransform();
512
+ renderMarkers();
513
+
514
+ const img = new Image();
515
+ img.onload = onImageLoad;
516
+ img.src = imageUrl;
517
+
518
+ if (img.complete) {
519
+ onImageLoad();
520
+ }
521
+
522
+ return () => {
523
+ window.removeEventListener('keydown', handleKeyDown);
524
+ window.removeEventListener('keyup', handleKeyUp);
525
+ if (resizeObserver) {
526
+ resizeObserver.disconnect();
527
+ }
528
+ };
529
+ });
530
+
531
+ $effect(() => {
532
+ renderMarkers();
533
+ });
534
+ </script>
535
+
536
+ {#snippet UndoResetControls()}
537
+ <div class="flex items-center gap-1">
538
+ <IconButton
539
+ variant="transparent-inverse"
540
+ rounded={false}
541
+ onclick={undo}
542
+ disabled={marks.length === 0 || disabled}
543
+ aria-label="Undo last mark"
544
+ >
545
+ <Icon iconName={'ArrowUUpLeft'} />
546
+ </IconButton>
547
+ <Divider vertical inverse class="!h-5" />
548
+ <Button
549
+ variant="transparent-inverse"
550
+ size="sm"
551
+ onClick={reset}
552
+ disabled={marks.length === 0 || disabled}
553
+ accessibilityLabel="Reset all marks"
554
+ class="!text-primary-inverse"
555
+ >
556
+ Clear all
557
+ </Button>
558
+ </div>
559
+ {/snippet}
560
+
561
+ {#snippet TopLeftActions()}
562
+ {#if activeMarkerName && !hideMarkers && !disabled}
563
+ <div
564
+ class="absolute left-2 top-2 z-20 flex w-full max-w-[calc(100%-1rem)] items-center justify-between rounded-lg border border-interactive-inverse bg-base-inverse px-3 py-2"
565
+ >
566
+ <div class="flex items-center gap-2">
567
+ <div
568
+ class="flex !h-6 !w-6 shrink-0 items-center justify-center rounded-md"
569
+ style="background-color: {resolvedActiveMarkerColor()}"
570
+ >
571
+ <Icon iconName={'CursorClick'} size={16} color="primary-inverse" />
572
+ </div>
573
+
574
+ <span class="text-sm text-primary-inverse">
575
+ Click to count each object in <strong>{activeMarkerName}</strong>
576
+ </span>
577
+ </div>
578
+
579
+ {@render UndoResetControls()}
580
+ </div>
581
+ {/if}
582
+ {/snippet}
583
+
584
+ {#snippet ZoomControls()}
585
+ <div
586
+ class="absolute bottom-2 right-2 z-20 flex flex-col items-center gap-1 rounded-md border border-interactive-inverse bg-base-inverse p-1 opacity-0 transition-opacity duration-200 group-hover:opacity-100"
587
+ >
588
+ <IconButton
589
+ variant="transparent-inverse"
590
+ rounded={false}
591
+ onclick={zoomIn}
592
+ aria-label="Zoom In"
593
+ disabled={transform.scale >= MAX_ZOOM || disabled}
594
+ >
595
+ <Icon iconName={'Plus'} />
596
+ </IconButton>
597
+ <IconButton
598
+ variant="transparent-inverse"
599
+ rounded={false}
600
+ onclick={zoomOut}
601
+ aria-label="Zoom Out"
602
+ disabled={transform.scale <= MIN_ZOOM || disabled}
603
+ >
604
+ <Icon iconName={'Minus'} />
605
+ </IconButton>
606
+ </div>
607
+ {/snippet}
608
+
609
+ <div
610
+ bind:this={container}
611
+ class="group relative h-full w-full overflow-hidden rounded-lg border"
612
+ data-testid="manual-cfu-counter"
613
+ >
614
+ {#if !hideMarkers && !disabled}
615
+ {@render TopLeftActions()}
616
+ {@render ZoomControls()}
617
+ {/if}
618
+
619
+ <!--
620
+ We need to use SVG for this interactive component.
621
+ The SVG element is treated as a canvas for clicking, panning, and zooming.
622
+ We add accessibility attributes to make it more accessible despite the interactive nature.
623
+ -->
624
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
625
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
626
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
627
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
628
+ <svg
629
+ bind:this={svgElement}
630
+ onclick={handleClick}
631
+ onmousedown={handleMouseDown}
632
+ onmousemove={handleMouseMove}
633
+ onmouseup={handleMouseUp}
634
+ onmouseleave={handleMouseLeave}
635
+ oncontextmenu={handleContextMenu}
636
+ onwheel={handleWheel}
637
+ class:cursor-grabbing={panningState === 'active'}
638
+ class:cursor-grab={!disabled && (panningState === 'ready' || (isShiftPressed && !panningState))}
639
+ class:cursor-not-allowed={disabled}
640
+ class:cursor-crosshair={!disabled && !panningState && !isShiftPressed}
641
+ class="h-full w-full"
642
+ role="application"
643
+ aria-label={disabled
644
+ ? 'CFU Counter (disabled)'
645
+ : 'CFU Counter - Click to add markers, right click or shift+click to pan'}
646
+ tabindex="0"
647
+ >
648
+ <g bind:this={viewport} id="viewport" class="h-full w-full">
649
+ <image href={imageUrl} x="0" y="0" width="100%" />
650
+ <g
651
+ bind:this={dotsGroup}
652
+ id="dots"
653
+ class="pointer-events-none"
654
+ aria-hidden={hideMarkers}
655
+ class:hidden={hideMarkers}
656
+ />
657
+ </g>
658
+ </svg>
659
+
660
+ <!-- Debug info for marks count - useful for testing -->
661
+ <span class="sr-only" data-testid="marks-count">{marks.length}</span>
662
+ </div>
@@ -0,0 +1,32 @@
1
+ interface ConfiguratedMark {
2
+ x: number;
3
+ y: number;
4
+ configIndex: number;
5
+ color: string;
6
+ id?: string;
7
+ }
8
+ interface Props {
9
+ imageUrl: string;
10
+ marks?: Array<{
11
+ x: number;
12
+ y: number;
13
+ }>;
14
+ onclick?: (event: MouseEvent, marks: Array<{
15
+ x: number;
16
+ y: number;
17
+ }>) => void;
18
+ onMarksChange?: (marks: Array<{
19
+ x: number;
20
+ y: number;
21
+ }>) => void;
22
+ disabled?: boolean;
23
+ hideMarkers?: boolean;
24
+ allMarks?: ConfiguratedMark[];
25
+ activeMarkerColor?: string;
26
+ activeMarkerName?: string;
27
+ editableConfigIndex?: number | null;
28
+ showMultiConfig?: boolean;
29
+ }
30
+ declare const MultiCfuCounter: import("svelte").Component<Props, {}, "marks">;
31
+ type MultiCfuCounter = ReturnType<typeof MultiCfuCounter>;
32
+ export default MultiCfuCounter;
@@ -0,0 +1 @@
1
+ export { default as MultiCFUCounter } from './MultiCFUCounter.svelte';
@@ -0,0 +1 @@
1
+ export { default as MultiCFUCounter } from './MultiCFUCounter.svelte';