@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.
- package/README.md +3 -1
- package/dist/app.css +97 -97
- package/dist/components/activity/Activity.stories.svelte +104 -104
- package/dist/components/activity/Activity.svelte +112 -112
- package/dist/components/avatar/Avatar.stories.svelte +23 -23
- package/dist/components/avatar/Avatar.svelte +54 -54
- package/dist/components/banner/Banner.stories.svelte +105 -105
- package/dist/components/banner/Banner.svelte +42 -42
- package/dist/components/button/Button.stories.svelte +61 -61
- package/dist/components/button/Button.svelte +95 -95
- package/dist/components/card/Card.stories.svelte +112 -112
- package/dist/components/card/Card.svelte +18 -18
- package/dist/components/checkbox/Checkbox.stories.svelte +8 -8
- package/dist/components/checkbox/Checkbox.svelte +17 -17
- package/dist/components/collapsible/Collapsible.stories.svelte +26 -26
- package/dist/components/collapsible/components/collapsible-content.svelte +20 -20
- package/dist/components/collapsible/components/collapsible-trigger.svelte +12 -12
- package/dist/components/combobox/Combobox.stories.svelte +412 -412
- package/dist/components/combobox/components/combobox-add.svelte +8 -8
- package/dist/components/combobox/components/combobox-content.svelte +39 -39
- package/dist/components/combobox/components/combobox-indicator.svelte +1 -1
- package/dist/components/datepicker/DatePicker.stories.svelte +196 -196
- package/dist/components/datepicker/DatePicker.svelte +173 -173
- package/dist/components/divider/Divider.stories.svelte +7 -7
- package/dist/components/divider/Divider.svelte +9 -9
- package/dist/components/drawer/Drawer.stories.svelte +51 -51
- package/dist/components/drawer/Drawer.svelte +33 -33
- package/dist/components/drawer/DrawerLabel.svelte +10 -10
- package/dist/components/dropdown/Dropdown.stories.svelte +210 -210
- package/dist/components/dropdown/Dropdown.svelte +57 -57
- package/dist/components/dropdown/components/DropdownContent.svelte +16 -16
- package/dist/components/dropdown/components/DropdownMenu.svelte +10 -10
- package/dist/components/dropdown/components/DropdownTrigger.svelte +37 -37
- package/dist/components/dropdown/components/OutlinedButton.svelte +9 -9
- package/dist/components/empty-content/EmptyContent.stories.svelte +38 -38
- package/dist/components/empty-content/EmptyContent.svelte +12 -12
- package/dist/components/graphs/bar-chart/BarChart.stories.svelte +91 -91
- package/dist/components/graphs/bar-chart/BarChart.svelte +147 -147
- package/dist/components/graphs/bar-chart/StackedBarChart.stories.svelte +57 -57
- package/dist/components/graphs/bar-chart/StackedBarChart.svelte +198 -199
- package/dist/components/graphs/chart/Chart.stories.svelte +96 -96
- package/dist/components/graphs/chart/Chart.svelte +207 -207
- package/dist/components/graphs/line/LineChart.stories.svelte +138 -138
- package/dist/components/graphs/line/LineChart.svelte +140 -142
- package/dist/components/graphs/matrix/Matrix.stories.svelte +117 -117
- package/dist/components/graphs/matrix/Matrix.svelte +141 -141
- package/dist/components/graphs/multiline/MultiLineChart.stories.svelte +168 -168
- package/dist/components/graphs/multiline/MultiLineChart.svelte +236 -236
- package/dist/components/graphs/scatterplot/Scatterplot.stories.svelte +84 -84
- package/dist/components/graphs/scatterplot/Scatterplot.svelte +302 -302
- package/dist/components/graphs/utils/duration.d.ts +1 -0
- package/dist/components/graphs/utils/duration.js +33 -0
- package/dist/components/graphs/utils/tooltipFormatter.js +1 -1
- package/dist/components/icon-button/IconButton.stories.svelte +64 -64
- package/dist/components/icon-button/IconButton.svelte +88 -88
- package/dist/components/icons/AnalysisIcon.stories.svelte +18 -18
- package/dist/components/icons/AnalysisIcon.svelte +96 -96
- package/dist/components/icons/Icon.stories.svelte +111 -111
- package/dist/components/icons/Icon.svelte +17 -17
- package/dist/components/icons/PrincipalIcon.svelte +59 -59
- package/dist/components/icons/custom/Halo.svelte +31 -31
- package/dist/components/icons/custom/Well.svelte +27 -27
- package/dist/components/icons/index.d.ts +3 -2
- package/dist/components/icons/index.js +3 -1
- package/dist/components/image/Image.svelte +42 -42
- package/dist/components/input/Input.stories.svelte +55 -55
- package/dist/components/input/Input.svelte +121 -121
- package/dist/components/label/Label.stories.svelte +18 -18
- package/dist/components/label/Label.svelte +11 -11
- package/dist/components/list/List.stories.svelte +84 -84
- package/dist/components/list/List.svelte +20 -20
- package/dist/components/logo/Logo.stories.svelte +15 -15
- package/dist/components/logo/Logo.svelte +30 -30
- package/dist/components/manual-cfu-counter/ManualCFUCounter.stories.svelte +102 -102
- package/dist/components/manual-cfu-counter/ManualCFUCounter.svelte +557 -565
- package/dist/components/manual-cfu-counter/test/ManualCFUCounterTestWrapper.svelte +11 -11
- package/dist/components/markdown/Markdown.stories.svelte +10 -10
- package/dist/components/markdown/Markdown.svelte +6 -6
- package/dist/components/modal/Modal.stories.svelte +29 -29
- package/dist/components/modal/Modal.svelte +71 -71
- package/dist/components/multi-cfu-counter/MultiCFUCounter.stories.svelte +215 -0
- package/dist/components/multi-cfu-counter/MultiCFUCounter.stories.svelte.d.ts +3 -0
- package/dist/components/multi-cfu-counter/MultiCFUCounter.svelte +662 -0
- package/dist/components/multi-cfu-counter/MultiCFUCounter.svelte.d.ts +32 -0
- package/dist/components/multi-cfu-counter/index.d.ts +1 -0
- package/dist/components/multi-cfu-counter/index.js +1 -0
- package/dist/components/multi-cfu-counter/test/MultiCFUCounterTestWrapper.svelte +28 -0
- package/dist/components/multi-cfu-counter/test/MultiCFUCounterTestWrapper.svelte.d.ts +20 -0
- package/dist/components/notification-popup/NotificationPopup.stories.svelte +18 -18
- package/dist/components/notification-popup/NotificationPopup.svelte +26 -26
- package/dist/components/notifications/Notifications.stories.svelte +101 -101
- package/dist/components/notifications/Notifications.svelte +9 -9
- package/dist/components/pill/Pill.stories.svelte +8 -8
- package/dist/components/pill/Pill.svelte +27 -27
- package/dist/components/progress-circle/ProgressCircle.stories.svelte +8 -8
- package/dist/components/progress-circle/ProgressCircle.svelte +54 -54
- package/dist/components/required-status-indicator/RequiredStatusIndicator.stories.svelte +18 -18
- package/dist/components/required-status-indicator/RequiredStatusIndicator.svelte +14 -14
- package/dist/components/segmented-control-buttons/ControlButton.svelte +36 -36
- package/dist/components/segmented-control-buttons/SegmentedControlButtons.stories.svelte +35 -35
- package/dist/components/segmented-control-buttons/SegmentedControlButtons.svelte +13 -13
- package/dist/components/select/Select.stories.svelte +77 -77
- package/dist/components/select/Select.svelte +114 -114
- package/dist/components/select-new/Select.stories.svelte +188 -188
- package/dist/components/select-new/components/Group.svelte +17 -17
- package/dist/components/select-new/components/MultiSelectTrigger.svelte +57 -57
- package/dist/components/select-new/components/SelectContent.svelte +22 -22
- package/dist/components/select-new/components/SelectGroupHeading.svelte +10 -10
- package/dist/components/select-new/components/SelectItem.svelte +25 -25
- package/dist/components/select-new/components/SelectTrigger.svelte +38 -38
- package/dist/components/sjsf-wrappers/SjsfNumberInputWrapper.svelte +76 -76
- package/dist/components/sjsf-wrappers/SjsfNumberInputWrapper.svelte.d.ts +1 -1
- package/dist/components/sjsf-wrappers/SjsfTextInputWrapper.svelte +53 -53
- package/dist/components/sjsf-wrappers/SjsfTextInputWrapper.svelte.d.ts +1 -1
- package/dist/components/sjsf-wrappers/sjsfCustomTheme.js +1 -1
- package/dist/components/skeleton-loader/SkeletonLoader.stories.svelte +32 -32
- package/dist/components/skeleton-loader/SkeletonLoader.svelte +10 -10
- package/dist/components/skeleton-loader/StatcardSkeleton.svelte +9 -9
- package/dist/components/skeleton-loader/components/Skeleton.svelte +7 -7
- package/dist/components/skeleton-loader/components/SkeletonImage.svelte +12 -12
- package/dist/components/slider/Slider.stories.svelte +23 -23
- package/dist/components/slider/Slider.svelte +107 -107
- package/dist/components/spinner/Spinner.stories.svelte +8 -8
- package/dist/components/spinner/Spinner.svelte +18 -18
- package/dist/components/stat-card/StatCard.stories.svelte +26 -26
- package/dist/components/stat-card/StatCard.svelte +128 -128
- package/dist/components/status-badge/StatusBadge.stories.svelte +365 -365
- package/dist/components/status-badge/StatusBadge.svelte +54 -54
- package/dist/components/stepper/Stepper.stories.svelte +219 -219
- package/dist/components/stepper/components/stepper-root.svelte +12 -12
- package/dist/components/stepper/components/stepper-step.svelte +83 -83
- package/dist/components/table/Table.stories.svelte +87 -87
- package/dist/components/table/Table.svelte +32 -32
- package/dist/components/table/components/TBody.svelte +7 -7
- package/dist/components/table/components/THead.svelte +7 -7
- package/dist/components/table/components/Td.svelte +8 -8
- package/dist/components/table/components/Th.svelte +8 -8
- package/dist/components/table/components/Tr.svelte +11 -11
- package/dist/components/tabs/Tabs.stories.svelte +20 -20
- package/dist/components/tabs/Tabs.svelte +8 -8
- package/dist/components/tabs/components/Content.svelte +8 -8
- package/dist/components/tabs/components/Tab.svelte +14 -14
- package/dist/components/tabs/components/Tabs.svelte +7 -7
- package/dist/components/tag/Tag.stories.svelte +57 -57
- package/dist/components/tag/Tag.svelte +95 -95
- package/dist/components/textarea/Textarea.stories.svelte +70 -70
- package/dist/components/textarea/Textarea.svelte +76 -76
- package/dist/components/toast/Toast.stories.svelte +204 -204
- package/dist/components/toast/Toast.svelte +53 -53
- package/dist/components/toggle/Toggle.stories.svelte +9 -9
- package/dist/components/toggle/Toggle.svelte +53 -53
- package/dist/components/toggle-icon-button/ToggleIconButton.stories.svelte +152 -152
- package/dist/components/toggle-icon-button/ToggleIconButton.svelte +99 -99
- package/dist/components/tooltip/Tooltip.stories.svelte +105 -105
- package/dist/components/tooltip/Tooltip.svelte +26 -26
- package/dist/fonts/index.js +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.js +5 -1
- package/dist/notifications.d.ts +1 -4
- package/dist/notifications.js +1 -1
- package/dist/styles.d.ts +1 -0
- package/dist/styles.js +4 -0
- package/dist/tailwind-safelist.js +406 -398
- package/dist/tailwind.preset.d.ts +4 -0
- package/dist/tailwind.preset.js +10 -10
- package/dist/tokens/colors.d.ts +246 -0
- package/dist/tokens/colors.js +139 -0
- package/dist/tokens/index.d.ts +3 -0
- package/dist/tokens/index.js +5 -0
- package/dist/tokens/typography.d.ts +48 -0
- package/dist/tokens/typography.js +48 -0
- package/dist/tokens.d.ts +16 -252
- package/dist/tokens.js +33 -164
- package/dist/types/fonts.d.ts +2 -2
- 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';
|