optica-core 0.2.1 → 0.2.3

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 (34) hide show
  1. package/package.json +1 -6
  2. package/src/components/ui/app-menu/AppMenu.vue +1 -1
  3. package/src/components/ui/credits/Credits.vue +1 -3
  4. package/src/components/ui/distance/DistanceIteration.vue +14 -18
  5. package/src/components/ui/distance/DistanceList.vue +9 -12
  6. package/src/components/ui/distance/types.ts +8 -2
  7. package/src/components/ui/image-viewer/ImageViewer.vue +9 -3
  8. package/src/components/ui/image-viewer/types.ts +0 -2
  9. package/src/components/ui/landmark/LandmarkIteration.vue +19 -12
  10. package/src/components/ui/landmark/LandmarkList.vue +1 -1
  11. package/src/components/ui/landmark/types.ts +0 -4
  12. package/src/components/ui/project-loader/ProjectLoader.vue +21 -18
  13. package/src/config/appSettings.ts +0 -4
  14. package/src/data/models/annotations.ts +21 -9
  15. package/src/data/models/coordinates.ts +14 -10
  16. package/src/data/models/dequeMax2.ts +57 -19
  17. package/src/data/models/distance.ts +17 -29
  18. package/src/data/models/image.ts +3 -1
  19. package/src/data/models/landmark.ts +0 -35
  20. package/src/data/models/project.ts +0 -4
  21. package/src/data/providers/backend.ts +28 -0
  22. package/src/data/repositories/data_repository.ts +7 -6
  23. package/src/data/repositories/project_repository.ts +6 -4
  24. package/src/data/repositories/repository.ts +3 -2
  25. package/src/env.d.ts +0 -1
  26. package/src/index.ts +2 -3
  27. package/src/lib/interaction.ts +10 -0
  28. package/src/lib/persistence.ts +10 -5
  29. package/src/lib/utils.ts +6 -8
  30. package/tests/annotations.test.ts +65 -4
  31. package/tests/project.test.ts +21 -10
  32. package/src/data/models/marker.ts +0 -8
  33. package/src/data/providers/provider_factory.ts +0 -32
  34. package/src/data/repositories/repository_factory.ts +0 -31
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "optica-core",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Shared frontend core of the Optica viewers",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "type": "module",
@@ -31,7 +31,6 @@
31
31
  },
32
32
  "peerDependencies": {
33
33
  "@phosphor-icons/vue": "^2.2.1",
34
- "@tanstack/vue-query": "^5.51.16",
35
34
  "@vueuse/core": "^11.3.0",
36
35
  "axios": "^1.6.7",
37
36
  "class-variance-authority": "^0.7.1",
@@ -49,14 +48,10 @@
49
48
  "peerDependenciesMeta": {
50
49
  "vee-validate": {
51
50
  "optional": true
52
- },
53
- "@tanstack/vue-query": {
54
- "optional": true
55
51
  }
56
52
  },
57
53
  "devDependencies": {
58
54
  "@phosphor-icons/vue": "^2.2.1",
59
- "@tanstack/vue-query": "^5.51.16",
60
55
  "@types/color": "^4.2.0",
61
56
  "@vue/tsconfig": "^0.5.1",
62
57
  "@vueuse/core": "^11.3.0",
@@ -43,7 +43,7 @@ defineSlots<{
43
43
  menus?(): any
44
44
  }>()
45
45
 
46
- const isDark = useDark({ storageKey: "localStorage" })
46
+ const isDark = useDark()
47
47
  const toggleDark = useToggle(isDark)
48
48
 
49
49
  const importOpen = ref<boolean>(false)
@@ -26,9 +26,7 @@ function initials(name: string): string {
26
26
  .join('')
27
27
  }
28
28
 
29
- const isDark = useDark({
30
- storageKey: 'localStorage'
31
- })
29
+ const isDark = useDark()
32
30
  </script>
33
31
 
34
32
  <template>
@@ -1,29 +1,26 @@
1
1
  <script setup lang="ts" generic="D extends ListDistance">
2
2
  import { computed, ref, useTemplateRef } from "vue"
3
- import { round } from "mathjs"
4
3
  import draggable from "vuedraggable"
5
4
  import { PhEraser, PhEye, PhEyeSlash, PhX } from "@phosphor-icons/vue"
6
- import { ROUNDING, Scale, toScale, type ScaleUnit } from "../../../lib/utils"
5
+ import type { ScaleUnit } from "../../../lib/utils"
7
6
  import { Button } from "../button"
8
7
  import { Input } from "../input"
9
8
  import { Label } from "../label"
10
9
  import { LandmarkIteration } from "../landmark"
11
10
  import { SelectScale } from "../select-scale"
12
- import type { ListDistance } from "./types"
13
-
14
- const scale = defineModel<ScaleUnit>('scale', { required: true })
11
+ import type { ListAnnotations, ListDistance } from "./types"
15
12
 
16
13
  const props = withDefaults(defineProps<{
17
14
  distance: D
15
+ // its scale select changes the scale of the annotations
16
+ annotations: ListAnnotations<D>
18
17
  index: number
19
18
  // the selected distance shows its landmarks and its length
20
19
  selected?: boolean
21
- adjustFactor?: number
22
20
  // units of the scale select
23
21
  units?: ScaleUnit[]
24
22
  }>(), {
25
23
  selected: true,
26
- adjustFactor: 1,
27
24
  })
28
25
 
29
26
  const emit = defineEmits<{
@@ -41,12 +38,11 @@ defineSlots<{
41
38
  }>()
42
39
 
43
40
  const input = useTemplateRef('input')
41
+ // the label is being typed
42
+ const editing = ref<boolean>(false)
44
43
  const scrollSnapType = ref<boolean>(true)
45
44
 
46
- const length = computed(() => {
47
- const value = props.distance.length(scale.value)
48
- return value == undefined ? 0 : round(toScale(value, props.adjustFactor) / Scale[scale.value], ROUNDING)
49
- })
45
+ const length = computed(() => props.annotations.scaled(props.distance))
50
46
 
51
47
  function changeColor(event: Event) {
52
48
  const target = event.currentTarget as HTMLInputElement | null
@@ -95,7 +91,7 @@ function showInput(event: MouseEvent) {
95
91
  emit('select', props.index)
96
92
  return
97
93
  }
98
- props.distance.edit_label = true
94
+ editing.value = true
99
95
  input.value?.$el?.focus?.()
100
96
  }
101
97
  </script>
@@ -108,13 +104,13 @@ function showInput(event: MouseEvent) {
108
104
  <input type="color"
109
105
  class="h-8 w-8 block bg-white border border-gray-950 cursor-pointer rounded-lg disabled:opacity-50 disabled:pointer-events-none dark:bg-slate-900 dark:border-gray-300"
110
106
  :value="distance.getColorHEX()" title="Choose your color" @change="changeColor($event)">
111
- <Label v-show="!distance.edit_label" class="flex whitespace-nowrap w-36 font-normal text-lg"
107
+ <Label v-show="!editing" class="flex whitespace-nowrap w-36 font-normal text-lg"
112
108
  @dblclick.stop="showInput">
113
109
  {{ distance.label }}
114
110
  </Label>
115
- <Input v-show="distance.edit_label" ref="input" type="text" :model-value="distance.label"
116
- class="flex h-auto w-full px-0" @focusout="distance.edit_label = false"
117
- @keyup.enter="distance.edit_label = false" @update:model-value="changeLabel($event)" />
111
+ <Input v-show="editing" ref="input" type="text" :model-value="distance.label"
112
+ class="flex h-auto w-full px-0" @focusout="editing = false"
113
+ @keyup.enter="editing = false" @update:model-value="changeLabel($event)" />
118
114
  </div>
119
115
  <div class="flex row justify-end items-center space-x-3">
120
116
  <slot name="info" :distance="distance" />
@@ -152,9 +148,9 @@ function showInput(event: MouseEvent) {
152
148
  <div class="flex row justify-between">
153
149
  <div class="flex row space-x-1">
154
150
  <Label class="flex whitespace-nowrap w-auto items-center">Distance :</Label>
155
- <Label class="flex whitespace-nowrap w-auto items-center">{{ length }} {{ scale }}</Label>
151
+ <Label class="flex whitespace-nowrap w-auto items-center">{{ length }} {{ annotations.scale }}</Label>
156
152
  </div>
157
- <SelectScale v-if="selected" v-model="scale" :units="units" />
153
+ <SelectScale v-if="selected" v-model="annotations.scale" :units="units" />
158
154
  <div v-else />
159
155
 
160
156
  </div>
@@ -6,20 +6,17 @@ import { PhCircle } from "@phosphor-icons/vue"
6
6
  import { Button } from "../button"
7
7
  import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "../select"
8
8
  import DistanceIteration from "./DistanceIteration.vue"
9
- import type { ListDistance } from "./types"
9
+ import type { ListAnnotations, ListDistance } from "./types"
10
10
 
11
11
  const distances = defineModel<D[]>({ required: true })
12
12
  // index of the selected distance, -1: the next landmark creates a new distance
13
13
  const selectedIndex = defineModel<number>('selected', { required: true })
14
- const scale = defineModel<ScaleUnit>('scale', { required: true })
15
-
16
- withDefaults(defineProps<{
17
- adjustFactor?: number
14
+ defineProps<{
15
+ // scale of the lengths and its calibration
16
+ annotations: ListAnnotations<D>
18
17
  // units of the scale select
19
18
  units?: ScaleUnit[]
20
- }>(), {
21
- adjustFactor: 1,
22
- })
19
+ }>()
23
20
 
24
21
  defineSlots<{
25
22
  info?(props: { distance: D }): any
@@ -76,14 +73,14 @@ function deleteDistance(index: number) {
76
73
  </Select>
77
74
  <Button variant="secondary" @click="select(-1)">New..</Button>
78
75
  </div>
79
- <DistanceIteration v-if="selectedDistance" v-model:scale="scale" :distance="selectedDistance"
80
- :index="selectedIndex" :selected="true" :adjust-factor="adjustFactor" :units="units"
76
+ <DistanceIteration v-if="selectedDistance" :distance="selectedDistance"
77
+ :index="selectedIndex" :selected="true" :annotations="annotations" :units="units"
81
78
  @delete="deleteDistance">
82
79
  <template v-if="$slots.info" #info="slotProps"><slot name="info" v-bind="slotProps" /></template>
83
80
  <template v-if="$slots['landmark-info']" #landmark-info="slotProps"><slot name="landmark-info" v-bind="slotProps" /></template>
84
81
  </DistanceIteration>
85
- <DistanceIteration v-for="{ distance, index } in others" :key="index" v-model:scale="scale" :distance="distance"
86
- :index="index" :selected="false" :adjust-factor="adjustFactor" :units="units" @delete="deleteDistance"
82
+ <DistanceIteration v-for="{ distance, index } in others" :key="index" :distance="distance"
83
+ :index="index" :selected="false" :annotations="annotations" :units="units" @delete="deleteDistance"
87
84
  @select="select">
88
85
  <template v-if="$slots.info" #info="slotProps"><slot name="info" v-bind="slotProps" /></template>
89
86
  </DistanceIteration>
@@ -4,8 +4,6 @@ import type { ListLandmark } from "../landmark/types"
4
4
  // What the distance lists need, the core Distance fits
5
5
  export interface ListDistance<L extends ListLandmark = ListLandmark> {
6
6
  label: string
7
- edit_label: boolean
8
- edit_distance: boolean
9
7
  landmarks: L[]
10
8
  show: boolean
11
9
  getColorHEX(): string
@@ -15,3 +13,11 @@ export interface ListDistance<L extends ListLandmark = ListLandmark> {
15
13
  // length in image units for a scale, undefined while it can't be measured
16
14
  length(scale?: ScaleUnit): number | undefined
17
15
  }
16
+
17
+ // What the distance lists need from the annotations, the core Annotations fits.
18
+ // The scale select changes `scale`
19
+ export interface ListAnnotations<D extends ListDistance = ListDistance> {
20
+ scale: ScaleUnit
21
+ // length of the distance shown in `scale`, calibrated and rounded
22
+ scaled(distance: D): number
23
+ }
@@ -20,6 +20,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
20
20
  <script setup lang="ts" generic="L extends ViewerLandmark">
21
21
  import { onBeforeUnmount, onMounted, shallowRef, useTemplateRef, watch, type HTMLAttributes } from 'vue'
22
22
  import { cn, DOT_RADIUS, ZOOM_MAX, ZOOM_MIN } from '../../../lib/utils'
23
+ import { interaction } from '../../../lib/interaction'
23
24
  import type { Coordinates } from '../../../data/models/coordinates'
24
25
  import type { ProjectImage, Ratio, Size } from '../../../data/models/image'
25
26
  import type { Camera } from '../../../data/models/camera'
@@ -127,6 +128,8 @@ loadImage()
127
128
  watch(() => [props.image.name, props.image.image, props.image.thumbnail], loadImage)
128
129
  watch(() => [props.landmarks, props.distances, props.extraLandmarks], () => update(), { deep: true })
129
130
  watch(() => [props.size.width, props.size.height, props.camera.zoom], () => update())
131
+ // a landmark pressed in a list is drawn bigger
132
+ watch(() => interaction.pressed, () => update())
130
133
 
131
134
  let resizeObserver: ResizeObserver | null = null
132
135
 
@@ -145,6 +148,9 @@ onMounted(() => {
145
148
 
146
149
  onBeforeUnmount(() => {
147
150
  resizeObserver?.disconnect()
151
+ if (landmarkDragged.value) {
152
+ interaction.dragged = null
153
+ }
148
154
  loadToken++
149
155
  })
150
156
 
@@ -267,7 +273,7 @@ function drawImage(c: HTMLCanvasElement) {
267
273
  drawTarget(ctx, toCanvas(marker), dotRadius)
268
274
  } else {
269
275
  const stroke = (props.markerStroke ?? defaultMarkerStroke)(landmark)
270
- drawDot(ctx, toCanvas(marker), landmark.isClicked ? dotRadius * 2 : dotRadius, landmark.getColorHEX(), stroke)
276
+ drawDot(ctx, toCanvas(marker), interaction.pressed == landmark.id ? dotRadius * 2 : dotRadius, landmark.getColorHEX(), stroke)
271
277
  }
272
278
  }
273
279
 
@@ -405,7 +411,7 @@ function startDrag(event: MouseEvent) {
405
411
  if (landmark) {
406
412
  emit('select', landmark)
407
413
  if (props.canDrag?.(landmark) ?? true) {
408
- landmark.isDragged = true
414
+ interaction.dragged = landmark.id
409
415
  draggedPos = markerOf(landmark) ?? pos
410
416
  landmarkDragged.value = landmark
411
417
  }
@@ -442,7 +448,7 @@ function stopDrag(event: MouseEvent) {
442
448
  const landmark = landmarkDragged.value
443
449
  if (landmark != null) {
444
450
  const pos = clampToImage(eventPos(event))
445
- landmark.isDragged = false
451
+ interaction.dragged = null
446
452
  landmarkDragged.value = null
447
453
  draggedPos = { x: -1, y: -1 }
448
454
  emit('move', landmark, pos)
@@ -4,8 +4,6 @@ import type { Coordinates } from "../../../data/models/coordinates"
4
4
  export interface ViewerLandmark {
5
5
  id: string
6
6
  show?: boolean
7
- isClicked?: boolean
8
- isDragged?: boolean
9
7
  getColorHEX(): string
10
8
  }
11
9
 
@@ -1,10 +1,11 @@
1
1
  <script setup lang="ts" generic="L extends ListLandmark">
2
- import { nextTick, onBeforeUnmount, useTemplateRef, watch } from "vue"
2
+ import { computed, nextTick, onBeforeUnmount, ref, useTemplateRef, watch } from "vue"
3
3
  import { PhEye, PhEyeSlash, PhX } from "@phosphor-icons/vue"
4
4
  import { Button } from "../button"
5
5
  import { Input } from "../input"
6
6
  import { Label } from "../label"
7
7
  import { Separator } from "../separator"
8
+ import { interaction } from "../../../lib/interaction"
8
9
  import type { ListLandmark } from "./types"
9
10
 
10
11
  const props = withDefaults(defineProps<{
@@ -33,6 +34,10 @@ defineSlots<{
33
34
  actions?(props: { landmark: L }): any
34
35
  }>()
35
36
 
37
+ // the label is being typed
38
+ const editing = ref<boolean>(false)
39
+ const dragged = computed(() => interaction.dragged == props.landmark.id)
40
+
36
41
  function changeColor(event: Event) {
37
42
  const target = event.currentTarget as HTMLInputElement | null
38
43
  if (target == null) {
@@ -42,19 +47,21 @@ function changeColor(event: Event) {
42
47
  }
43
48
 
44
49
  function changeLabel(payload: string | number) {
45
- props.landmark.setLabel(payload.toString())
50
+ props.landmark.label = payload.toString()
46
51
  }
47
52
 
48
- // isClicked stays true from pointer down until release, even while dragging the row.
53
+ // The landmark stays pressed from pointer down until release, even while dragging the row.
49
54
  // The release is listened on window: during a drag the pointer is no longer over this row.
50
55
  function release() {
51
- props.landmark.isClicked = false
56
+ if (interaction.pressed == props.landmark.id) {
57
+ interaction.pressed = null
58
+ }
52
59
  window.removeEventListener('pointerup', release)
53
60
  window.removeEventListener('pointercancel', release)
54
61
  }
55
62
 
56
63
  function press() {
57
- props.landmark.isClicked = true
64
+ interaction.pressed = props.landmark.id
58
65
  window.addEventListener('pointerup', release)
59
66
  window.addEventListener('pointercancel', release)
60
67
  }
@@ -62,9 +69,9 @@ function press() {
62
69
  onBeforeUnmount(release)
63
70
 
64
71
  // Scroll the row into view when its landmark is dragged in the image viewer.
65
- // immediate: the row is often mounted after isDragged is set (sidebar tab/selection switch).
72
+ // immediate: the row is often mounted after the drag started (sidebar tab/selection switch).
66
73
  const row = useTemplateRef('row')
67
- watch(() => props.landmark.isDragged, (isDragged) => {
74
+ watch(dragged, (isDragged) => {
68
75
  if (isDragged) {
69
76
  nextTick(() => row.value?.scrollIntoView({ block: 'nearest' }))
70
77
  }
@@ -73,7 +80,7 @@ watch(() => props.landmark.isDragged, (isDragged) => {
73
80
 
74
81
  <template>
75
82
  <div ref="row" class="scroll-align border flex grow p-2" @pointerdown="press"
76
- :class="{ 'dark:bg-purple-800 bg-purple-400': landmark.isDragged || highlighted }">
83
+ :class="{ 'dark:bg-purple-800 bg-purple-400': dragged || highlighted }">
77
84
  <div class="h-12 flex grow flex-row justify-between items-center font-normal px-3 py-2">
78
85
  <div class="flex flex-row w-full items-center justify-start space-x-3 py-3 mr-3">
79
86
  <svg v-if="handle" width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor"
@@ -85,11 +92,11 @@ watch(() => props.landmark.isDragged, (isDragged) => {
85
92
  <input v-if="colorPicker" type="color"
86
93
  class="flex-none h-8 w-8 block bg-white border border-gray-950 cursor-pointer rounded-lg disabled:opacity-50 disabled:pointer-events-none dark:bg-slate-900 dark:border-gray-300"
87
94
  :value="landmark.getColorHEX()" title="Choose your color" @change="changeColor($event)">
88
- <Label v-show="!landmark.getEdit()" class="whitespace-nowrap" @dblclick.stop="landmark.setEdit(true)">
95
+ <Label v-show="!editing" class="whitespace-nowrap" @dblclick.stop="editing = true">
89
96
  {{ landmark.label }}
90
97
  </Label>
91
- <Input v-show="landmark.getEdit()" @dblclick.stop="" type="text" :model-value="landmark.label"
92
- class="flex grow h-auto" @focusout="landmark.setEdit(false)" @keyup.enter="landmark.setEdit(false)"
98
+ <Input v-show="editing" @dblclick.stop="" type="text" :model-value="landmark.label"
99
+ class="flex grow h-auto" @focusout="editing = false" @keyup.enter="editing = false"
93
100
  @update:model-value="changeLabel($event)" />
94
101
  </div>
95
102
  <div v-if="$slots.info" class="flex items-center h-full w-auto justify-end space-x-3 pr-3">
@@ -108,7 +115,7 @@ watch(() => props.landmark.isDragged, (isDragged) => {
108
115
  </Button>
109
116
  </template>
110
117
  <slot name="actions" :landmark="landmark" />
111
- <Button class="w-6 h-6 p-0" variant="destructive" @click="emit('remove', landmark.getId())">
118
+ <Button class="w-6 h-6 p-0" variant="destructive" @click="emit('remove', landmark.id)">
112
119
  <PhX class="relative w-4 h-4 p-0" />
113
120
  </Button>
114
121
  </div>
@@ -32,7 +32,7 @@ defineSlots<{
32
32
  const scrollSnapType = ref<boolean>(true)
33
33
 
34
34
  function removeLandmark(id: string) {
35
- landmarks.value = landmarks.value.filter((landmark) => landmark.getId() != id)
35
+ landmarks.value = landmarks.value.filter((landmark) => landmark.id != id)
36
36
  emit('remove', id)
37
37
  }
38
38
  </script>
@@ -3,9 +3,5 @@ import type { ViewerLandmark } from "../image-viewer/types"
3
3
  // What the lists need from a landmark, the core Landmark fits, so do the app ones
4
4
  export interface ListLandmark extends ViewerLandmark {
5
5
  label: string
6
- getId(): string
7
- getEdit(): boolean
8
- setEdit(edit: boolean): void
9
- setLabel(label: string): void
10
6
  setColorHEX(color: string): void
11
7
  }
@@ -1,36 +1,39 @@
1
1
  <script setup lang="ts" generic="T">
2
- import { useQuery } from "@tanstack/vue-query"
2
+ import { ref, shallowRef } from "vue"
3
3
  import { PhSpinnerGap } from "@phosphor-icons/vue"
4
4
 
5
- // Spinner / error / content around the loading of a project (the old CameraViewer wrappers)
6
- const props = withDefaults(defineProps<{
5
+ // Spinner / error / content around the loading of a project (the old CameraViewer wrappers).
6
+ // Loaded once: the apps read the object from the url at startup (?series=) and keep it for the session.
7
+ // An app switching objects without reloading the page remounts it (:key="objectPath").
8
+ const props = defineProps<{
7
9
  load: () => Promise<T>
8
- queryKey?: unknown[]
9
- }>(), {
10
- queryKey: () => ["all_images"],
11
- })
10
+ }>()
12
11
 
13
12
  defineSlots<{
14
13
  default(props: { data: T }): any
15
14
  }>()
16
15
 
17
- const { isPending, isError, data, error } = useQuery({
18
- queryKey: props.queryKey,
19
- queryFn: () => props.load(),
20
- // load() replaces the project of the store (shown image back to the first one):
21
- // the images do not change during a session, never load them again on their own
22
- staleTime: Infinity,
23
- refetchOnWindowFocus: false,
24
- refetchOnReconnect: false,
25
- })
16
+ const status = ref<"pending" | "error" | "loaded">("pending")
17
+ const data = shallowRef<T>()
18
+ const error = ref<unknown>(null)
19
+
20
+ props.load()
21
+ .then((loaded) => {
22
+ data.value = loaded
23
+ status.value = "loaded"
24
+ })
25
+ .catch((reason) => {
26
+ error.value = reason
27
+ status.value = "error"
28
+ })
26
29
  </script>
27
30
 
28
31
  <template>
29
32
  <div class="w-full h-full border flex justify-center items-center">
30
- <div v-if="isPending" class="w-full h-full flex justify-center items-center">
33
+ <div v-if="status == 'pending'" class="w-full h-full flex justify-center items-center">
31
34
  <PhSpinnerGap class="animate-spin" width="10%" height="10%" />
32
35
  </div>
33
- <div v-else-if="isError" class="w-full h-full flex justify-center items-center">
36
+ <div v-else-if="status == 'error'" class="w-full h-full flex justify-center items-center">
34
37
  <div class="text-red-600">{{ error }}</div>
35
38
  </div>
36
39
  <div v-else-if="data !== undefined" class="w-full h-full flex flex-col items-center">
@@ -2,7 +2,3 @@ export const providerSettings = {
2
2
  type : import.meta.env.VITE_BACKEND_TYPE,
3
3
  url: import.meta.env.VITE_BACKEND_URL
4
4
  }
5
-
6
- export const repositorySettings = {
7
- type : import.meta.env.VITE_REPOSITORY,
8
- }
@@ -1,11 +1,12 @@
1
1
  import { round } from "mathjs"
2
- import { adjustFactorFor, isScaleUnit, randomID, ROUNDING, Scale, toScale, type ScaleUnit } from "../../lib/utils"
2
+ import { adjustFactorFor, isScaleUnit, randomID, ROUNDING, Scale, type ScaleUnit } from "../../lib/utils"
3
+ import { is3D } from "./coordinates"
3
4
  import { Distance, type DistanceObject } from "./distance"
4
5
  import { Landmark, type LandmarkObject } from "./landmark"
5
6
 
6
7
  // Saved form of a distance of the annotations: with its length shown in the scale of the annotations (not read back)
7
8
  export type DistanceAnnotationObject = DistanceObject & {
8
- value: number
9
+ distance: number
9
10
  }
10
11
 
11
12
  // Saved form of the annotations of an acquisition (scale_factor: key of the first polar exports)
@@ -46,7 +47,7 @@ export function landmarkCSVRow(type: string, group: string, landmark: Landmark):
46
47
  const { marker, image } = landmark.pose
47
48
  const position = landmark.position
48
49
  return [type, group, landmark.label, landmark.getColorHEX(), marker.x.toFixed(5).toString(), marker.y.toFixed(5).toString(),
49
- image.name, image.label, position.x.toFixed(5).toString(), position.y.toFixed(5).toString(), position.z?.toFixed(5).toString() ?? ""]
50
+ image.name, image.label, position.x.toFixed(5).toString(), position.y.toFixed(5).toString(), is3D(position) ? position.z.toFixed(5).toString() : ""]
50
51
  }
51
52
 
52
53
  // Landmarks and distances of an acquisition: all its images (group and standalone) share
@@ -91,15 +92,26 @@ export class Annotations implements AnnotationsSet {
91
92
  return this.distances[index]!
92
93
  }
93
94
 
94
- // length in image units shown in the current scale, corrected by the calibration and rounded
95
- scaled(length: number): number {
96
- return round(toScale(length, this.adjustFactor) / Scale[this.scale], ROUNDING)
95
+ // length of the distance shown in the current scale, corrected by the calibration and rounded (0 while it can't be measured)
96
+ scaled(distance: Distance): number {
97
+ return this.toUnit(distance.length(this.scale))
97
98
  }
98
99
 
99
- // typing a known length on a distance calibrates the coordinate space
100
+ // length in image units -> in the current scale, corrected by the calibration.
101
+ // Pixels for the "px" scale: the calibration is made on physical lengths, it doesn't apply to them
102
+ protected inScale(length: number): number {
103
+ return this.scale == "px" ? length : length * this.adjustFactor / Scale[this.scale]
104
+ }
105
+
106
+ // inScale rounded for display (0 while it can't be measured)
107
+ protected toUnit(length: number | undefined): number {
108
+ return length == undefined ? 0 : round(this.inScale(length), ROUNDING)
109
+ }
110
+
111
+ // typing a known length on a distance calibrates the coordinate space (not in pixels, see inScale)
100
112
  calibrate(distance: Distance, target: number) {
101
113
  const value = distance.length(this.scale)
102
- if (value && target > 0) {
114
+ if (this.scale != "px" && value && target > 0) {
103
115
  this.adjustFactor = adjustFactorFor(value, target, this.scale)
104
116
  }
105
117
  }
@@ -129,7 +141,7 @@ export class Annotations implements AnnotationsSet {
129
141
  landmarks: this.landmarks.map((landmark) => landmark.toJSON()),
130
142
  distances: this.distances.map((distance) => ({
131
143
  ...distance.toJSON(),
132
- value: this.scaled(distance.length(this.scale) ?? 0),
144
+ distance: this.scaled(distance),
133
145
  })),
134
146
  }
135
147
  }
@@ -12,21 +12,25 @@ export type Coords3D = {
12
12
  z: number
13
13
  }
14
14
 
15
- // Physical position returned by the backend, z is only given by 3D-aware apps
16
- export type Position = Coordinates & { z?: number }
15
+ // Physical position returned by the backend: 2D (polar, spectral) or 3D (stack, depth).
16
+ // What is known to be 3D is typed Coords3D (the 3D repositories), the shared models keep a Position
17
+ export type Position = Coordinates | Coords3D
18
+
19
+ export function is3D(position: Position): position is Coords3D {
20
+ return "z" in position && typeof position.z === "number"
21
+ }
17
22
 
18
23
  export function vectorToString(vector: Position) {
19
- const values = [vector.x, vector.y, ...(vector.z === undefined ? [] : [vector.z])]
24
+ const values = is3D(vector) ? [vector.x, vector.y, vector.z] : [vector.x, vector.y]
20
25
  return `(${values.map((v) => v.toFixed(ROUNDING)).join("; ")})`
21
26
  }
22
27
 
28
+ // 3D only if both are
23
29
  export function distanceVector(first: Position, second: Position): Position {
24
- const distance: Position = {
25
- x: math.abs(second.x - first.x),
26
- y: math.abs(second.y - first.y),
27
- }
28
- if (first.z !== undefined && second.z !== undefined) {
29
- distance.z = math.abs(second.z - first.z)
30
+ const x = math.abs(second.x - first.x)
31
+ const y = math.abs(second.y - first.y)
32
+ if (is3D(first) && is3D(second)) {
33
+ return { x, y, z: math.abs(second.z - first.z) }
30
34
  }
31
- return distance
35
+ return { x, y }
32
36
  }
@@ -1,30 +1,68 @@
1
- export class DequeMax2{
2
- deque : Array<string>
1
+ // At most two items in order of addition, adding a third one drops the first
2
+ // (sphaer: the two selected landmarks, depth: the ends of a profile).
3
+ // Items are compared with their equals, which also finds them by id (landmarks)
4
+ export class DequeMax2<T extends { equals(other: T | string): boolean }> {
5
+ items: T[] = []
3
6
 
4
- constructor(){
5
- this.deque= new Array()
7
+ constructor(items: T[] = []) {
8
+ items.forEach((item) => this.add(item))
6
9
  }
7
10
 
8
- add(landmark : string){
9
- if(this.selected(landmark) || landmark == null){
10
- return;
11
+ get length(): number {
12
+ return this.items.length
13
+ }
14
+
15
+ get first(): T | undefined {
16
+ return this.items[0]
17
+ }
18
+
19
+ get last(): T | undefined {
20
+ return this.items[this.items.length - 1]
21
+ }
22
+
23
+ isEmpty(): boolean {
24
+ return this.items.length == 0
25
+ }
26
+
27
+ isFull(): boolean {
28
+ return this.items.length == 2
29
+ }
30
+
31
+ // an item already in is not added again
32
+ add(item: T) {
33
+ if (this.contains(item)) {
34
+ return
11
35
  }
12
- if(this.deque.length == 2){
13
- this.deque.shift()
36
+ if (this.isFull()) {
37
+ this.items.shift()
14
38
  }
15
- this.deque.push(landmark)
39
+ this.items.push(item)
40
+ }
41
+
42
+ popFirst() {
43
+ this.items.shift()
44
+ }
45
+
46
+ popLast() {
47
+ this.items.pop()
16
48
  }
17
-
18
- remove(landmark: string){
19
- let index : number = this.deque.indexOf(landmark)
20
- this.deque.splice(index, 1)
49
+
50
+ permute() {
51
+ this.items.reverse()
52
+ }
53
+
54
+ contains(item: T | string | null | undefined): boolean {
55
+ if (item == null) {
56
+ return false
57
+ }
58
+ return this.items.some((other) => other.equals(item))
21
59
  }
22
60
 
23
- selected(landmark: string){
24
- return this.deque.indexOf(landmark) >= 0
61
+ remove(item: T | string) {
62
+ this.items = this.items.filter((other) => !other.equals(item))
25
63
  }
26
64
 
27
- fullSelected(){
28
- return this.deque.length == 2
65
+ forEach(callback: (item: T) => void) {
66
+ this.items.forEach(callback)
29
67
  }
30
- }
68
+ }