@reshape-biotech/design-system 0.0.52 → 1.1.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.
Files changed (184) hide show
  1. package/dist/components/activity/Activity.stories.svelte +21 -8
  2. package/dist/components/activity/Activity.svelte +49 -9
  3. package/dist/components/activity/Activity.svelte.d.ts +1 -1
  4. package/dist/components/avatar/Avatar.stories.svelte +7 -17
  5. package/dist/components/avatar/Avatar.svelte +2 -2
  6. package/dist/components/avatar/Avatar.svelte.d.ts +2 -2
  7. package/dist/components/banner/Banner.stories.svelte +5 -5
  8. package/dist/components/button/Button.stories.svelte +54 -21
  9. package/dist/components/button/Button.svelte +58 -11
  10. package/dist/components/button/Button.svelte.d.ts +4 -3
  11. package/dist/components/card/Card.stories.svelte +130 -0
  12. package/dist/components/card/Card.stories.svelte.d.ts +19 -0
  13. package/dist/components/card/Card.svelte +25 -0
  14. package/dist/components/card/Card.svelte.d.ts +10 -0
  15. package/dist/components/card/index.d.ts +1 -0
  16. package/dist/components/card/index.js +1 -0
  17. package/dist/components/checkbox/Checkbox.stories.svelte +22 -0
  18. package/dist/components/checkbox/Checkbox.stories.svelte.d.ts +19 -0
  19. package/dist/components/checkbox/Checkbox.svelte +24 -0
  20. package/dist/components/checkbox/Checkbox.svelte.d.ts +5 -0
  21. package/dist/components/checkbox/index.d.ts +1 -0
  22. package/dist/components/checkbox/index.js +1 -0
  23. package/dist/components/collapsible/Collapsible.stories.svelte +5 -4
  24. package/dist/components/collapsible/components/collapsible-trigger.svelte +1 -1
  25. package/dist/components/combobox/Combobox.stories.svelte +7 -5
  26. package/dist/components/combobox/components/combobox-add.svelte +2 -1
  27. package/dist/components/combobox/components/combobox-content.svelte +4 -0
  28. package/dist/components/combobox/types.d.ts +2 -0
  29. package/dist/components/datepicker/DatePicker.stories.svelte +8 -14
  30. package/dist/components/divider/Divider.stories.svelte +1 -3
  31. package/dist/components/divider/Divider.svelte +8 -2
  32. package/dist/components/divider/Divider.svelte.d.ts +2 -0
  33. package/dist/components/drawer/Drawer.stories.svelte +3 -3
  34. package/dist/components/dropdown/Dropdown.stories.svelte +8 -8
  35. package/dist/components/empty-content/EmptyContent.stories.svelte +2 -2
  36. package/dist/components/graphs/bar-chart/BarChart.stories.svelte +81 -0
  37. package/dist/components/graphs/bar-chart/BarChart.stories.svelte.d.ts +19 -0
  38. package/dist/components/graphs/bar-chart/BarChart.svelte +136 -0
  39. package/dist/components/graphs/bar-chart/BarChart.svelte.d.ts +15 -0
  40. package/dist/components/graphs/bar-chart/StackedBarChart.stories.svelte +42 -0
  41. package/dist/components/graphs/bar-chart/StackedBarChart.stories.svelte.d.ts +19 -0
  42. package/dist/components/graphs/bar-chart/StackedBarChart.svelte +177 -0
  43. package/dist/components/graphs/bar-chart/StackedBarChart.svelte.d.ts +16 -0
  44. package/dist/components/graphs/chart/Chart.stories.svelte +31 -23
  45. package/dist/components/graphs/chart/Chart.svelte +104 -32
  46. package/dist/components/graphs/chart/Chart.svelte.d.ts +15 -5
  47. package/dist/components/graphs/index.d.ts +3 -0
  48. package/dist/components/graphs/index.js +3 -0
  49. package/dist/components/graphs/line/LineChart.stories.svelte +107 -15
  50. package/dist/components/graphs/line/LineChart.svelte +90 -51
  51. package/dist/components/graphs/line/LineChart.svelte.d.ts +6 -13
  52. package/dist/components/graphs/matrix/Matrix.stories.svelte +156 -0
  53. package/dist/components/graphs/matrix/Matrix.stories.svelte.d.ts +19 -0
  54. package/dist/components/graphs/matrix/Matrix.svelte +149 -0
  55. package/dist/components/graphs/matrix/Matrix.svelte.d.ts +24 -0
  56. package/dist/components/graphs/matrix/index.d.ts +3 -0
  57. package/dist/components/graphs/matrix/index.js +3 -0
  58. package/dist/components/graphs/multiline/MultiLineChart.stories.svelte +130 -18
  59. package/dist/components/graphs/multiline/MultiLineChart.svelte +187 -50
  60. package/dist/components/graphs/multiline/MultiLineChart.svelte.d.ts +9 -12
  61. package/dist/components/graphs/scatterplot/Scatterplot.stories.svelte +68 -41
  62. package/dist/components/graphs/scatterplot/Scatterplot.svelte +312 -45
  63. package/dist/components/graphs/scatterplot/Scatterplot.svelte.d.ts +23 -13
  64. package/dist/components/graphs/utils/tooltipFormatter.d.ts +10 -0
  65. package/dist/components/graphs/utils/tooltipFormatter.js +52 -0
  66. package/dist/components/icon-button/IconButton.stories.svelte +6 -6
  67. package/dist/components/icon-button/IconButton.svelte +50 -9
  68. package/dist/components/icon-button/IconButton.svelte.d.ts +3 -5
  69. package/dist/components/icons/AnalysisIcon.stories.svelte +15 -21
  70. package/dist/components/icons/AnalysisIcon.svelte +53 -43
  71. package/dist/components/icons/Icon.stories.svelte +4 -4
  72. package/dist/components/icons/Icon.svelte +1 -1
  73. package/dist/components/icons/PrincipalIcon.svelte +96 -0
  74. package/dist/components/icons/PrincipalIcon.svelte.d.ts +10 -0
  75. package/dist/components/icons/custom/Halo.svelte +14 -8
  76. package/dist/components/icons/custom/Halo.svelte.d.ts +7 -25
  77. package/dist/components/icons/custom/Well.svelte +14 -6
  78. package/dist/components/icons/custom/Well.svelte.d.ts +7 -25
  79. package/dist/components/icons/index.d.ts +3 -2
  80. package/dist/components/icons/index.js +26 -0
  81. package/dist/components/input/Input.stories.svelte +16 -22
  82. package/dist/components/input/Input.svelte +140 -134
  83. package/dist/components/input/Input.svelte.d.ts +12 -13
  84. package/dist/components/label/Label.stories.svelte +28 -0
  85. package/dist/components/label/Label.stories.svelte.d.ts +19 -0
  86. package/dist/components/label/Label.svelte +17 -0
  87. package/dist/components/label/Label.svelte.d.ts +9 -0
  88. package/dist/components/list/List.stories.svelte +3 -3
  89. package/dist/components/logo/Logo.stories.svelte +1 -1
  90. package/dist/components/manual-cfu-counter/ManualCFUCounter.stories.svelte +125 -0
  91. package/dist/components/manual-cfu-counter/ManualCFUCounter.stories.svelte.d.ts +3 -0
  92. package/dist/components/manual-cfu-counter/ManualCFUCounter.svelte +577 -0
  93. package/dist/components/manual-cfu-counter/ManualCFUCounter.svelte.d.ts +16 -0
  94. package/dist/components/manual-cfu-counter/index.d.ts +1 -0
  95. package/dist/components/manual-cfu-counter/index.js +1 -0
  96. package/dist/components/manual-cfu-counter/test/ManualCFUCounterTestWrapper.svelte +22 -0
  97. package/dist/components/manual-cfu-counter/test/ManualCFUCounterTestWrapper.svelte.d.ts +19 -0
  98. package/dist/components/markdown/Markdown.stories.svelte +1 -1
  99. package/dist/components/markdown/Markdown.svelte +1 -1
  100. package/dist/components/modal/Modal.stories.svelte +2 -2
  101. package/dist/components/modal/Modal.svelte +27 -22
  102. package/dist/components/modal/Modal.svelte.d.ts +4 -1
  103. package/dist/components/notification-popup/NotificationPopup.stories.svelte +1 -1
  104. package/dist/components/pill/Pill.stories.svelte +13 -0
  105. package/dist/components/pill/Pill.stories.svelte.d.ts +19 -0
  106. package/dist/components/progress-circle/ProgressCircle.stories.svelte +15 -0
  107. package/dist/components/progress-circle/ProgressCircle.stories.svelte.d.ts +19 -0
  108. package/dist/components/required-status-indicator/RequiredStatusIndicator.stories.svelte +28 -0
  109. package/dist/components/required-status-indicator/RequiredStatusIndicator.stories.svelte.d.ts +19 -0
  110. package/dist/components/required-status-indicator/RequiredStatusIndicator.svelte +22 -0
  111. package/dist/components/required-status-indicator/RequiredStatusIndicator.svelte.d.ts +8 -0
  112. package/dist/components/required-status-indicator/index.d.ts +1 -0
  113. package/dist/components/required-status-indicator/index.js +1 -0
  114. package/dist/components/segmented-control-buttons/SegmentedControlButtons.stories.svelte +3 -3
  115. package/dist/components/select/Select.stories.svelte +12 -12
  116. package/dist/components/select/Select.svelte +0 -2
  117. package/dist/components/select-new/Select.stories.svelte +219 -0
  118. package/dist/components/select-new/Select.stories.svelte.d.ts +19 -0
  119. package/dist/components/select-new/components/Group.svelte +23 -0
  120. package/dist/components/select-new/components/Group.svelte.d.ts +10 -0
  121. package/dist/components/select-new/components/MultiSelectTrigger.svelte +66 -0
  122. package/dist/components/select-new/components/MultiSelectTrigger.svelte.d.ts +17 -0
  123. package/dist/components/select-new/components/SelectContent.svelte +33 -0
  124. package/dist/components/select-new/components/SelectContent.svelte.d.ts +10 -0
  125. package/dist/components/select-new/components/SelectGroupHeading.svelte +19 -0
  126. package/dist/components/select-new/components/SelectGroupHeading.svelte.d.ts +9 -0
  127. package/dist/components/select-new/components/SelectItem.svelte +41 -0
  128. package/dist/components/select-new/components/SelectItem.svelte.d.ts +9 -0
  129. package/dist/components/select-new/components/SelectTrigger.svelte +48 -0
  130. package/dist/components/select-new/components/SelectTrigger.svelte.d.ts +12 -0
  131. package/dist/components/select-new/index.d.ts +10 -0
  132. package/dist/components/select-new/index.js +12 -0
  133. package/dist/components/select-new/types.d.ts +25 -0
  134. package/dist/components/select-new/types.js +1 -0
  135. package/dist/components/sjsf-wrappers/SjsfNumberInputWrapper.svelte +92 -0
  136. package/dist/components/sjsf-wrappers/SjsfNumberInputWrapper.svelte.d.ts +3 -0
  137. package/dist/components/sjsf-wrappers/SjsfTextInputWrapper.svelte +65 -0
  138. package/dist/components/sjsf-wrappers/SjsfTextInputWrapper.svelte.d.ts +3 -0
  139. package/dist/components/sjsf-wrappers/index.d.ts +2 -0
  140. package/dist/components/sjsf-wrappers/index.js +2 -0
  141. package/dist/components/sjsf-wrappers/sjsfCustomTheme.d.ts +2 -0
  142. package/dist/components/sjsf-wrappers/sjsfCustomTheme.js +8 -0
  143. package/dist/components/skeleton-loader/SkeletonLoader.stories.svelte +4 -4
  144. package/dist/components/slider/Slider.stories.svelte +4 -4
  145. package/dist/components/spinner/Spinner.stories.svelte +13 -0
  146. package/dist/components/spinner/Spinner.stories.svelte.d.ts +19 -0
  147. package/dist/components/stat-card/StatCard.stories.svelte +27 -19
  148. package/dist/components/stat-card/StatCard.svelte +100 -6
  149. package/dist/components/stat-card/StatCard.svelte.d.ts +3 -0
  150. package/dist/components/status-badge/StatusBadge.stories.svelte +6 -6
  151. package/dist/components/status-badge/StatusBadge.svelte +5 -3
  152. package/dist/components/table/Table.stories.svelte +1 -1
  153. package/dist/components/table/components/Td.svelte +3 -2
  154. package/dist/components/table/components/Td.svelte.d.ts +1 -0
  155. package/dist/components/table/components/Tr.svelte +3 -2
  156. package/dist/components/table/components/Tr.svelte.d.ts +1 -0
  157. package/dist/components/tabs/Tabs.stories.svelte +1 -1
  158. package/dist/components/tag/Tag.stories.svelte +38 -7
  159. package/dist/components/tag/Tag.svelte +34 -21
  160. package/dist/components/tag/Tag.svelte.d.ts +1 -1
  161. package/dist/components/textarea/Textarea.stories.svelte +97 -0
  162. package/dist/components/textarea/Textarea.stories.svelte.d.ts +19 -0
  163. package/dist/components/textarea/Textarea.svelte +94 -0
  164. package/dist/components/textarea/Textarea.svelte.d.ts +17 -0
  165. package/dist/components/textarea/index.d.ts +1 -0
  166. package/dist/components/textarea/index.js +1 -0
  167. package/dist/components/toggle/Toggle.stories.svelte +1 -1
  168. package/dist/components/toggle/Toggle.svelte +3 -2
  169. package/dist/components/toggle/Toggle.svelte.d.ts +1 -0
  170. package/dist/components/toggle-icon-button/ToggleIconButton.stories.svelte +173 -0
  171. package/dist/components/toggle-icon-button/ToggleIconButton.stories.svelte.d.ts +19 -0
  172. package/dist/components/toggle-icon-button/ToggleIconButton.svelte +117 -0
  173. package/dist/components/toggle-icon-button/ToggleIconButton.svelte.d.ts +15 -0
  174. package/dist/components/toggle-icon-button/index.d.ts +3 -0
  175. package/dist/components/toggle-icon-button/index.js +3 -0
  176. package/dist/components/tooltip/Tooltip.stories.svelte +6 -6
  177. package/dist/components/tooltip/Tooltip.svelte +1 -1
  178. package/dist/index.d.ts +7 -0
  179. package/dist/index.js +7 -0
  180. package/dist/tailwind-safelist.js +2 -0
  181. package/dist/tailwind.preset.d.ts +2 -0
  182. package/dist/tokens.d.ts +4 -0
  183. package/dist/tokens.js +3 -1
  184. package/package.json +1 -2
@@ -0,0 +1,125 @@
1
+ <script module lang="ts">
2
+ import { defineMeta } from '@storybook/addon-svelte-csf';
3
+ import ManualCFUCounter from './ManualCFUCounter.svelte';
4
+ const { Story } = defineMeta({
5
+ component: ManualCFUCounter,
6
+ title: 'Design System/ManualCFUCounter',
7
+ tags: ['autodocs'],
8
+ args: {
9
+ imageUrl: '/imgs/ris.jpg',
10
+ marks: [],
11
+ disabled: false,
12
+ hideMarkers: false
13
+ },
14
+ argTypes: {
15
+ imageUrl: {
16
+ control: 'text',
17
+ description: 'URL of the image to display'
18
+ },
19
+ marks: {
20
+ control: 'object',
21
+ description: 'Array of marker coordinates. Bindable for two-way data flow.'
22
+ },
23
+ onclick: {
24
+ action: 'clicked',
25
+ description: 'Event handler for when a marker is added'
26
+ },
27
+ disabled: {
28
+ control: 'boolean',
29
+ description: 'Disables interaction with the counter'
30
+ },
31
+ hideMarkers: {
32
+ control: 'boolean',
33
+ description: 'Hides the markers and prevents new ones from being added'
34
+ }
35
+ }
36
+ });
37
+
38
+ const largeImage = '/imgs/ris.jpg';
39
+
40
+ function handleUpdate(event: MouseEvent, marks: Array<{ x: number; y: number }>) {
41
+ console.log('Marker count:', marks.length);
42
+ console.log('Marker positions:', marks);
43
+ }
44
+ </script>
45
+
46
+ <script lang="ts">
47
+ // For two-way binding examples
48
+ let boundMarksArray = $state<Array<{ x: number; y: number }>>([]);
49
+
50
+ // For initial markers example
51
+ let presetMarks = [
52
+ { x: 100, y: 100 },
53
+ { x: 200, y: 200 },
54
+ { x: 300, y: 300 }
55
+ ];
56
+ </script>
57
+
58
+ <Story name="Default" asChild>
59
+ <div class="p-4">
60
+ <h3 class="mb-4 text-lg font-semibold">Manual CFU Counter</h3>
61
+ <p class="mb-4 text-sm text-secondary">
62
+ Click to add markers. Pan by clicking and dragging. Zoom with the mouse wheel.
63
+ </p>
64
+ <div class="h-[600px] rounded-lg border border-static p-4">
65
+ <ManualCFUCounter imageUrl={largeImage} onclick={handleUpdate} />
66
+ </div>
67
+ </div>
68
+ </Story>
69
+
70
+ <Story name="With Initial Data (marks prop)" asChild>
71
+ <div class="p-4">
72
+ <h3 class="mb-4 text-lg font-semibold">Pre-populated Markers (via marks prop)</h3>
73
+ <p class="mb-4 text-sm text-secondary">
74
+ This example starts with 3 pre-defined markers passed via the `marks` prop.
75
+ </p>
76
+ <div class="h-[600px] rounded-lg border border-static p-4">
77
+ <ManualCFUCounter imageUrl={largeImage} marks={presetMarks} onclick={handleUpdate} />
78
+ </div>
79
+ </div>
80
+ </Story>
81
+
82
+ <Story name="Two-way Binding (bind:marks)" asChild>
83
+ <div class="p-4">
84
+ <h3 class="mb-4 text-lg font-semibold">Two-way Binding</h3>
85
+ <p class="mb-4 text-sm text-secondary">
86
+ This example uses `bind:marks` to synchronize state between the story and the component. Click
87
+ inside the counter, then check the count below.
88
+ </p>
89
+ <p class="mb-2 text-sm">Marks count in story state: {boundMarksArray.length}</p>
90
+ <div class="h-[600px] rounded-lg border border-static p-4">
91
+ <ManualCFUCounter imageUrl={largeImage} bind:marks={boundMarksArray} onclick={handleUpdate} />
92
+ </div>
93
+ <details class="mt-2 text-sm">
94
+ <summary>Current boundMarksArray:</summary>
95
+ <pre>{JSON.stringify(boundMarksArray, null, 2)}</pre>
96
+ </details>
97
+ </div>
98
+ </Story>
99
+
100
+ <Story name="Hide Markers" asChild>
101
+ <div class="p-4">
102
+ <h3 class="mb-4 text-lg font-semibold">Without Markers</h3>
103
+ <p class="mb-4 text-sm text-secondary">
104
+ In this mode, markers are hidden and you cannot add new ones.
105
+ </p>
106
+ <div class="h-[600px] rounded-lg border border-static p-4">
107
+ <ManualCFUCounter
108
+ imageUrl={largeImage}
109
+ onclick={handleUpdate}
110
+ hideMarkers
111
+ marks={presetMarks}
112
+ />
113
+ </div>
114
+ </div>
115
+ </Story>
116
+
117
+ <Story name="Disabled" asChild>
118
+ <div class="p-4">
119
+ <h3 class="mb-4 text-lg font-semibold">Disabled Counter</h3>
120
+ <p class="mb-4 text-sm text-secondary">No interactions are possible in disabled mode.</p>
121
+ <div class="h-[600px] rounded-lg border border-static p-4">
122
+ <ManualCFUCounter imageUrl={largeImage} onclick={handleUpdate} disabled />
123
+ </div>
124
+ </div>
125
+ </Story>
@@ -0,0 +1,3 @@
1
+ declare const ManualCfuCounter: import("svelte").Component<Record<string, never>, {}, "">;
2
+ type ManualCfuCounter = ReturnType<typeof ManualCfuCounter>;
3
+ export default ManualCfuCounter;
@@ -0,0 +1,577 @@
1
+ <script lang="ts">
2
+ import { onMount } from 'svelte';
3
+ import { Button } from '../button';
4
+ import { textColor } from '../../tokens';
5
+ import { Icon } from '../icons';
6
+ import IconButton from '../icon-button/IconButton.svelte';
7
+ import Divider from '../divider/Divider.svelte';
8
+
9
+ // Constants for the component
10
+ const BASE_IMAGE_SIZE = 464; // Base size, will be overriden by container dimensions
11
+ const BASE_MARKER_SIZE = 12; // Base size at zoom level 1
12
+ const BASE_MARKER_FONT_SIZE = 10; // Base font size at zoom level 1
13
+ const MAX_ZOOM = 10;
14
+ const MIN_ZOOM = 1;
15
+ const ZOOM_STEP = 0.001;
16
+ const MARKER_COLOR = textColor['icon-blue'] || '#2563eb';
17
+ const TEXT_COLOR = textColor['primary-inverse'];
18
+ const DRAG_THRESHOLD = 5;
19
+
20
+ interface Props {
21
+ imageUrl: string;
22
+ marks?: Array<{ x: number; y: number }>;
23
+ onclick?: (event: MouseEvent, marks: Array<{ x: number; y: number }>) => void;
24
+ disabled?: boolean;
25
+ hideMarkers?: boolean;
26
+ }
27
+
28
+ let {
29
+ imageUrl,
30
+ onclick,
31
+ disabled = false,
32
+ hideMarkers = false,
33
+ marks = $bindable([])
34
+ }: Props = $props();
35
+
36
+ let svgElement: SVGSVGElement;
37
+ let viewport: SVGGraphicsElement;
38
+ let dotsGroup: SVGElement;
39
+ let marksHistory: Array<Array<{ x: number; y: number }>> = $state([]);
40
+ let container: HTMLDivElement;
41
+
42
+ let containerW = BASE_IMAGE_SIZE;
43
+ let containerH = BASE_IMAGE_SIZE;
44
+
45
+ let transform = $state({ x: 0, y: 0, scale: 1 });
46
+
47
+ // Panning state: null = not panning, 'ready' = mouse button down, 'active' = dragging
48
+ let panningState = $state<null | 'ready' | 'active'>(null);
49
+ let startPoint = $state({ x: 0, y: 0 });
50
+ let isShiftPressed = $state(false);
51
+ let resizeObserver: ResizeObserver;
52
+
53
+ let imageAspectRatio = $state(1); // default 1:1 ratio
54
+ let imageDisplayWidth = $state(0);
55
+ let imageDisplayHeight = $state(0);
56
+
57
+ function handleResize(entries: ResizeObserverEntry[]) {
58
+ const entry = entries[0];
59
+ if (entry) {
60
+ const oldWidth = imageDisplayWidth || containerW;
61
+ const oldHeight = imageDisplayHeight || containerH;
62
+
63
+ containerW = entry.contentRect.width;
64
+ containerH = entry.contentRect.height;
65
+
66
+ // If we already had dimensions and marks, update their positions
67
+ if (oldWidth > 0 && oldHeight > 0 && marks.length > 0) {
68
+ // Adjust marks positions based on new container dimensions
69
+ marks = marks.map((mark) => ({
70
+ x: (mark.x / oldWidth) * containerW,
71
+ y: (mark.y / oldHeight) * containerH
72
+ }));
73
+ }
74
+
75
+ updateImageDimensions();
76
+ }
77
+ }
78
+
79
+ function onImageLoad() {
80
+ if (container) {
81
+ containerW = container.clientWidth;
82
+ containerH = container.clientHeight;
83
+
84
+ // get original image aspect ratio
85
+ const img = new Image();
86
+ img.src = imageUrl;
87
+ img.onload = () => {
88
+ imageAspectRatio = img.naturalWidth / img.naturalHeight;
89
+ updateImageDimensions();
90
+ };
91
+
92
+ if (img.complete && img.naturalWidth) {
93
+ imageAspectRatio = img.naturalWidth / img.naturalHeight;
94
+ updateImageDimensions();
95
+ }
96
+ }
97
+ }
98
+
99
+ function updateImageDimensions() {
100
+ // calculate dimensions that maintain aspect ratio within container
101
+ const containerRatio = containerW / containerH;
102
+
103
+ if (containerRatio > imageAspectRatio) {
104
+ // container is wider than image - fit to height
105
+ imageDisplayHeight = containerH;
106
+ imageDisplayWidth = containerH * imageAspectRatio;
107
+ } else {
108
+ // container is taller than image - fit to width
109
+ imageDisplayWidth = containerW;
110
+ imageDisplayHeight = containerW / imageAspectRatio;
111
+ }
112
+
113
+ updateTransform();
114
+ }
115
+
116
+ // Properly constrain the transform to prevent moving outside container
117
+ function clampTransform() {
118
+ // Calculate maximum translation offsets based on scale > 1
119
+ const maxX = 0;
120
+ const maxY = 0;
121
+ // Prevent division by zero or unexpected behavior if container dimensions aren't ready
122
+ const safeContainerW = containerW || 1;
123
+ const safeContainerH = containerH || 1;
124
+ const minX = safeContainerW * (1 - transform.scale);
125
+ const minY = safeContainerH * (1 - transform.scale);
126
+
127
+ if (transform.scale <= 1) {
128
+ // If scale is 1 or less, center the view
129
+ // Check if scale is exactly 1 before modifying to avoid floating point issues if needed
130
+ // Or simply reset if it goes below
131
+ transform.scale = 1;
132
+ transform.x = 0;
133
+ transform.y = 0;
134
+ } else {
135
+ // Apply clamping for scale > 1
136
+ transform.x = Math.max(minX, Math.min(maxX, transform.x));
137
+ transform.y = Math.max(minY, Math.min(maxY, transform.y));
138
+ }
139
+ }
140
+
141
+ function updateTransform() {
142
+ if (!viewport) return;
143
+
144
+ // update viewport transform
145
+ viewport.setAttribute(
146
+ 'transform',
147
+ `translate(${transform.x}, ${transform.y}) scale(${transform.scale})`
148
+ );
149
+
150
+ // set image dimensions based on calculated size that maintains aspect ratio
151
+ const imageElement = viewport.querySelector('image');
152
+ if (imageElement) {
153
+ imageElement.setAttribute('width', String(imageDisplayWidth));
154
+ imageElement.setAttribute('height', String(imageDisplayHeight));
155
+
156
+ // center the image in the container
157
+ const offsetX = (containerW - imageDisplayWidth) / 2;
158
+ const offsetY = (containerH - imageDisplayHeight) / 2;
159
+
160
+ imageElement.setAttribute('x', String(offsetX));
161
+ imageElement.setAttribute('y', String(offsetY));
162
+ }
163
+
164
+ // update markers to maintain consistent size
165
+ renderMarkers();
166
+ }
167
+
168
+ function getSvgPoint(event: MouseEvent) {
169
+ if (!svgElement || !container) return { x: 0, y: 0 };
170
+
171
+ // get container bounding rect
172
+ const rect = container.getBoundingClientRect();
173
+
174
+ // calculate position relative to container
175
+ const relativeX = event.clientX - rect.left;
176
+ const relativeY = event.clientY - rect.top;
177
+
178
+ // apply inverse of current transform to get SVG coordinates
179
+ const svgX = (relativeX - transform.x) / transform.scale;
180
+ const svgY = (relativeY - transform.y) / transform.scale;
181
+
182
+ // adjust for image position within container (letterboxing)
183
+ const offsetX = (containerW - imageDisplayWidth) / 2;
184
+ const offsetY = (containerH - imageDisplayHeight) / 2;
185
+
186
+ // return coordinates relative to the image, not the container
187
+ return {
188
+ x: svgX - offsetX,
189
+ y: svgY - offsetY
190
+ };
191
+ }
192
+
193
+ // Render SVG markers from the marks array
194
+ function renderMarkers() {
195
+ if (!dotsGroup) return;
196
+
197
+ while (dotsGroup.firstChild) {
198
+ dotsGroup.removeChild(dotsGroup.firstChild);
199
+ }
200
+
201
+ const offsetX = (containerW - imageDisplayWidth) / 2;
202
+ const offsetY = (containerH - imageDisplayHeight) / 2;
203
+
204
+ const svgns = 'http://www.w3.org/2000/svg';
205
+ const adjustedMarkerSize = BASE_MARKER_SIZE / transform.scale;
206
+ const adjustedFontSize = BASE_MARKER_FONT_SIZE / transform.scale;
207
+
208
+ marks.forEach((mark, index) => {
209
+ // Create a marker group
210
+ const group = document.createElementNS(svgns, 'g');
211
+ group.setAttribute('data-testid', `marker-${index + 1}`);
212
+
213
+ const markX = mark.x + offsetX;
214
+ const markY = mark.y + offsetY;
215
+
216
+ const circle = document.createElementNS(svgns, 'circle');
217
+ circle.setAttribute('cx', String(markX));
218
+ circle.setAttribute('cy', String(markY));
219
+ circle.setAttribute('r', String(adjustedMarkerSize));
220
+ circle.setAttribute('fill', MARKER_COLOR);
221
+ circle.setAttribute('class', 'drop-shadow-sm');
222
+ group.appendChild(circle);
223
+
224
+ const text = document.createElementNS(svgns, 'text');
225
+ text.setAttribute('x', String(markX));
226
+ text.setAttribute('y', String(markY));
227
+ text.setAttribute('text-anchor', 'middle');
228
+ text.setAttribute('dominant-baseline', 'central');
229
+ text.setAttribute('fill', TEXT_COLOR);
230
+ text.setAttribute('font-size', String(adjustedFontSize));
231
+ text.setAttribute('font-weight', 'bold');
232
+ text.textContent = String(index + 1);
233
+ group.appendChild(text);
234
+
235
+ dotsGroup.appendChild(group);
236
+ });
237
+ }
238
+
239
+ function handleClick(event: MouseEvent) {
240
+ if (disabled || hideMarkers || !dotsGroup || isShiftPressed || panningState !== null) {
241
+ panningState = null;
242
+ return;
243
+ }
244
+ saveCurrentState();
245
+ const pt = getSvgPoint(event);
246
+ marks.push({ x: pt.x, y: pt.y });
247
+
248
+ renderMarkers();
249
+ if (onclick) {
250
+ onclick(event, marks);
251
+ }
252
+ }
253
+
254
+ function handleKeyDown(event: KeyboardEvent) {
255
+ if (event.shiftKey) {
256
+ isShiftPressed = true;
257
+ }
258
+ }
259
+
260
+ function handleKeyUp(event: KeyboardEvent) {
261
+ let shouldResetReadyState = false;
262
+
263
+ if (!event.shiftKey && isShiftPressed) {
264
+ isShiftPressed = false;
265
+ shouldResetReadyState = true;
266
+ }
267
+
268
+ // If panning was in 'ready' state and the relevant key was released, reset state
269
+ if (shouldResetReadyState && panningState === 'ready') {
270
+ panningState = null;
271
+ }
272
+ }
273
+
274
+ function handleMouseDown(event: MouseEvent) {
275
+ // Only start panning on right click, shift+left click
276
+ const isPanningTrigger = event.button === 2 || (event.button === 0 && isShiftPressed);
277
+ if (!isPanningTrigger) return;
278
+
279
+ event.preventDefault(); // Prevent default context menu or text selection
280
+ panningState = 'ready';
281
+ startPoint = { x: event.clientX, y: event.clientY };
282
+ }
283
+
284
+ // Handle panning on mouse move
285
+ function handleMouseMove(event: MouseEvent) {
286
+ if (panningState === null) return;
287
+
288
+ const dx = event.clientX - startPoint.x;
289
+ const dy = event.clientY - startPoint.y;
290
+
291
+ // Detect if we're dragging (moved beyond threshold)
292
+ if (panningState === 'ready' && Math.sqrt(dx * dx + dy * dy) > DRAG_THRESHOLD) {
293
+ panningState = 'active';
294
+ }
295
+
296
+ // Only update position if we're actually panning
297
+ if (panningState === 'active') {
298
+ startPoint = { x: event.clientX, y: event.clientY };
299
+
300
+ transform.x += dx;
301
+ transform.y += dy;
302
+
303
+ // Ensure pan stays within bounds
304
+ clampTransform();
305
+ updateTransform();
306
+ }
307
+ }
308
+
309
+ function handleMouseUp() {
310
+ panningState = null;
311
+ }
312
+
313
+ function handleMouseLeave() {
314
+ panningState = null;
315
+ }
316
+
317
+ function handleContextMenu(event: MouseEvent) {
318
+ event.preventDefault();
319
+ }
320
+
321
+ function handleWheel(event: WheelEvent) {
322
+ event.preventDefault();
323
+
324
+ if (!svgElement || !viewport || !container) return;
325
+
326
+ // Update container dimensions just in case they changed
327
+ containerW = container.clientWidth;
328
+ containerH = container.clientHeight;
329
+
330
+ const zoomIntensity = ZOOM_STEP;
331
+ const delta = event.deltaY;
332
+ const zoomFactor = 1 - delta * zoomIntensity;
333
+ const currentScale = transform.scale; // Store current scale before modification
334
+
335
+ let potentialNewScale = currentScale * zoomFactor;
336
+
337
+ // Clamp the scale
338
+ let finalScale = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, potentialNewScale));
339
+
340
+ if (finalScale <= MIN_ZOOM) {
341
+ // Reset view if zooming out below minimum or exactly at minimum
342
+ transform.scale = MIN_ZOOM;
343
+ transform.x = 0;
344
+ transform.y = 0;
345
+ } else {
346
+ // Calculate transform based on finalScale (which might be MAX_ZOOM)
347
+ const rect = container.getBoundingClientRect();
348
+ const mouseX = event.clientX - rect.left;
349
+ const mouseY = event.clientY - rect.top;
350
+
351
+ // Calculate distance from origin at current scale
352
+ const distX = mouseX - transform.x;
353
+ const distY = mouseY - transform.y;
354
+
355
+ // Calculate what the distance should be at the final scale
356
+ // Use currentScale from *before* this zoom event for the ratio
357
+ const scaleRatio = finalScale / currentScale;
358
+ const newDistX = distX * scaleRatio;
359
+ const newDistY = distY * scaleRatio;
360
+
361
+ // Update the transform to maintain the mouse position relative to the final scale
362
+ transform.x = mouseX - newDistX;
363
+ transform.y = mouseY - newDistY;
364
+ transform.scale = finalScale;
365
+ }
366
+
367
+ clampTransform();
368
+ updateTransform();
369
+ }
370
+
371
+ function saveCurrentState() {
372
+ marksHistory.push([...marks]);
373
+ }
374
+
375
+ function undo() {
376
+ if (marksHistory.length === 0) return;
377
+ const previousState = marksHistory.pop();
378
+ if (previousState) {
379
+ marks.splice(0, marks.length, ...previousState);
380
+ marks = marks;
381
+ renderMarkers();
382
+ }
383
+ }
384
+
385
+ function reset() {
386
+ if (marks.length === 0) return;
387
+ saveCurrentState();
388
+
389
+ marks = [];
390
+ renderMarkers();
391
+ }
392
+
393
+ // --- Zoom Functions ---
394
+ function applyZoom(zoomFactor: number) {
395
+ if (!container) return;
396
+
397
+ const currentScale = transform.scale;
398
+ const potentialNewScale = currentScale * zoomFactor;
399
+ const finalScale = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, potentialNewScale));
400
+
401
+ // If scale didn't change (already at min/max), do nothing
402
+ if (finalScale === currentScale) return;
403
+
404
+ // Get center of the container view
405
+ const centerX = containerW / 2;
406
+ const centerY = containerH / 2;
407
+
408
+ // Calculate distance from origin at current scale
409
+ const distX = centerX - transform.x;
410
+ const distY = centerY - transform.y;
411
+
412
+ // Calculate what the distance should be at the final scale
413
+ const scaleRatio = finalScale / currentScale;
414
+ const newDistX = distX * scaleRatio;
415
+ const newDistY = distY * scaleRatio;
416
+
417
+ // Update the transform to maintain the center position relative to the final scale
418
+ transform.x = centerX - newDistX;
419
+ transform.y = centerY - newDistY;
420
+ transform.scale = finalScale;
421
+
422
+ clampTransform();
423
+ updateTransform();
424
+ }
425
+
426
+ function zoomIn() {
427
+ applyZoom(1.2); // Zoom in by 20%
428
+ }
429
+
430
+ function zoomOut() {
431
+ applyZoom(1 / 1.2); // Zoom out by 20%
432
+ }
433
+
434
+ onMount(() => {
435
+ if (container) {
436
+ containerW = container.clientWidth;
437
+ containerH = container.clientHeight;
438
+
439
+ // setup resize observer
440
+ resizeObserver = new ResizeObserver(handleResize);
441
+ resizeObserver.observe(container);
442
+ }
443
+
444
+ window.addEventListener('keydown', handleKeyDown);
445
+ window.addEventListener('keyup', handleKeyUp);
446
+
447
+ updateTransform();
448
+ renderMarkers();
449
+
450
+ const img = new Image();
451
+ img.onload = onImageLoad;
452
+ img.src = imageUrl;
453
+
454
+ if (img.complete) {
455
+ onImageLoad();
456
+ }
457
+
458
+ return () => {
459
+ window.removeEventListener('keydown', handleKeyDown);
460
+ window.removeEventListener('keyup', handleKeyUp);
461
+ // cleanup resize observer
462
+ if (resizeObserver) {
463
+ resizeObserver.disconnect();
464
+ }
465
+ };
466
+ });
467
+
468
+ // Effect to render when marks prop changes from parent or internally
469
+ $effect(() => {
470
+ renderMarkers();
471
+ });
472
+ </script>
473
+
474
+ {#snippet TopLeftActions()}
475
+ {#if marks.length > 0}
476
+ <div
477
+ class="absolute left-2 top-2 z-20 flex items-center gap-1 rounded-lg border border-interactive-inverse bg-base-inverse p-1 opacity-0 transition-opacity duration-200 group-hover:opacity-100"
478
+ >
479
+ <IconButton
480
+ variant="transparent-inverse"
481
+ rounded={false}
482
+ onclick={undo}
483
+ disabled={marksHistory.length === 0 || disabled}
484
+ aria-label="Undo last mark"
485
+ >
486
+ <Icon iconName={'ArrowUUpLeft'} />
487
+ </IconButton>
488
+ <Divider vertical inverse class="!h-5" />
489
+ <Button
490
+ variant="transparent-inverse"
491
+ size="sm"
492
+ onClick={reset}
493
+ disabled={marks.length === 0 || disabled}
494
+ accessibilityLabel="Reset all marks"
495
+ class="!text-primary-inverse"
496
+ >
497
+ Clear all
498
+ </Button>
499
+ </div>
500
+ {/if}
501
+ {/snippet}
502
+
503
+ {#snippet ZoomControls()}
504
+ <div
505
+ 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"
506
+ >
507
+ <IconButton
508
+ variant="transparent-inverse"
509
+ rounded={false}
510
+ onclick={zoomIn}
511
+ aria-label="Zoom In"
512
+ disabled={transform.scale >= MAX_ZOOM || disabled}
513
+ >
514
+ <Icon iconName={'Plus'} />
515
+ </IconButton>
516
+ <IconButton
517
+ variant="transparent-inverse"
518
+ rounded={false}
519
+ onclick={zoomOut}
520
+ aria-label="Zoom Out"
521
+ disabled={transform.scale <= MIN_ZOOM || disabled}
522
+ >
523
+ <Icon iconName={'Minus'} />
524
+ </IconButton>
525
+ </div>
526
+ {/snippet}
527
+
528
+ <div bind:this={container} class="group relative h-full w-full overflow-hidden rounded-lg border">
529
+ {#if !hideMarkers && !disabled}
530
+ {@render TopLeftActions()}
531
+ {@render ZoomControls()}
532
+ {/if}
533
+
534
+ <!--
535
+ We need to use SVG for this interactive component.
536
+ The SVG element is treated as a canvas for clicking, panning, and zooming.
537
+ We add accessibility attributes to make it more accessible despite the interactive nature.
538
+ -->
539
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
540
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
541
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
542
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
543
+ <svg
544
+ bind:this={svgElement}
545
+ onclick={handleClick}
546
+ onmousedown={handleMouseDown}
547
+ onmousemove={handleMouseMove}
548
+ onmouseup={handleMouseUp}
549
+ onmouseleave={handleMouseLeave}
550
+ oncontextmenu={handleContextMenu}
551
+ onwheel={handleWheel}
552
+ class:cursor-grabbing={panningState === 'active'}
553
+ class:cursor-grab={!disabled && (panningState === 'ready' || (isShiftPressed && !panningState))}
554
+ class:cursor-not-allowed={disabled}
555
+ class:cursor-crosshair={!disabled && !panningState && !isShiftPressed}
556
+ class="h-full w-full"
557
+ role="application"
558
+ aria-label={disabled
559
+ ? 'CFU Counter (disabled)'
560
+ : 'CFU Counter - Click to add markers, right click or shift+click to pan'}
561
+ tabindex="0"
562
+ >
563
+ <g bind:this={viewport} id="viewport" class="h-full w-full">
564
+ <image href={imageUrl} x="0" y="0" width="100%" />
565
+ <g
566
+ bind:this={dotsGroup}
567
+ id="dots"
568
+ class="pointer-events-none"
569
+ aria-hidden={hideMarkers}
570
+ class:hidden={hideMarkers}
571
+ />
572
+ </g>
573
+ </svg>
574
+
575
+ <!-- Debug info for marks count - useful for testing -->
576
+ <span class="sr-only" data-testid="marks-count">{marks.length}</span>
577
+ </div>