supervisely 6.73.438__py3-none-any.whl → 6.73.513__py3-none-any.whl

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 (203) hide show
  1. supervisely/__init__.py +137 -1
  2. supervisely/_utils.py +81 -0
  3. supervisely/annotation/annotation.py +8 -2
  4. supervisely/annotation/json_geometries_map.py +14 -11
  5. supervisely/annotation/label.py +80 -3
  6. supervisely/api/annotation_api.py +14 -11
  7. supervisely/api/api.py +59 -38
  8. supervisely/api/app_api.py +11 -2
  9. supervisely/api/dataset_api.py +74 -12
  10. supervisely/api/entities_collection_api.py +10 -0
  11. supervisely/api/entity_annotation/figure_api.py +52 -4
  12. supervisely/api/entity_annotation/object_api.py +3 -3
  13. supervisely/api/entity_annotation/tag_api.py +63 -12
  14. supervisely/api/guides_api.py +210 -0
  15. supervisely/api/image_api.py +72 -1
  16. supervisely/api/labeling_job_api.py +83 -1
  17. supervisely/api/labeling_queue_api.py +33 -7
  18. supervisely/api/module_api.py +9 -0
  19. supervisely/api/project_api.py +71 -26
  20. supervisely/api/storage_api.py +3 -1
  21. supervisely/api/task_api.py +13 -2
  22. supervisely/api/team_api.py +4 -3
  23. supervisely/api/video/video_annotation_api.py +119 -3
  24. supervisely/api/video/video_api.py +65 -14
  25. supervisely/api/video/video_figure_api.py +24 -11
  26. supervisely/app/__init__.py +1 -1
  27. supervisely/app/content.py +23 -7
  28. supervisely/app/development/development.py +18 -2
  29. supervisely/app/fastapi/__init__.py +1 -0
  30. supervisely/app/fastapi/custom_static_files.py +1 -1
  31. supervisely/app/fastapi/multi_user.py +105 -0
  32. supervisely/app/fastapi/subapp.py +88 -42
  33. supervisely/app/fastapi/websocket.py +77 -9
  34. supervisely/app/singleton.py +21 -0
  35. supervisely/app/v1/app_service.py +18 -2
  36. supervisely/app/v1/constants.py +7 -1
  37. supervisely/app/widgets/__init__.py +6 -0
  38. supervisely/app/widgets/activity_feed/__init__.py +0 -0
  39. supervisely/app/widgets/activity_feed/activity_feed.py +239 -0
  40. supervisely/app/widgets/activity_feed/style.css +78 -0
  41. supervisely/app/widgets/activity_feed/template.html +22 -0
  42. supervisely/app/widgets/card/card.py +20 -0
  43. supervisely/app/widgets/classes_list_selector/classes_list_selector.py +121 -9
  44. supervisely/app/widgets/classes_list_selector/template.html +60 -93
  45. supervisely/app/widgets/classes_mapping/classes_mapping.py +13 -12
  46. supervisely/app/widgets/classes_table/classes_table.py +1 -0
  47. supervisely/app/widgets/deploy_model/deploy_model.py +56 -35
  48. supervisely/app/widgets/dialog/dialog.py +12 -0
  49. supervisely/app/widgets/dialog/template.html +2 -1
  50. supervisely/app/widgets/ecosystem_model_selector/ecosystem_model_selector.py +1 -1
  51. supervisely/app/widgets/experiment_selector/experiment_selector.py +8 -0
  52. supervisely/app/widgets/fast_table/fast_table.py +184 -60
  53. supervisely/app/widgets/fast_table/template.html +1 -1
  54. supervisely/app/widgets/heatmap/__init__.py +0 -0
  55. supervisely/app/widgets/heatmap/heatmap.py +564 -0
  56. supervisely/app/widgets/heatmap/script.js +533 -0
  57. supervisely/app/widgets/heatmap/style.css +233 -0
  58. supervisely/app/widgets/heatmap/template.html +21 -0
  59. supervisely/app/widgets/modal/__init__.py +0 -0
  60. supervisely/app/widgets/modal/modal.py +198 -0
  61. supervisely/app/widgets/modal/template.html +10 -0
  62. supervisely/app/widgets/object_class_view/object_class_view.py +3 -0
  63. supervisely/app/widgets/radio_tabs/radio_tabs.py +18 -2
  64. supervisely/app/widgets/radio_tabs/template.html +1 -0
  65. supervisely/app/widgets/select/select.py +6 -3
  66. supervisely/app/widgets/select_class/__init__.py +0 -0
  67. supervisely/app/widgets/select_class/select_class.py +363 -0
  68. supervisely/app/widgets/select_class/template.html +50 -0
  69. supervisely/app/widgets/select_cuda/select_cuda.py +22 -0
  70. supervisely/app/widgets/select_dataset_tree/select_dataset_tree.py +65 -7
  71. supervisely/app/widgets/select_tag/__init__.py +0 -0
  72. supervisely/app/widgets/select_tag/select_tag.py +352 -0
  73. supervisely/app/widgets/select_tag/template.html +64 -0
  74. supervisely/app/widgets/select_team/select_team.py +37 -4
  75. supervisely/app/widgets/select_team/template.html +4 -5
  76. supervisely/app/widgets/select_user/__init__.py +0 -0
  77. supervisely/app/widgets/select_user/select_user.py +270 -0
  78. supervisely/app/widgets/select_user/template.html +13 -0
  79. supervisely/app/widgets/select_workspace/select_workspace.py +59 -10
  80. supervisely/app/widgets/select_workspace/template.html +9 -12
  81. supervisely/app/widgets/table/table.py +68 -13
  82. supervisely/app/widgets/tree_select/tree_select.py +2 -0
  83. supervisely/aug/aug.py +6 -2
  84. supervisely/convert/base_converter.py +1 -0
  85. supervisely/convert/converter.py +2 -2
  86. supervisely/convert/image/csv/csv_converter.py +24 -15
  87. supervisely/convert/image/image_converter.py +3 -1
  88. supervisely/convert/image/image_helper.py +48 -4
  89. supervisely/convert/image/label_studio/label_studio_converter.py +2 -0
  90. supervisely/convert/image/medical2d/medical2d_helper.py +2 -24
  91. supervisely/convert/image/multispectral/multispectral_converter.py +6 -0
  92. supervisely/convert/image/pascal_voc/pascal_voc_converter.py +8 -5
  93. supervisely/convert/image/pascal_voc/pascal_voc_helper.py +7 -0
  94. supervisely/convert/pointcloud/kitti_3d/kitti_3d_converter.py +33 -3
  95. supervisely/convert/pointcloud/kitti_3d/kitti_3d_helper.py +12 -5
  96. supervisely/convert/pointcloud/las/las_converter.py +13 -1
  97. supervisely/convert/pointcloud/las/las_helper.py +110 -11
  98. supervisely/convert/pointcloud/nuscenes_conv/nuscenes_converter.py +27 -16
  99. supervisely/convert/pointcloud/pointcloud_converter.py +91 -3
  100. supervisely/convert/pointcloud_episodes/nuscenes_conv/nuscenes_converter.py +58 -22
  101. supervisely/convert/pointcloud_episodes/nuscenes_conv/nuscenes_helper.py +21 -47
  102. supervisely/convert/video/__init__.py +1 -0
  103. supervisely/convert/video/multi_view/__init__.py +0 -0
  104. supervisely/convert/video/multi_view/multi_view.py +543 -0
  105. supervisely/convert/video/sly/sly_video_converter.py +359 -3
  106. supervisely/convert/video/video_converter.py +24 -4
  107. supervisely/convert/volume/dicom/dicom_converter.py +13 -5
  108. supervisely/convert/volume/dicom/dicom_helper.py +30 -18
  109. supervisely/geometry/constants.py +1 -0
  110. supervisely/geometry/geometry.py +4 -0
  111. supervisely/geometry/helpers.py +5 -1
  112. supervisely/geometry/oriented_bbox.py +676 -0
  113. supervisely/geometry/polyline_3d.py +110 -0
  114. supervisely/geometry/rectangle.py +2 -1
  115. supervisely/io/env.py +76 -1
  116. supervisely/io/fs.py +21 -0
  117. supervisely/nn/benchmark/base_evaluator.py +104 -11
  118. supervisely/nn/benchmark/instance_segmentation/evaluator.py +1 -8
  119. supervisely/nn/benchmark/object_detection/evaluator.py +20 -4
  120. supervisely/nn/benchmark/object_detection/vis_metrics/pr_curve.py +10 -5
  121. supervisely/nn/benchmark/semantic_segmentation/evaluator.py +34 -16
  122. supervisely/nn/benchmark/semantic_segmentation/vis_metrics/confusion_matrix.py +1 -1
  123. supervisely/nn/benchmark/semantic_segmentation/vis_metrics/frequently_confused.py +1 -1
  124. supervisely/nn/benchmark/semantic_segmentation/vis_metrics/overview.py +1 -1
  125. supervisely/nn/benchmark/visualization/evaluation_result.py +66 -4
  126. supervisely/nn/inference/cache.py +43 -18
  127. supervisely/nn/inference/gui/serving_gui_template.py +5 -2
  128. supervisely/nn/inference/inference.py +916 -222
  129. supervisely/nn/inference/inference_request.py +55 -10
  130. supervisely/nn/inference/predict_app/gui/classes_selector.py +83 -12
  131. supervisely/nn/inference/predict_app/gui/gui.py +676 -488
  132. supervisely/nn/inference/predict_app/gui/input_selector.py +205 -26
  133. supervisely/nn/inference/predict_app/gui/model_selector.py +2 -4
  134. supervisely/nn/inference/predict_app/gui/output_selector.py +46 -6
  135. supervisely/nn/inference/predict_app/gui/settings_selector.py +756 -59
  136. supervisely/nn/inference/predict_app/gui/tags_selector.py +1 -1
  137. supervisely/nn/inference/predict_app/gui/utils.py +236 -119
  138. supervisely/nn/inference/predict_app/predict_app.py +2 -2
  139. supervisely/nn/inference/session.py +43 -35
  140. supervisely/nn/inference/tracking/bbox_tracking.py +118 -35
  141. supervisely/nn/inference/tracking/point_tracking.py +5 -1
  142. supervisely/nn/inference/tracking/tracker_interface.py +10 -1
  143. supervisely/nn/inference/uploader.py +139 -12
  144. supervisely/nn/live_training/__init__.py +7 -0
  145. supervisely/nn/live_training/api_server.py +111 -0
  146. supervisely/nn/live_training/artifacts_utils.py +243 -0
  147. supervisely/nn/live_training/checkpoint_utils.py +229 -0
  148. supervisely/nn/live_training/dynamic_sampler.py +44 -0
  149. supervisely/nn/live_training/helpers.py +14 -0
  150. supervisely/nn/live_training/incremental_dataset.py +146 -0
  151. supervisely/nn/live_training/live_training.py +497 -0
  152. supervisely/nn/live_training/loss_plateau_detector.py +111 -0
  153. supervisely/nn/live_training/request_queue.py +52 -0
  154. supervisely/nn/model/model_api.py +9 -0
  155. supervisely/nn/model/prediction.py +2 -1
  156. supervisely/nn/model/prediction_session.py +26 -14
  157. supervisely/nn/prediction_dto.py +19 -1
  158. supervisely/nn/tracker/base_tracker.py +11 -1
  159. supervisely/nn/tracker/botsort/botsort_config.yaml +0 -1
  160. supervisely/nn/tracker/botsort/tracker/mc_bot_sort.py +7 -4
  161. supervisely/nn/tracker/botsort_tracker.py +94 -65
  162. supervisely/nn/tracker/utils.py +4 -5
  163. supervisely/nn/tracker/visualize.py +93 -93
  164. supervisely/nn/training/gui/classes_selector.py +16 -1
  165. supervisely/nn/training/gui/train_val_splits_selector.py +52 -31
  166. supervisely/nn/training/train_app.py +46 -31
  167. supervisely/project/data_version.py +115 -51
  168. supervisely/project/download.py +1 -1
  169. supervisely/project/pointcloud_episode_project.py +37 -8
  170. supervisely/project/pointcloud_project.py +30 -2
  171. supervisely/project/project.py +14 -2
  172. supervisely/project/project_meta.py +27 -1
  173. supervisely/project/project_settings.py +32 -18
  174. supervisely/project/versioning/__init__.py +1 -0
  175. supervisely/project/versioning/common.py +20 -0
  176. supervisely/project/versioning/schema_fields.py +35 -0
  177. supervisely/project/versioning/video_schema.py +221 -0
  178. supervisely/project/versioning/volume_schema.py +87 -0
  179. supervisely/project/video_project.py +717 -15
  180. supervisely/project/volume_project.py +623 -5
  181. supervisely/template/experiment/experiment.html.jinja +4 -4
  182. supervisely/template/experiment/experiment_generator.py +14 -21
  183. supervisely/template/live_training/__init__.py +0 -0
  184. supervisely/template/live_training/header.html.jinja +96 -0
  185. supervisely/template/live_training/live_training.html.jinja +51 -0
  186. supervisely/template/live_training/live_training_generator.py +464 -0
  187. supervisely/template/live_training/sly-style.css +402 -0
  188. supervisely/template/live_training/template.html.jinja +18 -0
  189. supervisely/versions.json +28 -26
  190. supervisely/video/sampling.py +39 -20
  191. supervisely/video/video.py +41 -12
  192. supervisely/video_annotation/video_figure.py +38 -4
  193. supervisely/video_annotation/video_object.py +29 -4
  194. supervisely/volume/stl_converter.py +2 -0
  195. supervisely/worker_api/agent_rpc.py +24 -1
  196. supervisely/worker_api/rpc_servicer.py +31 -7
  197. {supervisely-6.73.438.dist-info → supervisely-6.73.513.dist-info}/METADATA +58 -40
  198. {supervisely-6.73.438.dist-info → supervisely-6.73.513.dist-info}/RECORD +203 -155
  199. {supervisely-6.73.438.dist-info → supervisely-6.73.513.dist-info}/WHEEL +1 -1
  200. supervisely_lib/__init__.py +6 -1
  201. {supervisely-6.73.438.dist-info → supervisely-6.73.513.dist-info}/entry_points.txt +0 -0
  202. {supervisely-6.73.438.dist-info → supervisely-6.73.513.dist-info/licenses}/LICENSE +0 -0
  203. {supervisely-6.73.438.dist-info → supervisely-6.73.513.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,533 @@
1
+ Vue.component('heatmap-image', {
2
+ template: `
3
+ <div
4
+ class="heatmap-container"
5
+ :style="widthStyle"
6
+ @click="$emit('click')"
7
+ >
8
+ <div class="heatmap-header">
9
+ <div class="opacity-slider" @click.stop>
10
+ <div class="opacity-label">
11
+ <span class="opacity-label-text">Opacity:</span>
12
+ <span class="opacity-value">{{opacity}}%</span>
13
+ </div>
14
+
15
+ <el-slider
16
+ type="range"
17
+ :min="0"
18
+ :max="100"
19
+ :step="1"
20
+ :value="opacity"
21
+ @input="$emit('update:opacity', $event)"
22
+ class="slider"
23
+ >
24
+ </div>
25
+
26
+ <div class="legend" @click.stop>
27
+ <span class="legend-label legend-min">{{ formatValue(minValue) }}</span>
28
+ <div class="legend-gradient" :style="{ background: gradientStyle }"></div>
29
+ <span class="legend-label legend-max">{{ formatValue(maxValue) }}</span>
30
+ </div>
31
+ </div>
32
+ <div class="image-container" :style="imageContainerStyle">
33
+ <div
34
+ class="image-wrapper"
35
+ ref="wrapper"
36
+ :style="imageWrapperStyle"
37
+ @click.stop="handleImageClick"
38
+ @mouseleave="handleMouseLeave"
39
+ @mousedown="handleMouseDown"
40
+ @mousemove="handleMouseMove"
41
+ @mouseup="handleMouseUp"
42
+ @wheel="handleWheel"
43
+ >
44
+ <img
45
+ class="base-image"
46
+ :src="backgroundUrl"
47
+ @load="handleImageLoad"
48
+ draggable="false"
49
+ >
50
+ <img
51
+ class="overlay-image"
52
+ :style="{ opacity: opacity / 100 }"
53
+ :src="maskUrl"
54
+ draggable="false"
55
+ >
56
+
57
+ <div
58
+ v-if="clickedValue !== null"
59
+ class="click-indicator"
60
+ :class="{ 'hiding': isHiding }"
61
+ :style="indicatorStyle"
62
+ >
63
+ <div class="click-dot"></div>
64
+ </div>
65
+
66
+ <div
67
+ v-if="clickedValue !== null"
68
+ class="value-popup"
69
+ :class="['popup-position-' + popupPosition, { 'hiding': isHiding }]"
70
+ :style="popupStyle"
71
+ >
72
+ <div class="value-popup-content">
73
+ <span class="value-popup-value">{{ formatValue(clickedValue) }}</span>
74
+ </div>
75
+ <div class="value-popup-arrow"></div>
76
+ </div>
77
+ </div>
78
+ </div>
79
+ `,
80
+ data() {
81
+ return {
82
+ naturalWidth: null,
83
+ naturalHeight: null,
84
+ clickX: 0,
85
+ clickY: 0,
86
+ popupX: 0,
87
+ popupY: 0,
88
+ popupPosition: 'top', // 'top', 'bottom', 'right', 'left'
89
+ isHiding: false, // Flag for fade-out animation
90
+ zoom: 1.0,
91
+ panX: 0,
92
+ panY: 0,
93
+ isDragging: false,
94
+ hasDragged: false,
95
+ dragStartX: 0,
96
+ dragStartY: 0,
97
+ dragStartPanX: 0,
98
+ dragStartPanY: 0,
99
+ };
100
+ },
101
+ computed: {
102
+ gradientStyle() {
103
+ return `linear-gradient(to right, ${this.legendColors.join(', ')})`;
104
+ },
105
+ widthStyle() {
106
+ const styles = {};
107
+
108
+ if (this.width) {
109
+ styles.width = typeof this.width === 'number' ? `${this.width}px` : this.width;
110
+ } else if (this.height) {
111
+ // Use naturalWidth/Height from loaded image, or fallback to maskWidth/Height
112
+ const effectiveWidth = this.naturalWidth || this.maskWidth;
113
+ const effectiveHeight = this.naturalHeight || this.maskHeight;
114
+
115
+ if (effectiveWidth && effectiveHeight) {
116
+ const heightValue = typeof this.height === 'number' ? this.height : parseFloat(this.height);
117
+ const aspectRatio = effectiveWidth / effectiveHeight;
118
+ styles.width = `${heightValue * aspectRatio}px`;
119
+ }
120
+ }
121
+
122
+ return styles;
123
+ },
124
+ imageWrapperStyle() {
125
+ return {
126
+ cursor: this.zoom > 1 ? (this.isDragging ? 'grabbing' : 'grab') : 'pointer',
127
+ transform: `scale(${this.zoom}) translate(${this.panX}px, ${this.panY}px)`, // Swapped order
128
+ transformOrigin: 'center center',
129
+ transition: this.isDragging ? 'none' : 'transform 0.1s ease-out',
130
+ width: '100%',
131
+ height: '100%'
132
+ };
133
+ },
134
+ indicatorStyle() {
135
+ return {
136
+ left: `${this.clickX}px`,
137
+ top: `${this.clickY}px`,
138
+ transform: `translate(-50%, -50%) scale(${1 / this.zoom})`
139
+ };
140
+ },
141
+ popupStyle() {
142
+ const baseStyle = {
143
+ left: `${this.popupX}px`,
144
+ top: `${this.popupY}px`
145
+ };
146
+
147
+ // Adjust transform based on position
148
+ const inverseScale = 1 / this.zoom;
149
+ switch (this.popupPosition) {
150
+ case 'top':
151
+ baseStyle.transform = `translate(-50%, -50%) scale(${inverseScale}) translate(0, -100%)`;
152
+ break;
153
+ case 'bottom':
154
+ baseStyle.transform = `translate(-50%, -50%) scale(${inverseScale}) translate(0, 100%)`;
155
+ break;
156
+ case 'right':
157
+ baseStyle.transform = `translate(-50%, -50%) scale(${inverseScale}) translate(calc(50% + 16px), 0)`;
158
+ break;
159
+ case 'left':
160
+ baseStyle.transform = `translate(-50%, -50%) scale(${inverseScale}) translate(calc(-50% - 16px), 0)`;
161
+ break;
162
+ }
163
+
164
+ return baseStyle;
165
+ },
166
+ imageContainerStyle() {
167
+ const styles = {
168
+ overflow: 'hidden',
169
+ position: 'relative',
170
+ maxWidth: '100%'
171
+ };
172
+
173
+ const hasWidth = this.width !== undefined && this.width !== null;
174
+ const hasHeight = this.height !== undefined && this.height !== null;
175
+
176
+ if (hasWidth && hasHeight) {
177
+ styles.width = typeof this.width === 'number' ? `${this.width}px` : this.width;
178
+ styles.height = typeof this.height === 'number' ? `${this.height}px` : this.height;
179
+ return styles;
180
+ }
181
+
182
+ if (hasHeight) {
183
+ styles.maxHeight = typeof this.height === 'number' ? `${this.height}px` : this.height;
184
+ }
185
+
186
+ const effectiveWidth = this.naturalWidth || this.maskWidth;
187
+ const effectiveHeight = this.naturalHeight || this.maskHeight;
188
+
189
+ if (effectiveWidth && effectiveHeight) {
190
+ styles.aspectRatio = `${effectiveWidth} / ${effectiveHeight}`;
191
+ }
192
+
193
+ return styles;
194
+ },
195
+ },
196
+ methods: {
197
+ handleImageLoad(event) {
198
+ this.naturalWidth = event.target.naturalWidth;
199
+ this.naturalHeight = event.target.naturalHeight;
200
+ },
201
+ handleImageClick(event) {
202
+ if (this.isDragging || this.hasDragged) return;
203
+
204
+ const wrapper = this.$refs.wrapper;
205
+ if (!wrapper) {
206
+ console.warn('[Heatmap] Wrapper not found', { maskUrl: this.maskUrl, backgroundUrl: this.backgroundUrl });
207
+ return;
208
+ }
209
+
210
+ // Get image element first to calculate position relative to actual image
211
+ const imgEl = wrapper.querySelector('.overlay-image');
212
+ if (!imgEl) {
213
+ console.warn('[Heatmap] Overlay image element not found', { maskUrl: this.maskUrl, backgroundUrl: this.backgroundUrl });
214
+ return;
215
+ }
216
+
217
+ const container = wrapper.parentElement; // Use parent container which doesn't transform
218
+
219
+ const imgRect = imgEl.getBoundingClientRect();
220
+
221
+ // Get click position relative to actual image (not wrapper!)
222
+ const relativeX = event.clientX - imgRect.left;
223
+ const relativeY = event.clientY - imgRect.top;
224
+
225
+ // Check if click is within image bounds with small tolerance for edge cases
226
+ const tolerance = 1; // 1px tolerance for edge clicks with browser zoom
227
+ if (relativeX < -tolerance || relativeY < -tolerance ||
228
+ relativeX > imgRect.width + tolerance || relativeY > imgRect.height + tolerance) {
229
+ return;
230
+ }
231
+
232
+ // Clamp coordinates to image bounds (handle edge cases from browser zoom)
233
+ const clampedX = Math.max(0, Math.min(relativeX, imgRect.width - 0.01));
234
+ const clampedY = Math.max(0, Math.min(relativeY, imgRect.height - 0.01));
235
+
236
+ // Set visual indicator position (relative to image, not wrapper)
237
+ this.clickX = clampedX / this.zoom;
238
+ this.clickY = clampedY / this.zoom;
239
+ this.popupX = clampedX / this.zoom;
240
+ this.popupY = clampedY / this.zoom;
241
+
242
+ // Determine best popup position based on click location
243
+ const popupHeight = 40; // Approximate popup height
244
+ const popupWidth = 80; // Approximate popup width (half width for centered popup)
245
+ const margin = 20; // Minimum margin from edges
246
+
247
+ const containerRect = container.getBoundingClientRect();
248
+ const screenClickX = event.clientX - containerRect.left;
249
+ const screenClickY = event.clientY - containerRect.top;
250
+
251
+ // Check available space in each direction (relative to image, not wrapper)
252
+ const spaceTop = screenClickY;
253
+ const spaceBottom = containerRect.height - screenClickY;
254
+ const spaceLeft = screenClickX;
255
+ const spaceRight = containerRect.width - screenClickX;
256
+
257
+ // Check if popup would overflow horizontally when positioned top/bottom
258
+ const wouldOverflowLeft = screenClickX < (popupWidth / 2);
259
+ const wouldOverflowRight = (containerRect.width - screenClickX) < (popupWidth / 2);
260
+
261
+ // Logic: prefer top, but if at edges use left/right
262
+ if (spaceTop > popupHeight + margin && !wouldOverflowLeft && !wouldOverflowRight) {
263
+ // Enough space on top and won't overflow horizontally
264
+ this.popupPosition = 'top';
265
+ }
266
+ else if (wouldOverflowRight && spaceLeft > popupWidth + margin) {
267
+ // Point is at right edge, show popup on left
268
+ this.popupPosition = 'left';
269
+ }
270
+ else if (wouldOverflowLeft && spaceRight > popupWidth + margin) {
271
+ // Point is at left edge, show popup on right
272
+ this.popupPosition = 'right';
273
+ }
274
+ else if (spaceTop > popupHeight + margin) {
275
+ // Use top even if might slightly overflow (better than nothing)
276
+ this.popupPosition = 'top';
277
+ }
278
+ else if (spaceBottom > popupHeight + margin && !wouldOverflowLeft && !wouldOverflowRight) {
279
+ // If no space on top, show popup below (if won't overflow)
280
+ this.popupPosition = 'bottom';
281
+ }
282
+ else if (spaceRight > popupWidth + margin) {
283
+ // Show on right if there's space
284
+ this.popupPosition = 'right';
285
+ }
286
+ else if (spaceLeft > popupWidth + margin) {
287
+ // Show on left if there's space
288
+ this.popupPosition = 'left';
289
+ }
290
+ else if (spaceBottom > popupHeight + margin) {
291
+ // Fallback to bottom even if might overflow
292
+ this.popupPosition = 'bottom';
293
+ }
294
+ else {
295
+ // Final fallback: top
296
+ this.popupPosition = 'top';
297
+ }
298
+
299
+ // Use mask dimensions from server
300
+ const maskWidth = this.maskWidth;
301
+ const maskHeight = this.maskHeight;
302
+
303
+ if (!maskWidth || !maskHeight) {
304
+ console.warn('[Heatmap] Mask dimensions not available', {
305
+ maskUrl: this.maskUrl,
306
+ maskWidth: this.maskWidth,
307
+ maskHeight: this.maskHeight
308
+ });
309
+ return;
310
+ }
311
+
312
+ // Get PNG file dimensions (naturalWidth/Height of the loaded image)
313
+ const pngWidth = imgEl.naturalWidth;
314
+ const pngHeight = imgEl.naturalHeight;
315
+
316
+ if (!pngWidth || !pngHeight) {
317
+ console.warn('[Heatmap] PNG dimensions not available', {
318
+ maskUrl: this.maskUrl,
319
+ naturalWidth: imgEl.naturalWidth,
320
+ naturalHeight: imgEl.naturalHeight
321
+ });
322
+ return;
323
+ }
324
+
325
+ // Two-step coordinate transformation:
326
+ // 1. From screen coordinates to PNG coordinates
327
+ // 2. From PNG coordinates to mask coordinates
328
+
329
+ // Step 1: Scale from displayed size to PNG size
330
+ const displayToImageScaleX = pngWidth / imgRect.width;
331
+ const displayToImageScaleY = pngHeight / imgRect.height;
332
+
333
+ const pngX = clampedX * displayToImageScaleX;
334
+ const pngY = clampedY * displayToImageScaleY;
335
+
336
+ // Step 2: Scale from PNG size to mask size
337
+ const imageTomaskScaleX = maskWidth / pngWidth;
338
+ const imageTomaskScaleY = maskHeight / pngHeight;
339
+
340
+ let maskX = Math.floor(pngX * imageTomaskScaleX);
341
+ let maskY = Math.floor(pngY * imageTomaskScaleY);
342
+
343
+ // Clamp to mask bounds
344
+ maskX = Math.min(Math.max(maskX, 0), maskWidth - 1);
345
+ maskY = Math.min(Math.max(maskY, 0), maskHeight - 1);
346
+
347
+ // Update state - this will trigger server-side callback
348
+ this.$emit('update:mask-x', maskX);
349
+ this.$emit('update:mask-y', maskY);
350
+
351
+ // Reset hiding state for new click
352
+ this.isHiding = false;
353
+
354
+ // Don't set clicked-value here - server will set it after getting value from mask
355
+ this.$emit('update:clicked-value', null);
356
+
357
+ // Call server callback after Vue updates state
358
+ if (this.onImageClick) {
359
+ this.$nextTick(() => {
360
+ this.onImageClick();
361
+ });
362
+ }
363
+ },
364
+ handleMouseLeave() {
365
+ this.isDragging = false;
366
+
367
+ if (this.clickedValue === null) return;
368
+
369
+ this.isHiding = true;
370
+
371
+ setTimeout(() => {
372
+ this.$emit('update:clicked-value', null);
373
+ this.$emit('update:mask-x', null);
374
+ this.$emit('update:mask-y', null);
375
+ this.isHiding = false;
376
+ }, 300);
377
+ },
378
+ formatValue(value) {
379
+ if (value === null || value === undefined) return 'N/A';
380
+ if (Number.isInteger(value)) {
381
+ return value.toString();
382
+ }
383
+ const abs = Math.abs(value);
384
+ let decimals;
385
+ if (abs >= 1000) decimals = 1;
386
+ else if (abs >= 100) decimals = 2;
387
+ else if (abs >= 1) decimals = 3;
388
+ else if (abs >= 0.01) decimals = 4;
389
+ else decimals = 5;
390
+
391
+ return parseFloat(value.toFixed(decimals)).toString();
392
+ },
393
+ handleMouseDown(event) {
394
+ if (this.zoom > 1) {
395
+ this.isDragging = true;
396
+ this.hasDragged = false; // Reset flag
397
+ this.dragStartX = event.clientX;
398
+ this.dragStartY = event.clientY;
399
+ this.dragStartPanX = this.panX;
400
+ this.dragStartPanY = this.panY;
401
+ event.preventDefault();
402
+ }
403
+ },
404
+ calculatePanLimits() {
405
+ const wrapper = this.$refs.wrapper;
406
+ if (!wrapper) return null;
407
+
408
+ const container = wrapper.parentElement;
409
+ if (!container) return null;
410
+
411
+ const rect = container.getBoundingClientRect();
412
+ const containerWidth = rect.width;
413
+ const containerHeight = rect.height;
414
+
415
+ const imgRect = wrapper.getBoundingClientRect();
416
+ const imgWidth = imgRect.width
417
+ const imgHeight = imgRect.height
418
+
419
+ maxPanDistance = 0.2
420
+ const maxPanX = (imgWidth - containerWidth) / 2 + containerWidth * maxPanDistance;
421
+ const maxPanY = (imgHeight - containerHeight) / 2 + containerHeight * maxPanDistance;
422
+
423
+ return {
424
+ minX: -maxPanX / this.zoom,
425
+ maxX: maxPanX / this.zoom,
426
+ minY: -maxPanY / this.zoom,
427
+ maxY: maxPanY / this.zoom
428
+ };
429
+ },
430
+ handleMouseMove(event) {
431
+ if (this.isDragging) {
432
+ const deltaX = event.clientX - this.dragStartX;
433
+ const deltaY = event.clientY - this.dragStartY;
434
+
435
+ // Mark as dragged if moved more than a small threshold
436
+ if (Math.abs(deltaX) > 3 || Math.abs(deltaY) > 3) {
437
+ this.hasDragged = true;
438
+ }
439
+
440
+ let newPanX = this.dragStartPanX + deltaX / this.zoom;
441
+ let newPanY = this.dragStartPanY + deltaY / this.zoom;
442
+
443
+ const limits = this.calculatePanLimits();
444
+ if (limits) {
445
+ newPanX = Math.max(limits.minX, Math.min(limits.maxX, newPanX));
446
+ newPanY = Math.max(limits.minY, Math.min(limits.maxY, newPanY));
447
+ }
448
+
449
+ this.panX = newPanX;
450
+ this.panY = newPanY;
451
+
452
+ event.preventDefault();
453
+ }
454
+ },
455
+ handleMouseUp() {
456
+ this.isDragging = false;
457
+ },
458
+ handleWheel(event) {
459
+ event.preventDefault();
460
+
461
+ const wrapper = this.$refs.wrapper;
462
+ if (!wrapper) return;
463
+
464
+ // Use the parent container (image-container) which doesn't transform
465
+ const container = wrapper.parentElement;
466
+ if (!container) return;
467
+
468
+ const delta = -event.deltaY;
469
+ const zoomSpeed = 0.001;
470
+ const oldZoom = this.zoom;
471
+ let newZoom = oldZoom + delta * zoomSpeed;
472
+
473
+ // Clamp zoom between 1x and 10x
474
+ newZoom = Math.max(1.0, Math.min(10.0, newZoom));
475
+
476
+ if (newZoom === 1.0) {
477
+ this.zoom = 1.0;
478
+ this.panX = 0;
479
+ this.panY = 0;
480
+ this.hasDragged = false;
481
+ return;
482
+ }
483
+
484
+ if (oldZoom === newZoom) return;
485
+
486
+ // Use CONTAINER rect (which doesn't transform)
487
+ const rect = container.getBoundingClientRect();
488
+
489
+ // Mouse position relative to container center
490
+ const mouseX = event.clientX - rect.left;
491
+ const mouseY = event.clientY - rect.top;
492
+
493
+ const centerX = rect.width / 2;
494
+ const centerY = rect.height / 2;
495
+
496
+ const offsetX = mouseX - centerX;
497
+ const offsetY = mouseY - centerY;
498
+
499
+ // Adjust pan based on zoom change
500
+ this.panX = this.panX + offsetX * (1/newZoom - 1/oldZoom);
501
+ this.panY = this.panY + offsetY * (1/newZoom - 1/oldZoom);
502
+ this.zoom = newZoom;
503
+ }
504
+ },
505
+ watch: {
506
+ backgroundUrl() {
507
+ this.naturalWidth = null;
508
+ this.naturalHeight = null;
509
+ }
510
+ },
511
+ props: {
512
+ backgroundUrl: String,
513
+ maskUrl: String,
514
+ opacity: Number,
515
+ width: [Number, String],
516
+ height: [Number, String],
517
+ maskWidth: Number,
518
+ maskHeight: Number,
519
+ legendColors: {
520
+ type: Array,
521
+ default: () => ['#0000FF', '#00FF00', '#FFFF00', '#FF0000']
522
+ },
523
+ minValue: Number,
524
+ maxValue: Number,
525
+ clickedValue: Number,
526
+ maskX: Number,
527
+ maskY: Number,
528
+ onImageClick: {
529
+ type: Function,
530
+ default: null
531
+ }
532
+ },
533
+ });