optica-core 0.2.2 → 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.
- package/package.json +1 -6
- package/src/components/ui/app-menu/AppMenu.vue +1 -1
- package/src/components/ui/credits/Credits.vue +1 -3
- package/src/components/ui/distance/DistanceIteration.vue +14 -18
- package/src/components/ui/distance/DistanceList.vue +9 -12
- package/src/components/ui/distance/types.ts +8 -2
- package/src/components/ui/image-viewer/ImageViewer.vue +9 -3
- package/src/components/ui/image-viewer/types.ts +0 -2
- package/src/components/ui/landmark/LandmarkIteration.vue +19 -12
- package/src/components/ui/landmark/LandmarkList.vue +1 -1
- package/src/components/ui/landmark/types.ts +0 -4
- package/src/components/ui/project-loader/ProjectLoader.vue +21 -18
- package/src/config/appSettings.ts +0 -4
- package/src/data/models/annotations.ts +21 -9
- package/src/data/models/coordinates.ts +14 -10
- package/src/data/models/dequeMax2.ts +57 -19
- package/src/data/models/distance.ts +17 -29
- package/src/data/models/landmark.ts +0 -35
- package/src/data/providers/backend.ts +28 -0
- package/src/data/repositories/data_repository.ts +3 -3
- package/src/data/repositories/project_repository.ts +4 -2
- package/src/data/repositories/repository.ts +3 -2
- package/src/env.d.ts +0 -1
- package/src/index.ts +2 -3
- package/src/lib/interaction.ts +10 -0
- package/src/lib/persistence.ts +10 -5
- package/src/lib/utils.ts +6 -8
- package/tests/annotations.test.ts +63 -2
- package/src/data/models/marker.ts +0 -8
- package/src/data/providers/provider_factory.ts +0 -32
- 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.
|
|
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",
|
|
@@ -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 {
|
|
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
|
-
|
|
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="!
|
|
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="
|
|
116
|
-
class="flex h-auto w-full px-0" @focusout="
|
|
117
|
-
@keyup.enter="
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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"
|
|
80
|
-
:index="selectedIndex" :selected="true" :
|
|
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"
|
|
86
|
-
:index="index" :selected="false" :
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
451
|
+
interaction.dragged = null
|
|
446
452
|
landmarkDragged.value = null
|
|
447
453
|
draggedPos = { x: -1, y: -1 }
|
|
448
454
|
emit('move', landmark, pos)
|
|
@@ -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.
|
|
50
|
+
props.landmark.label = payload.toString()
|
|
46
51
|
}
|
|
47
52
|
|
|
48
|
-
//
|
|
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.
|
|
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.
|
|
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
|
|
72
|
+
// immediate: the row is often mounted after the drag started (sidebar tab/selection switch).
|
|
66
73
|
const row = useTemplateRef('row')
|
|
67
|
-
watch(
|
|
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':
|
|
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="!
|
|
95
|
+
<Label v-show="!editing" class="whitespace-nowrap" @dblclick.stop="editing = true">
|
|
89
96
|
{{ landmark.label }}
|
|
90
97
|
</Label>
|
|
91
|
-
<Input v-show="
|
|
92
|
-
class="flex grow h-auto" @focusout="
|
|
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.
|
|
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.
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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="
|
|
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="
|
|
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">
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { round } from "mathjs"
|
|
2
|
-
import { adjustFactorFor, isScaleUnit, randomID, ROUNDING, Scale,
|
|
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
|
-
|
|
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
|
|
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
|
|
95
|
-
scaled(
|
|
96
|
-
return
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
16
|
-
|
|
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,
|
|
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
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
35
|
+
return { x, y }
|
|
32
36
|
}
|
|
@@ -1,30 +1,68 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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
|
-
|
|
7
|
+
constructor(items: T[] = []) {
|
|
8
|
+
items.forEach((item) => this.add(item))
|
|
6
9
|
}
|
|
7
10
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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.
|
|
13
|
-
this.
|
|
36
|
+
if (this.isFull()) {
|
|
37
|
+
this.items.shift()
|
|
14
38
|
}
|
|
15
|
-
this.
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
24
|
-
|
|
61
|
+
remove(item: T | string) {
|
|
62
|
+
this.items = this.items.filter((other) => !other.equals(item))
|
|
25
63
|
}
|
|
26
64
|
|
|
27
|
-
|
|
28
|
-
|
|
65
|
+
forEach(callback: (item: T) => void) {
|
|
66
|
+
this.items.forEach(callback)
|
|
29
67
|
}
|
|
30
|
-
}
|
|
68
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import Color from "color"
|
|
2
2
|
import { Landmark, type LandmarkObject } from "./landmark"
|
|
3
|
-
import { distanceVector, type
|
|
3
|
+
import { distanceVector, type Position } from "./coordinates"
|
|
4
4
|
import { computeDistance, randomColorRGB, type ScaleUnit } from "../../lib/utils"
|
|
5
5
|
|
|
6
6
|
export type DistanceObject = {
|
|
@@ -9,52 +9,40 @@ export type DistanceObject = {
|
|
|
9
9
|
color: string,
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
// sum of the segments between consecutive landmarks, `point`: where a landmark is. undefined under two landmarks
|
|
13
|
+
function sumOfSegments(landmarks: Landmark[], point: (landmark: Landmark) => Position) : number | undefined {
|
|
14
|
+
if(landmarks.length < 2 ){
|
|
15
|
+
return undefined
|
|
16
|
+
}
|
|
17
|
+
return computeDistance(landmarks.slice(1).map((landmark, i) => distanceVector(point(landmarks[i]!), point(landmark))))
|
|
18
|
+
}
|
|
19
|
+
|
|
12
20
|
export class Distance {
|
|
13
21
|
label : string
|
|
14
22
|
landmarks : Array<Landmark>
|
|
15
23
|
color: Color
|
|
16
|
-
edit_label: boolean
|
|
17
|
-
edit_distance: boolean
|
|
18
24
|
show : boolean
|
|
19
25
|
|
|
20
26
|
constructor(label: string, landmarks : Array<Landmark> | null = null, color: Color | null = null){
|
|
21
27
|
this.label = label
|
|
22
28
|
this.landmarks = landmarks || new Array()
|
|
23
|
-
this.edit_label = false
|
|
24
|
-
this.edit_distance = false
|
|
25
29
|
this.show = true
|
|
26
30
|
this.color = color || Color.rgb(randomColorRGB())
|
|
27
31
|
}
|
|
28
32
|
|
|
29
|
-
//
|
|
30
|
-
get distance() :
|
|
31
|
-
|
|
32
|
-
return undefined
|
|
33
|
-
}
|
|
34
|
-
let distance : Position[] = new Array()
|
|
35
|
-
for(let i = 1; i < this.landmarks.length; i++){
|
|
36
|
-
distance.push(distanceVector(this.landmarks[i-1].position, this.landmarks[i].position))
|
|
37
|
-
}
|
|
38
|
-
return distance
|
|
33
|
+
// physical length: sum of the segments between consecutive landmarks, undefined under two landmarks
|
|
34
|
+
get distance() : number | undefined {
|
|
35
|
+
return sumOfSegments(this.landmarks, (landmark) => landmark.position)
|
|
39
36
|
}
|
|
40
37
|
|
|
41
|
-
//
|
|
42
|
-
get distancePx() :
|
|
43
|
-
|
|
44
|
-
return undefined
|
|
45
|
-
}
|
|
46
|
-
let distance : Coordinates[] = new Array()
|
|
47
|
-
for(let i = 1; i < this.landmarks.length; i++){
|
|
48
|
-
distance.push(distanceVector(this.landmarks[i-1].pose.marker, this.landmarks[i].pose.marker))
|
|
49
|
-
}
|
|
50
|
-
return distance
|
|
38
|
+
// pixel length: sum of the segments between consecutive markers, undefined under two landmarks
|
|
39
|
+
get distancePx() : number | undefined {
|
|
40
|
+
return sumOfSegments(this.landmarks, (landmark) => landmark.pose.marker)
|
|
51
41
|
}
|
|
52
42
|
|
|
53
|
-
// length in image units: pixel length for the "px" scale, physical length otherwise
|
|
54
|
-
// undefined while it can't be measured
|
|
43
|
+
// length in image units: pixel length for the "px" scale, physical length otherwise
|
|
55
44
|
length(scale?: ScaleUnit) : number | undefined {
|
|
56
|
-
|
|
57
|
-
return intervals === undefined ? undefined : computeDistance(intervals)
|
|
45
|
+
return scale == "px" ? this.distancePx : this.distance
|
|
58
46
|
}
|
|
59
47
|
|
|
60
48
|
in(landmark : Landmark | string) : boolean{
|
|
@@ -23,9 +23,6 @@ export class Landmark {
|
|
|
23
23
|
position : Position
|
|
24
24
|
color: Color
|
|
25
25
|
show: boolean
|
|
26
|
-
edit: boolean
|
|
27
|
-
isClicked: boolean
|
|
28
|
-
isDragged: boolean
|
|
29
26
|
|
|
30
27
|
// pose.image may be the full image, only its reference is kept
|
|
31
28
|
constructor(id: string, label: string, pose: Pose, position : Position, color: Color | null = null) {
|
|
@@ -33,11 +30,8 @@ export class Landmark {
|
|
|
33
30
|
this.label = label
|
|
34
31
|
this.pose = { marker: pose.marker, image: imageRef(pose.image) }
|
|
35
32
|
this.position = position
|
|
36
|
-
this.edit = false
|
|
37
33
|
this.color = color || Color.rgb(randomColorRGB())
|
|
38
34
|
this.show = true
|
|
39
|
-
this.isClicked = false
|
|
40
|
-
this.isDragged = false
|
|
41
35
|
}
|
|
42
36
|
|
|
43
37
|
equals(other : Landmark | string | null | undefined){
|
|
@@ -58,16 +52,6 @@ export class Landmark {
|
|
|
58
52
|
return { id: this.id, label: this.label, pose: this.pose, position : this.position, color: this.color.hex() }
|
|
59
53
|
}
|
|
60
54
|
|
|
61
|
-
getId() : string {
|
|
62
|
-
return this.id
|
|
63
|
-
}
|
|
64
|
-
getLabel() : string {
|
|
65
|
-
return this.label
|
|
66
|
-
}
|
|
67
|
-
setLabel(label : string){
|
|
68
|
-
this.label = label
|
|
69
|
-
}
|
|
70
|
-
|
|
71
55
|
getColorHEX() : string{
|
|
72
56
|
return this.color.hex()
|
|
73
57
|
}
|
|
@@ -87,23 +71,4 @@ export class Landmark {
|
|
|
87
71
|
image : imageRef(image)
|
|
88
72
|
}
|
|
89
73
|
}
|
|
90
|
-
|
|
91
|
-
getPose() : Pose {
|
|
92
|
-
return this.pose
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
setPosition(position : Position) {
|
|
96
|
-
this.position = position
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
getPosition() : Position {
|
|
100
|
-
return this.position
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
getEdit() : boolean{
|
|
104
|
-
return this.edit
|
|
105
|
-
}
|
|
106
|
-
setEdit(edit : boolean){
|
|
107
|
-
this.edit = edit
|
|
108
|
-
}
|
|
109
74
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { DataProvider } from "./providers";
|
|
2
|
+
import { WebProvider } from "./web_providers";
|
|
3
|
+
import { OrthancProvider } from "./orthanc_providers";
|
|
4
|
+
import { providerSettings } from "../../config/appSettings"
|
|
5
|
+
|
|
6
|
+
// Backends an app can be served by (VITE_BACKEND_TYPE)
|
|
7
|
+
export type BackendType = "WEB" | "ORTHANC"
|
|
8
|
+
|
|
9
|
+
// how to build the provider of each backend from its url (VITE_BACKEND_URL)
|
|
10
|
+
export type ProviderBuilders<P extends DataProvider> = Record<BackendType, (url: string) => P>
|
|
11
|
+
|
|
12
|
+
// The core providers, `plugin`: the path of the app in Orthanc
|
|
13
|
+
export function coreProviders(plugin: string): ProviderBuilders<DataProvider> {
|
|
14
|
+
return {
|
|
15
|
+
WEB: (url) => new WebProvider(url),
|
|
16
|
+
ORTHANC: (url) => new OrthancProvider(url, plugin),
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Provider of the backend set in the environment (.env: VITE_BACKEND_TYPE, VITE_BACKEND_URL).
|
|
21
|
+
// Apps with their own routes pass their own providers (sphaer)
|
|
22
|
+
export function createProvider<P extends DataProvider>(builders: ProviderBuilders<P>): P {
|
|
23
|
+
const type = providerSettings.type
|
|
24
|
+
if (!(type in builders)) {
|
|
25
|
+
throw new Error(`Unknown VITE_BACKEND_TYPE "${type}", expected one of ${Object.keys(builders).join(", ")}`)
|
|
26
|
+
}
|
|
27
|
+
return builders[type as BackendType](providerSettings.url)
|
|
28
|
+
}
|
|
@@ -5,7 +5,7 @@ import type { ImageName, ProjectImage } from "../models/image";
|
|
|
5
5
|
import type { Pose } from "../models/landmark";
|
|
6
6
|
|
|
7
7
|
// Common part of every app repository, apps only have to parse their /images payload
|
|
8
|
-
export abstract class DataRepository<TData> implements Repository<TData> {
|
|
8
|
+
export abstract class DataRepository<TData, TPosition extends Position = Position> implements Repository<TData, TPosition> {
|
|
9
9
|
provider: DataProvider;
|
|
10
10
|
|
|
11
11
|
constructor(provider: DataProvider) {
|
|
@@ -14,9 +14,9 @@ export abstract class DataRepository<TData> implements Repository<TData> {
|
|
|
14
14
|
|
|
15
15
|
abstract getImages(objectPath: string): Promise<TData>
|
|
16
16
|
|
|
17
|
-
computeLandmarkPosition(objectPath: string, pose: Pose) : Promise<
|
|
17
|
+
computeLandmarkPosition(objectPath: string, pose: Pose) : Promise<TPosition> {
|
|
18
18
|
return this.provider.computeLandmarkPosition(objectPath, pose.image.name, pose.marker).then((res) => {
|
|
19
|
-
return res.data as
|
|
19
|
+
return res.data as TPosition
|
|
20
20
|
})
|
|
21
21
|
}
|
|
22
22
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { DataProvider } from "../providers/providers";
|
|
2
|
+
import type { Position } from "../models/coordinates";
|
|
2
3
|
import type { ProjectImage } from "../models/image";
|
|
3
4
|
import { Acquisition, Project, type AcquisitionObject } from "../models/project";
|
|
4
5
|
import type { AnnotationsSet } from "../models/annotations";
|
|
@@ -16,8 +17,9 @@ export function parseAcquisitions<TImage extends ProjectImage>(data: any): Acqui
|
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
// Loads /images as a Project, apps whose payload differs pass a parser
|
|
19
|
-
|
|
20
|
-
|
|
20
|
+
// TPosition: Coords3D when the backend gives 3D positions (stack, depth)
|
|
21
|
+
export class ProjectRepository<TImage extends ProjectImage = ProjectImage, TAcquisition extends Acquisition<TImage, AnnotationsSet> = Acquisition<TImage>, TPosition extends Position = Position>
|
|
22
|
+
extends DataRepository<Project<TImage, TAcquisition>, TPosition> {
|
|
21
23
|
parse: ProjectParser<TImage>
|
|
22
24
|
createAcquisition: AcquisitionFactory<TImage, TAcquisition>
|
|
23
25
|
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import type { Position } from "../models/coordinates";
|
|
2
2
|
import type { Pose } from "../models/landmark";
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
// TPosition: Coords3D for the apps whose backend gives 3D positions (stack, depth)
|
|
5
|
+
export interface Repository<TData = unknown, TPosition extends Position = Position> {
|
|
5
6
|
getImages : (objectPath:string) => Promise<TData>;
|
|
6
7
|
getFullImage : (objectPath:string, imageName : string) => string;
|
|
7
8
|
getThumbnail : (objectPath:string, imageName : string) => string;
|
|
8
|
-
computeLandmarkPosition : (objectPath:string, pose : Pose) => Promise<
|
|
9
|
+
computeLandmarkPosition : (objectPath:string, pose : Pose) => Promise<TPosition>;
|
|
9
10
|
}
|
package/src/env.d.ts
CHANGED
package/src/index.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
export * from "./lib/utils"
|
|
2
2
|
export * from "./lib/export"
|
|
3
3
|
export * from "./lib/persistence"
|
|
4
|
+
export * from "./lib/interaction"
|
|
4
5
|
export * from "./config/appSettings"
|
|
5
6
|
|
|
6
7
|
export * from "./data/models/coordinates"
|
|
7
8
|
export * from "./data/models/image"
|
|
8
9
|
export * from "./data/models/camera"
|
|
9
10
|
export * from "./data/models/project"
|
|
10
|
-
export * from "./data/models/marker"
|
|
11
11
|
export * from "./data/models/landmark"
|
|
12
12
|
export * from "./data/models/distance"
|
|
13
13
|
export * from "./data/models/dequeMax2"
|
|
@@ -17,9 +17,8 @@ export * from "./data/providers/routes"
|
|
|
17
17
|
export * from "./data/providers/providers"
|
|
18
18
|
export * from "./data/providers/web_providers"
|
|
19
19
|
export * from "./data/providers/orthanc_providers"
|
|
20
|
-
export * from "./data/providers/
|
|
20
|
+
export * from "./data/providers/backend"
|
|
21
21
|
|
|
22
22
|
export * from "./data/repositories/repository"
|
|
23
23
|
export * from "./data/repositories/data_repository"
|
|
24
24
|
export * from "./data/repositories/project_repository"
|
|
25
|
-
export * from "./data/repositories/repository_factory"
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { reactive } from "vue"
|
|
2
|
+
|
|
3
|
+
// What the user is doing with the landmarks right now, shared by the lists and the ImageViewer.
|
|
4
|
+
// Not part of the models: never saved, one landmark at a time (by id).
|
|
5
|
+
export const interaction = reactive({
|
|
6
|
+
// pressed in a list, until the pointer is released: the viewer draws it bigger
|
|
7
|
+
pressed: null as string | null,
|
|
8
|
+
// dragged in the viewer: its row in the lists is highlighted and scrolled to
|
|
9
|
+
dragged: null as string | null,
|
|
10
|
+
})
|
package/src/lib/persistence.ts
CHANGED
|
@@ -34,17 +34,22 @@ export function usePersistedAnnotations(project: () => Project<any, any> | null
|
|
|
34
34
|
}
|
|
35
35
|
}, { immediate: true })
|
|
36
36
|
|
|
37
|
-
//
|
|
37
|
+
// Watches what is saved, serialized: only a change of it writes the storage, not the
|
|
38
|
+
// display state of the annotations (hover, drag) nor the images of the project.
|
|
38
39
|
// Registered after the restore: it saves the restored annotations, never empty ones over them.
|
|
39
|
-
watch(() =>
|
|
40
|
+
watch(() => {
|
|
40
41
|
const loaded = project()
|
|
41
42
|
if (!loaded) {
|
|
42
|
-
return
|
|
43
|
+
return undefined
|
|
43
44
|
}
|
|
44
45
|
const saved: SavedAnnotations = {
|
|
45
46
|
annotations: loaded.annotationsToJSON(),
|
|
46
47
|
selection: Object.fromEntries(loaded.acquisitions.map((acquisition) => [acquisition.id, acquisition.annotations.getSelection()])),
|
|
47
48
|
}
|
|
48
|
-
|
|
49
|
-
},
|
|
49
|
+
return { objectPath: loaded.objectPath, json: JSON.stringify(saved) }
|
|
50
|
+
}, (saved, previous) => {
|
|
51
|
+
if (saved && (saved.objectPath != previous?.objectPath || saved.json != previous.json)) {
|
|
52
|
+
storage.setItem(key(saved.objectPath), saved.json)
|
|
53
|
+
}
|
|
54
|
+
})
|
|
50
55
|
}
|
package/src/lib/utils.ts
CHANGED
|
@@ -31,9 +31,12 @@ export function randomColorRGB(): [number, number, number] {
|
|
|
31
31
|
return [Math.floor(Math.random() * 256), Math.floor(Math.random() * 256), Math.floor(Math.random() * 256)]
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
34
|
+
const ID_CHARS = "0123456789abcdefghijklmnopqrstuvwxyz"
|
|
35
|
+
|
|
36
|
+
// random base-36 id of `length` characters (landmarks, also their first label: kept short).
|
|
37
|
+
// getRandomValues works on http too, unlike crypto.randomUUID
|
|
38
|
+
export function randomID(length: number = 5): string {
|
|
39
|
+
return Array.from(crypto.getRandomValues(new Uint8Array(length)), (byte) => ID_CHARS[byte % ID_CHARS.length]).join("")
|
|
37
40
|
}
|
|
38
41
|
|
|
39
42
|
export enum Scale {
|
|
@@ -63,11 +66,6 @@ export const DOT_RADIUS = 4.5
|
|
|
63
66
|
export const SPACE_TARGET = 0.2
|
|
64
67
|
export const ROUNDING = 2
|
|
65
68
|
|
|
66
|
-
// length in image units -> length shown in `scale`, corrected by the calibration factor
|
|
67
|
-
export function toScale(length: number, adjustFactor: number): number {
|
|
68
|
-
return length * adjustFactor
|
|
69
|
-
}
|
|
70
|
-
|
|
71
69
|
// calibration factor so that `length` is shown as `target` in `scale`
|
|
72
70
|
export function adjustFactorFor(length: number, target: number, scale: ScaleUnit): number {
|
|
73
71
|
return target / length * Scale[scale]
|
|
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"
|
|
|
2
2
|
import { effectScope, isReactive, nextTick, reactive, shallowRef } from "vue"
|
|
3
3
|
import Color from "color"
|
|
4
4
|
import {
|
|
5
|
-
Acquisition, Annotations, Distance, Landmark, Project, usePersistedAnnotations,
|
|
5
|
+
Acquisition, Annotations, DequeMax2, Distance, Landmark, Project, usePersistedAnnotations,
|
|
6
6
|
type ProjectImage,
|
|
7
7
|
} from "../src"
|
|
8
8
|
|
|
@@ -90,7 +90,7 @@ describe("Project annotations", () => {
|
|
|
90
90
|
it("lets an app use its own Annotations", () => {
|
|
91
91
|
const a = new TaggedAcquisition({ id: "a", images: [image("i0")], size })
|
|
92
92
|
expect(a.annotations.tagged).toBe(true)
|
|
93
|
-
a.annotations.restore({ distances: [{ label: "d", color: "#00ff00", landmarks: [],
|
|
93
|
+
a.annotations.restore({ distances: [{ label: "d", color: "#00ff00", landmarks: [], distance: 0 }] })
|
|
94
94
|
expect(a.annotations.distances[0]).toBeInstanceOf(Distance)
|
|
95
95
|
})
|
|
96
96
|
|
|
@@ -170,6 +170,28 @@ describe("usePersistedAnnotations", () => {
|
|
|
170
170
|
scope2.stop()
|
|
171
171
|
})
|
|
172
172
|
|
|
173
|
+
it("doesn't write the storage for what isn't saved", async () => {
|
|
174
|
+
const storage = new MemoryStorage()
|
|
175
|
+
const current = shallowRef<Project | null>(null)
|
|
176
|
+
const scope = effectScope()
|
|
177
|
+
scope.run(() => usePersistedAnnotations(() => current.value, storage))
|
|
178
|
+
current.value = reactive(new Project("A1", [acquisition("rotation")])) as Project
|
|
179
|
+
await nextTick()
|
|
180
|
+
const annotations = current.value.acquisition!.annotations
|
|
181
|
+
annotations.landmarks.push(landmark("kept"))
|
|
182
|
+
await nextTick()
|
|
183
|
+
let writes = 0
|
|
184
|
+
const setItem = storage.setItem.bind(storage)
|
|
185
|
+
storage.setItem = (key, value) => { writes++; setItem(key, value) }
|
|
186
|
+
annotations.landmarks[0]!.show = false
|
|
187
|
+
await nextTick()
|
|
188
|
+
expect(writes).toBe(0)
|
|
189
|
+
annotations.landmarks[0]!.label = "renamed"
|
|
190
|
+
await nextTick()
|
|
191
|
+
expect(writes).toBe(1)
|
|
192
|
+
scope.stop()
|
|
193
|
+
})
|
|
194
|
+
|
|
173
195
|
it("ignores a saved selection that doesn't fit the annotations", () => {
|
|
174
196
|
const annotations = new Annotations()
|
|
175
197
|
annotations.newDistance()
|
|
@@ -179,3 +201,42 @@ describe("usePersistedAnnotations", () => {
|
|
|
179
201
|
expect(annotations.selectedDistanceIndex).toBe(-1)
|
|
180
202
|
})
|
|
181
203
|
})
|
|
204
|
+
|
|
205
|
+
describe("scale", () => {
|
|
206
|
+
const distance = () => new Distance("d", [
|
|
207
|
+
new Landmark("a", "a", { marker: { x: 0, y: 0 }, image: image("i0") }, { x: 0, y: 0 }),
|
|
208
|
+
new Landmark("b", "b", { marker: { x: 3, y: 4 }, image: image("i0") }, { x: 30, y: 40 }),
|
|
209
|
+
])
|
|
210
|
+
|
|
211
|
+
it("applies the calibration to physical lengths, not to pixels", () => {
|
|
212
|
+
const annotations = new Annotations()
|
|
213
|
+
annotations.adjustFactor = 2
|
|
214
|
+
annotations.scale = "cm"
|
|
215
|
+
expect(annotations.scaled(distance())).toBe(10)
|
|
216
|
+
annotations.scale = "px"
|
|
217
|
+
expect(annotations.scaled(distance())).toBe(5)
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it("doesn't calibrate in pixels", () => {
|
|
221
|
+
const annotations = new Annotations()
|
|
222
|
+
annotations.scale = "px"
|
|
223
|
+
annotations.calibrate(distance(), 10)
|
|
224
|
+
expect(annotations.adjustFactor).toBe(1)
|
|
225
|
+
annotations.scale = "mm"
|
|
226
|
+
annotations.calibrate(distance(), 10)
|
|
227
|
+
expect(annotations.adjustFactor).toBe(0.2)
|
|
228
|
+
})
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
describe("DequeMax2", () => {
|
|
232
|
+
it("keeps the last two items, finds them with equals", () => {
|
|
233
|
+
const deque = new DequeMax2([landmark("a"), landmark("b"), landmark("c")])
|
|
234
|
+
expect(deque.items.map((l) => l.id)).toEqual(["b", "c"])
|
|
235
|
+
deque.add(landmark("c"))
|
|
236
|
+
expect(deque.items.map((l) => l.id)).toEqual(["b", "c"])
|
|
237
|
+
expect([deque.contains("b"), deque.contains(landmark("a")), deque.contains(null)]).toEqual([true, false, false])
|
|
238
|
+
deque.remove("unknown")
|
|
239
|
+
deque.remove("b")
|
|
240
|
+
expect([deque.first?.id, deque.last?.id, deque.isFull()]).toEqual(["c", "c", false])
|
|
241
|
+
})
|
|
242
|
+
})
|
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
import type { DataProvider } from "./providers";
|
|
2
|
-
import { WebProvider } from "./web_providers";
|
|
3
|
-
import { providerSettings } from "../../config/appSettings"
|
|
4
|
-
|
|
5
|
-
type ProviderBuilder = (url: string) => DataProvider
|
|
6
|
-
|
|
7
|
-
// "ORTHANC" needs the plugin name of the app, register it with
|
|
8
|
-
// ProviderFactory.register("ORTHANC", (url) => new OrthancProvider(url, "polaroptica"))
|
|
9
|
-
export class ProviderFactory{
|
|
10
|
-
private static _instances = new Map<string, DataProvider>()
|
|
11
|
-
private static _builders = new Map<string, ProviderBuilder>([
|
|
12
|
-
["WEB", (url) => new WebProvider(url)],
|
|
13
|
-
])
|
|
14
|
-
|
|
15
|
-
static register(type : string, builder : ProviderBuilder){
|
|
16
|
-
this._builders.set(type, builder)
|
|
17
|
-
this._instances.delete(type)
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
static get(type : string = providerSettings.type) : DataProvider{
|
|
21
|
-
let instance = this._instances.get(type)
|
|
22
|
-
if(instance == null){
|
|
23
|
-
const builder = this._builders.get(type)
|
|
24
|
-
if(builder == null){
|
|
25
|
-
throw new Error(`Unknown provider type "${type}"`)
|
|
26
|
-
}
|
|
27
|
-
instance = builder(providerSettings.url)
|
|
28
|
-
this._instances.set(type, instance)
|
|
29
|
-
}
|
|
30
|
-
return instance
|
|
31
|
-
}
|
|
32
|
-
}
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
import type { Repository } from "./repository";
|
|
2
|
-
import type { DataProvider } from "../providers/providers";
|
|
3
|
-
import { ProviderFactory } from "../providers/provider_factory";
|
|
4
|
-
import { providerSettings, repositorySettings } from "../../config/appSettings"
|
|
5
|
-
|
|
6
|
-
type RepositoryBuilder = (provider: DataProvider) => Repository<any>
|
|
7
|
-
|
|
8
|
-
// Apps register their repository at startup:
|
|
9
|
-
// RepositoryFactory.register("DATA", (provider) => new StackRepository(provider))
|
|
10
|
-
export class RepositoryFactory{
|
|
11
|
-
private static _instances = new Map<string, Repository<any>>()
|
|
12
|
-
private static _builders = new Map<string, RepositoryBuilder>()
|
|
13
|
-
|
|
14
|
-
static register(type : string, builder : RepositoryBuilder){
|
|
15
|
-
this._builders.set(type, builder)
|
|
16
|
-
this._instances.delete(type)
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
static get<R extends Repository<any> = Repository>(type : string = repositorySettings.type) : R{
|
|
20
|
-
let instance = this._instances.get(type)
|
|
21
|
-
if(instance == null){
|
|
22
|
-
const builder = this._builders.get(type)
|
|
23
|
-
if(builder == null){
|
|
24
|
-
throw new Error(`Unknown repository type "${type}"`)
|
|
25
|
-
}
|
|
26
|
-
instance = builder(ProviderFactory.get(providerSettings.type))
|
|
27
|
-
this._instances.set(type, instance)
|
|
28
|
-
}
|
|
29
|
-
return instance as R
|
|
30
|
-
}
|
|
31
|
-
}
|