optica-core 0.1.0 → 0.2.0
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/distance/DistanceIteration.vue +4 -6
- package/src/components/ui/distance/DistanceList.vue +3 -4
- package/src/components/ui/distance/index.ts +0 -2
- package/src/components/ui/distance/types.ts +5 -10
- package/src/components/ui/image-overview/ImageOverview.vue +58 -0
- package/src/components/ui/image-overview/index.ts +1 -0
- package/src/components/ui/image-viewer/drawing.ts +1 -0
- package/src/components/ui/project-loader/ProjectLoader.vue +5 -0
- package/src/components/ui/slider/Slider.vue +17 -5
- package/src/components/ui/toggle-image/ToggleImage.vue +29 -0
- package/src/components/ui/toggle-image/index.ts +2 -0
- package/src/components/ui/toggle-image/types.ts +5 -0
- package/src/data/models/annotations.ts +50 -8
- package/src/data/models/camera.ts +5 -0
- package/src/data/models/distance.ts +8 -5
- package/src/data/models/project.ts +18 -17
- package/src/data/providers/orthanc_providers.ts +3 -2
- package/src/data/providers/routes.ts +5 -2
- package/src/data/repositories/project_repository.ts +4 -3
- package/src/lib/persistence.ts +4 -4
- package/src/lib/utils.ts +7 -2
- package/tests/annotations.test.ts +1 -1
- package/src/components/ui/carousel/Carousel.vue +0 -52
- package/src/components/ui/carousel/CarouselContent.vue +0 -29
- package/src/components/ui/carousel/CarouselItem.vue +0 -23
- package/src/components/ui/carousel/CarouselNext.vue +0 -31
- package/src/components/ui/carousel/CarouselPrevious.vue +0 -31
- package/src/components/ui/carousel/index.ts +0 -10
- package/src/components/ui/carousel/interface.ts +0 -26
- package/src/components/ui/carousel/useCarousel.ts +0 -56
- package/src/components/ui/distance/DistanceMeasures.vue +0 -87
- package/src/components/ui/distance/measure.ts +0 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "optica-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Shared frontend core of the Optica viewers",
|
|
5
5
|
"license": "GPL-3.0-or-later",
|
|
6
6
|
"type": "module",
|
|
@@ -30,7 +30,6 @@
|
|
|
30
30
|
"class-variance-authority": "^0.7.1",
|
|
31
31
|
"clsx": "^2.1.1",
|
|
32
32
|
"color": "^4.2.3",
|
|
33
|
-
"embla-carousel-vue": "^8.6.0",
|
|
34
33
|
"mathjs": "^15.2.0",
|
|
35
34
|
"reka-ui": "^2.11.0",
|
|
36
35
|
"tailwind-merge": "^2.6.1",
|
|
@@ -41,9 +40,6 @@
|
|
|
41
40
|
"vuedraggable": "^4.1.0"
|
|
42
41
|
},
|
|
43
42
|
"peerDependenciesMeta": {
|
|
44
|
-
"embla-carousel-vue": {
|
|
45
|
-
"optional": true
|
|
46
|
-
},
|
|
47
43
|
"vee-validate": {
|
|
48
44
|
"optional": true
|
|
49
45
|
},
|
|
@@ -61,7 +57,6 @@
|
|
|
61
57
|
"class-variance-authority": "^0.7.1",
|
|
62
58
|
"clsx": "^2.1.1",
|
|
63
59
|
"color": "^4.2.3",
|
|
64
|
-
"embla-carousel-vue": "^8.6.0",
|
|
65
60
|
"mathjs": "^15.2.0",
|
|
66
61
|
"reka-ui": "^2.11.0",
|
|
67
62
|
"tailwind-merge": "^2.6.1",
|
|
@@ -143,7 +143,7 @@ function onSubmit(event: Event) {
|
|
|
143
143
|
<DialogContent class="flex h-[85vh] w-[50vw] max-w-none sm:max-w-none flex-col overflow-hidden">
|
|
144
144
|
<DialogHeader class="shrink-0">
|
|
145
145
|
<DialogTitle>Credits</DialogTitle>
|
|
146
|
-
<DialogDescription>
|
|
146
|
+
<DialogDescription>Thanks to everyone that contributed to this project</DialogDescription>
|
|
147
147
|
</DialogHeader>
|
|
148
148
|
<div class="min-h-0 h-full w-full flex-1 overflow-y-auto overflow-x-hidden">
|
|
149
149
|
<slot name="credits">
|
|
@@ -3,14 +3,13 @@ import { computed, ref, useTemplateRef } from "vue"
|
|
|
3
3
|
import { round } from "mathjs"
|
|
4
4
|
import draggable from "vuedraggable"
|
|
5
5
|
import { PhEraser, PhEye, PhEyeSlash, PhX } from "@phosphor-icons/vue"
|
|
6
|
-
import { ROUNDING, toScale, type ScaleUnit } from "../../../lib/utils"
|
|
6
|
+
import { ROUNDING, Scale, toScale, type ScaleUnit } from "../../../lib/utils"
|
|
7
7
|
import { Button } from "../button"
|
|
8
8
|
import { Input } from "../input"
|
|
9
9
|
import { Label } from "../label"
|
|
10
10
|
import { LandmarkIteration } from "../landmark"
|
|
11
11
|
import { SelectScale } from "../select-scale"
|
|
12
|
-
import {
|
|
13
|
-
import type { ListDistance, Measure } from "./types"
|
|
12
|
+
import type { ListDistance } from "./types"
|
|
14
13
|
|
|
15
14
|
const scale = defineModel<ScaleUnit>('scale', { required: true })
|
|
16
15
|
|
|
@@ -20,7 +19,6 @@ const props = withDefaults(defineProps<{
|
|
|
20
19
|
// the selected distance shows its landmarks and its length
|
|
21
20
|
selected?: boolean
|
|
22
21
|
adjustFactor?: number
|
|
23
|
-
measure?: Measure<D>
|
|
24
22
|
// units of the scale select
|
|
25
23
|
units?: ScaleUnit[]
|
|
26
24
|
}>(), {
|
|
@@ -46,8 +44,8 @@ const input = useTemplateRef('input')
|
|
|
46
44
|
const scrollSnapType = ref<boolean>(true)
|
|
47
45
|
|
|
48
46
|
const length = computed(() => {
|
|
49
|
-
const value =
|
|
50
|
-
return value == undefined ? 0 : round(toScale(value, props.adjustFactor
|
|
47
|
+
const value = props.distance.length(scale.value)
|
|
48
|
+
return value == undefined ? 0 : round(toScale(value, props.adjustFactor) / Scale[scale.value], ROUNDING)
|
|
51
49
|
})
|
|
52
50
|
|
|
53
51
|
function changeColor(event: Event) {
|
|
@@ -6,7 +6,7 @@ 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
|
|
9
|
+
import type { 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
|
|
@@ -15,7 +15,6 @@ const scale = defineModel<ScaleUnit>('scale', { required: true })
|
|
|
15
15
|
|
|
16
16
|
withDefaults(defineProps<{
|
|
17
17
|
adjustFactor?: number
|
|
18
|
-
measure?: Measure<D>
|
|
19
18
|
// units of the scale select
|
|
20
19
|
units?: ScaleUnit[]
|
|
21
20
|
}>(), {
|
|
@@ -78,13 +77,13 @@ function deleteDistance(index: number) {
|
|
|
78
77
|
<Button variant="secondary" @click="select(-1)">New..</Button>
|
|
79
78
|
</div>
|
|
80
79
|
<DistanceIteration v-if="selectedDistance" v-model:scale="scale" :distance="selectedDistance"
|
|
81
|
-
:index="selectedIndex" :selected="true" :adjust-factor="adjustFactor" :
|
|
80
|
+
:index="selectedIndex" :selected="true" :adjust-factor="adjustFactor" :units="units"
|
|
82
81
|
@delete="deleteDistance">
|
|
83
82
|
<template v-if="$slots.info" #info="slotProps"><slot name="info" v-bind="slotProps" /></template>
|
|
84
83
|
<template v-if="$slots['landmark-info']" #landmark-info="slotProps"><slot name="landmark-info" v-bind="slotProps" /></template>
|
|
85
84
|
</DistanceIteration>
|
|
86
85
|
<DistanceIteration v-for="{ distance, index } in others" :key="index" v-model:scale="scale" :distance="distance"
|
|
87
|
-
:index="index" :selected="false" :adjust-factor="adjustFactor" :
|
|
86
|
+
:index="index" :selected="false" :adjust-factor="adjustFactor" :units="units" @delete="deleteDistance"
|
|
88
87
|
@select="select">
|
|
89
88
|
<template v-if="$slots.info" #info="slotProps"><slot name="info" v-bind="slotProps" /></template>
|
|
90
89
|
</DistanceIteration>
|
|
@@ -1,5 +1,3 @@
|
|
|
1
1
|
export { default as DistanceIteration } from "./DistanceIteration.vue"
|
|
2
2
|
export { default as DistanceList } from "./DistanceList.vue"
|
|
3
|
-
export { default as DistanceMeasures } from "./DistanceMeasures.vue"
|
|
4
|
-
export { defaultMeasure } from "./measure"
|
|
5
3
|
export type * from "./types"
|
|
@@ -1,22 +1,17 @@
|
|
|
1
1
|
import type { ScaleUnit } from "../../../lib/utils"
|
|
2
2
|
import type { ListLandmark } from "../landmark/types"
|
|
3
3
|
|
|
4
|
-
// What the
|
|
5
|
-
export interface
|
|
4
|
+
// What the distance lists need, the core Distance fits
|
|
5
|
+
export interface ListDistance<L extends ListLandmark = ListLandmark> {
|
|
6
6
|
label: string
|
|
7
7
|
edit_label: boolean
|
|
8
8
|
edit_distance: boolean
|
|
9
|
-
getColorHEX(): string
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
// What the distance lists need, the core Distance fits
|
|
13
|
-
export interface ListDistance<L extends ListLandmark = ListLandmark> extends MeasuredDistance {
|
|
14
9
|
landmarks: L[]
|
|
15
10
|
show: boolean
|
|
11
|
+
getColorHEX(): string
|
|
16
12
|
setColorHEX(color: string): void
|
|
17
13
|
reset(): void
|
|
18
14
|
remove(landmark: L | string): void
|
|
15
|
+
// length in image units for a scale, undefined while it can't be measured
|
|
16
|
+
length(scale?: ScaleUnit): number | undefined
|
|
19
17
|
}
|
|
20
|
-
|
|
21
|
-
// length of a distance in image units for a scale, undefined while it can't be measured
|
|
22
|
-
export type Measure<D> = (distance: D, scale: ScaleUnit) => number | undefined
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { HTMLAttributes } from "vue"
|
|
3
|
+
import { PhArrowsOut } from "@phosphor-icons/vue"
|
|
4
|
+
import { cn } from "../../../lib/utils"
|
|
5
|
+
import { fitCamera, type Camera } from "../../../data/models/camera"
|
|
6
|
+
import type { ProjectImage, Size } from "../../../data/models/image"
|
|
7
|
+
import type { Coordinates } from "../../../data/models/coordinates"
|
|
8
|
+
import { Button } from "../button"
|
|
9
|
+
import { Label } from "../label"
|
|
10
|
+
import { ThumbnailViewer } from "../thumbnail-viewer"
|
|
11
|
+
|
|
12
|
+
// Top of the sidebars: the thumbnail of the shown image with the visible part of the viewer,
|
|
13
|
+
// and under it its label, its position in the group and the fit to screen button
|
|
14
|
+
const props = defineProps<{
|
|
15
|
+
image: ProjectImage
|
|
16
|
+
// size of the full image, the zoom rect of the camera is in its pixels
|
|
17
|
+
size: Size
|
|
18
|
+
// camera of the ImageViewer: the thumbnail shows its zoom rect, the button refits it
|
|
19
|
+
camera: Camera
|
|
20
|
+
// position of the image in its group (stack: "3/12"), hidden without it
|
|
21
|
+
counter?: string
|
|
22
|
+
class?: HTMLAttributes["class"]
|
|
23
|
+
thumbnailClass?: HTMLAttributes["class"]
|
|
24
|
+
}>()
|
|
25
|
+
|
|
26
|
+
const emit = defineEmits<{
|
|
27
|
+
// drag on the thumbnail, as a fraction of its size (sphaer turns it into a rotation)
|
|
28
|
+
drag: [movement: Coordinates]
|
|
29
|
+
}>()
|
|
30
|
+
|
|
31
|
+
const slots = defineSlots<{
|
|
32
|
+
// shown left of the thumbnail, which then takes half of the width (polar: the circular slider)
|
|
33
|
+
aside?(): any
|
|
34
|
+
}>()
|
|
35
|
+
</script>
|
|
36
|
+
|
|
37
|
+
<template>
|
|
38
|
+
<div :class="cn('flex justify-center', props.class)">
|
|
39
|
+
<div class="flex flex-col gap-2" :class="slots.aside ? 'w-[350px] max-w-full' : 'w-4/5'">
|
|
40
|
+
<div class="flex items-center">
|
|
41
|
+
<div v-if="slots.aside" class="w-1/2">
|
|
42
|
+
<slot name="aside" />
|
|
43
|
+
</div>
|
|
44
|
+
<ThumbnailViewer :class="cn(slots.aside ? 'w-1/2 shrink' : 'w-full', props.thumbnailClass)"
|
|
45
|
+
:src="image.thumbnail || image.image" :alt="image.label" :size="size" :zoom-rect="camera.zoomRect"
|
|
46
|
+
@drag="(movement) => emit('drag', movement)" />
|
|
47
|
+
</div>
|
|
48
|
+
<div class="flex items-center gap-2">
|
|
49
|
+
<Label class="block min-w-0 truncate leading-normal" :title="image.label">{{ image.label }}</Label>
|
|
50
|
+
<Label v-if="counter" class="ml-auto flex-none tabular-nums text-muted-foreground">{{ counter }}</Label>
|
|
51
|
+
<Button variant="ghost" size="icon-sm" class="flex-none" :class="{ 'ml-auto': !counter }" title="Fit to screen"
|
|
52
|
+
aria-label="Fit to screen" @click="fitCamera(camera)">
|
|
53
|
+
<PhArrowsOut />
|
|
54
|
+
</Button>
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
</template>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default as ImageOverview } from "./ImageOverview.vue"
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Coordinates } from "../../../data/models/coordinates"
|
|
2
|
+
import type { Landmark } from "../../../data/models/landmark"
|
|
2
3
|
import { degreesToRad, SPACE_TARGET } from "../../../lib/utils"
|
|
3
4
|
|
|
4
5
|
export function posCircle(center: Coordinates, angle: number, radius: number): Coordinates {
|
|
@@ -17,6 +17,11 @@ defineSlots<{
|
|
|
17
17
|
const { isPending, isError, data, error } = useQuery({
|
|
18
18
|
queryKey: props.queryKey,
|
|
19
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,
|
|
20
25
|
})
|
|
21
26
|
</script>
|
|
22
27
|
|
|
@@ -5,10 +5,16 @@ import { reactiveOmit } from "@vueuse/core"
|
|
|
5
5
|
import { cn } from "../../../lib/utils"
|
|
6
6
|
import { SliderRange, SliderRoot, SliderThumb, SliderTrack, useForwardPropsEmits } from "reka-ui"
|
|
7
7
|
|
|
8
|
-
const props = defineProps<SliderRootProps & {
|
|
8
|
+
const props = defineProps<SliderRootProps & {
|
|
9
|
+
class?: HTMLAttributes["class"]
|
|
10
|
+
// e.g. bg-transparent to show what is behind the slider (spectral: the colours of the wavelengths)
|
|
11
|
+
trackClass?: HTMLAttributes["class"]
|
|
12
|
+
rangeClass?: HTMLAttributes["class"]
|
|
13
|
+
thumbClass?: HTMLAttributes["class"]
|
|
14
|
+
}>()
|
|
9
15
|
const emits = defineEmits<SliderRootEmits>()
|
|
10
16
|
|
|
11
|
-
const delegatedProps = reactiveOmit(props, "class")
|
|
17
|
+
const delegatedProps = reactiveOmit(props, "class", "trackClass", "rangeClass", "thumbClass")
|
|
12
18
|
|
|
13
19
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
|
14
20
|
</script>
|
|
@@ -25,11 +31,14 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
|
|
25
31
|
>
|
|
26
32
|
<SliderTrack
|
|
27
33
|
data-slot="slider-track"
|
|
28
|
-
class="
|
|
34
|
+
:class="cn(
|
|
35
|
+
'bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5',
|
|
36
|
+
props.trackClass,
|
|
37
|
+
)"
|
|
29
38
|
>
|
|
30
39
|
<SliderRange
|
|
31
40
|
data-slot="slider-range"
|
|
32
|
-
class="bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full"
|
|
41
|
+
:class="cn('bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full', props.rangeClass)"
|
|
33
42
|
/>
|
|
34
43
|
</SliderTrack>
|
|
35
44
|
|
|
@@ -37,7 +46,10 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
|
|
37
46
|
v-for="(_, key) in modelValue"
|
|
38
47
|
:key="key"
|
|
39
48
|
data-slot="slider-thumb"
|
|
40
|
-
class="
|
|
49
|
+
:class="cn(
|
|
50
|
+
'bg-white border-primary ring-ring/50 block size-4 shrink-0 rounded-full border shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50',
|
|
51
|
+
props.thumbClass,
|
|
52
|
+
)"
|
|
41
53
|
/>
|
|
42
54
|
</SliderRoot>
|
|
43
55
|
</template>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ToggleGroup, ToggleGroupItem } from "../toggle-group"
|
|
3
|
+
import type { ToggleImageItem } from "./types"
|
|
4
|
+
|
|
5
|
+
// value of the shown image (depth: acquisition id, stack: stack or a stacked image)
|
|
6
|
+
const selected = defineModel<string>()
|
|
7
|
+
|
|
8
|
+
defineProps<{
|
|
9
|
+
items: ToggleImageItem[]
|
|
10
|
+
}>()
|
|
11
|
+
|
|
12
|
+
// clicking the active button unselects it: keep the shown image
|
|
13
|
+
function select(value: unknown) {
|
|
14
|
+
if (typeof value == "string") {
|
|
15
|
+
selected.value = value
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
</script>
|
|
19
|
+
|
|
20
|
+
<template>
|
|
21
|
+
<div class="flex justify-center p-4">
|
|
22
|
+
<ToggleGroup type="single" variant="outline" :spacing="5" class="w-auto max-w-full flex-wrap justify-center"
|
|
23
|
+
:model-value="selected" @update:model-value="select">
|
|
24
|
+
<ToggleGroupItem v-for="item in items" :key="item.value" :value="item.value">
|
|
25
|
+
{{ item.label }}
|
|
26
|
+
</ToggleGroupItem>
|
|
27
|
+
</ToggleGroup>
|
|
28
|
+
</div>
|
|
29
|
+
</template>
|
|
@@ -1,13 +1,19 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { round } from "mathjs"
|
|
2
|
+
import { adjustFactorFor, isScaleUnit, randomID, ROUNDING, Scale, toScale, type ScaleUnit } from "../../lib/utils"
|
|
2
3
|
import { Distance, type DistanceObject } from "./distance"
|
|
3
4
|
import { Landmark, type LandmarkObject } from "./landmark"
|
|
4
5
|
|
|
6
|
+
// Saved form of a distance of the annotations: with its length shown in the scale of the annotations (not read back)
|
|
7
|
+
export type DistanceAnnotationObject = DistanceObject & {
|
|
8
|
+
value: number
|
|
9
|
+
}
|
|
10
|
+
|
|
5
11
|
// Saved form of the annotations of an acquisition (scale_factor: key of the first polar exports)
|
|
6
12
|
export type AnnotationsObject = {
|
|
7
13
|
scale_factor: number
|
|
8
14
|
scale?: ScaleUnit
|
|
9
15
|
landmarks: LandmarkObject[]
|
|
10
|
-
distances:
|
|
16
|
+
distances: DistanceAnnotationObject[]
|
|
11
17
|
}
|
|
12
18
|
|
|
13
19
|
// Sidebar state of the annotations: kept across reloads, not exported
|
|
@@ -16,7 +22,24 @@ export type AnnotationsSelection = {
|
|
|
16
22
|
selectedDistanceIndex: number
|
|
17
23
|
}
|
|
18
24
|
|
|
19
|
-
|
|
25
|
+
// What a Project needs from the annotations of an acquisition (export, import, persistence):
|
|
26
|
+
// the core Annotations, or an app's own set when its landmarks differ (sphaer: one pose per view)
|
|
27
|
+
export interface AnnotationsSet {
|
|
28
|
+
// saved form, also what merge and restore read back
|
|
29
|
+
toJSON(): object
|
|
30
|
+
// adds saved annotations (import)
|
|
31
|
+
merge(object: any): void
|
|
32
|
+
// replaces these annotations by saved ones (persistence)
|
|
33
|
+
restore(object: any): void
|
|
34
|
+
// sidebar state, kept across reloads but not exported
|
|
35
|
+
getSelection(): object
|
|
36
|
+
setSelection(selection: any): void
|
|
37
|
+
// the header of toCSVRows
|
|
38
|
+
csvHeader(): string[]
|
|
39
|
+
toCSVRows(): string[][]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const ANNOTATIONS_CSV_HEADER =["Group", "Group Label", "Landmark Label", "Color", "Pose_X", "Pose_Y", "Image", "ImageLabel", "X", "Y", "Z"]
|
|
20
43
|
|
|
21
44
|
// one CSV row (ANNOTATIONS_CSV_HEADER), `group`: label of the distance or profile of the landmark
|
|
22
45
|
export function landmarkCSVRow(type: string, group: string, landmark: Landmark): string[] {
|
|
@@ -28,7 +51,7 @@ export function landmarkCSVRow(type: string, group: string, landmark: Landmark):
|
|
|
28
51
|
|
|
29
52
|
// Landmarks and distances of an acquisition: all its images (group and standalone) share
|
|
30
53
|
// one coordinate space, so one calibration. Apps with more annotations or rules extend it.
|
|
31
|
-
export class Annotations {
|
|
54
|
+
export class Annotations implements AnnotationsSet {
|
|
32
55
|
landmarks: Landmark[] = []
|
|
33
56
|
distances: Distance[] = []
|
|
34
57
|
// calibration of the coordinate space
|
|
@@ -51,7 +74,7 @@ export class Annotations {
|
|
|
51
74
|
const used = new Set(this.allLandmarks.map((landmark) => landmark.id))
|
|
52
75
|
let id: string
|
|
53
76
|
do {
|
|
54
|
-
id = (
|
|
77
|
+
id = randomID()
|
|
55
78
|
} while (used.has(id))
|
|
56
79
|
return id
|
|
57
80
|
}
|
|
@@ -68,6 +91,19 @@ export class Annotations {
|
|
|
68
91
|
return this.distances[index]!
|
|
69
92
|
}
|
|
70
93
|
|
|
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)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// typing a known length on a distance calibrates the coordinate space
|
|
100
|
+
calibrate(distance: Distance, target: number) {
|
|
101
|
+
const value = distance.length(this.scale)
|
|
102
|
+
if (value && target > 0) {
|
|
103
|
+
this.adjustFactor = adjustFactorFor(value, target, this.scale)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
71
107
|
removeLandmark(id: string) {
|
|
72
108
|
this.distances.forEach((distance) => distance.remove(id))
|
|
73
109
|
this.landmarks = this.landmarks.filter((landmark) => !landmark.equals(id))
|
|
@@ -91,7 +127,10 @@ export class Annotations {
|
|
|
91
127
|
scale_factor: this.adjustFactor,
|
|
92
128
|
scale: this.scale,
|
|
93
129
|
landmarks: this.landmarks.map((landmark) => landmark.toJSON()),
|
|
94
|
-
distances: this.distances.map((distance) =>
|
|
130
|
+
distances: this.distances.map((distance) => ({
|
|
131
|
+
...distance.toJSON(),
|
|
132
|
+
value: this.scaled(distance.length(this.scale) ?? 0),
|
|
133
|
+
})),
|
|
95
134
|
}
|
|
96
135
|
}
|
|
97
136
|
|
|
@@ -100,7 +139,7 @@ export class Annotations {
|
|
|
100
139
|
protected claimIDs(landmarks: Landmark[], used: Set<string>) {
|
|
101
140
|
landmarks.forEach((landmark) => {
|
|
102
141
|
while (used.has(landmark.id)) {
|
|
103
|
-
landmark.id = (
|
|
142
|
+
landmark.id = randomID()
|
|
104
143
|
}
|
|
105
144
|
used.add(landmark.id)
|
|
106
145
|
})
|
|
@@ -146,9 +185,12 @@ export class Annotations {
|
|
|
146
185
|
}
|
|
147
186
|
}
|
|
148
187
|
|
|
188
|
+
csvHeader(): string[] {
|
|
189
|
+
return ANNOTATIONS_CSV_HEADER
|
|
190
|
+
}
|
|
191
|
+
|
|
149
192
|
// one row per landmark, the ones of a distance carry its label (no header)
|
|
150
193
|
toCSVRows(): string[][] {
|
|
151
|
-
console.log("toCSVRows", this.landmarks, this.distances)
|
|
152
194
|
return [
|
|
153
195
|
...this.landmarks.map((landmark) => landmarkCSVRow("Landmark", "", landmark)),
|
|
154
196
|
...this.distances.flatMap((distance) => distance.landmarks.map((landmark) => landmarkCSVRow("Distance", distance.label, landmark))),
|
|
@@ -15,6 +15,11 @@ export type Camera = {
|
|
|
15
15
|
zoomRect: ZoomRect
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
// The ImageViewer showing this camera fits the whole image on its next update
|
|
19
|
+
export function fitCamera(camera: Camera) {
|
|
20
|
+
camera.zoom = 0
|
|
21
|
+
}
|
|
22
|
+
|
|
18
23
|
export function createCamera(): Camera {
|
|
19
24
|
return {
|
|
20
25
|
zoom: 0,
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import Color from "color"
|
|
2
2
|
import { Landmark, type LandmarkObject } from "./landmark"
|
|
3
3
|
import { distanceVector, type Coordinates, type Position } from "./coordinates"
|
|
4
|
-
import { computeDistance, randomColorRGB } from "../../lib/utils"
|
|
4
|
+
import { computeDistance, randomColorRGB, type ScaleUnit } from "../../lib/utils"
|
|
5
5
|
|
|
6
6
|
export type DistanceObject = {
|
|
7
7
|
label: string
|
|
8
8
|
landmarks: Array<LandmarkObject>
|
|
9
|
-
color: string
|
|
9
|
+
color: string,
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
export class Distance {
|
|
@@ -50,9 +50,11 @@ export class Distance {
|
|
|
50
50
|
return distance
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
53
|
+
// length in image units: pixel length for the "px" scale, physical length otherwise.
|
|
54
|
+
// undefined while it can't be measured
|
|
55
|
+
length(scale?: ScaleUnit) : number | undefined {
|
|
56
|
+
const intervals = scale == "px" ? this.distancePx : this.distance
|
|
57
|
+
return intervals === undefined ? undefined : computeDistance(intervals)
|
|
56
58
|
}
|
|
57
59
|
|
|
58
60
|
in(landmark : Landmark | string) : boolean{
|
|
@@ -80,6 +82,7 @@ export class Distance {
|
|
|
80
82
|
this.color = Color.rgb(color[0], color[1], color[2])
|
|
81
83
|
}
|
|
82
84
|
|
|
85
|
+
// the value (shown length) needs the calibration: Annotations.toJSON adds it
|
|
83
86
|
toJSON() : DistanceObject {
|
|
84
87
|
return { label: this.label, color: this.color.hex(), landmarks : this.landmarks.map((x) => x.toJSON())}
|
|
85
88
|
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Annotations, type AnnotationsSet } from "./annotations"
|
|
2
2
|
import { createCamera, type Camera } from "./camera"
|
|
3
3
|
import type { ProjectImage, Size } from "./image"
|
|
4
4
|
|
|
5
5
|
// One acquisition as sent by the backend
|
|
6
|
-
export type AcquisitionObject<TImage extends ProjectImage> = {
|
|
6
|
+
export type AcquisitionObject<TImage extends ProjectImage = ProjectImage> = {
|
|
7
7
|
id: string
|
|
8
8
|
label?: string
|
|
9
9
|
// images taken together, in order: the stack, the rotation, the spectral images, the sphere of views
|
|
@@ -14,8 +14,9 @@ export type AcquisitionObject<TImage extends ProjectImage> = {
|
|
|
14
14
|
thumbnails: boolean
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
// Images taken together, with the viewer state and the annotations of the acquisition
|
|
18
|
-
|
|
17
|
+
// Images taken together, with the viewer state and the annotations of the acquisition.
|
|
18
|
+
// TAnnotations: an app's own set when its landmarks are not the core ones (sphaer)
|
|
19
|
+
export class Acquisition<TImage extends ProjectImage = ProjectImage, TAnnotations extends AnnotationsSet = Annotations> {
|
|
19
20
|
id: string
|
|
20
21
|
label: string
|
|
21
22
|
images: TImage[]
|
|
@@ -24,7 +25,7 @@ export class Acquisition<TImage extends ProjectImage> {
|
|
|
24
25
|
thumbnails: boolean
|
|
25
26
|
camera: Camera
|
|
26
27
|
// shared by the images of the group and the standalone ones
|
|
27
|
-
annotations:
|
|
28
|
+
annotations: TAnnotations
|
|
28
29
|
// position in images
|
|
29
30
|
index: number
|
|
30
31
|
// name of the shown standalone image, null when an image of the group is shown
|
|
@@ -44,9 +45,9 @@ export class Acquisition<TImage extends ProjectImage> {
|
|
|
44
45
|
this.standalone = (this.images.length == 0) ? (this.standaloneImages[0]?.name ?? null) : null
|
|
45
46
|
}
|
|
46
47
|
|
|
47
|
-
// apps with their own
|
|
48
|
-
protected createAnnotations():
|
|
49
|
-
return new Annotations()
|
|
48
|
+
// apps with their own annotations override it (and `declare` the annotations type, or pass TAnnotations)
|
|
49
|
+
protected createAnnotations(): TAnnotations {
|
|
50
|
+
return new Annotations() as AnnotationsSet as TAnnotations
|
|
50
51
|
}
|
|
51
52
|
|
|
52
53
|
get hasGroup(): boolean {
|
|
@@ -126,7 +127,7 @@ export class Acquisition<TImage extends ProjectImage> {
|
|
|
126
127
|
}
|
|
127
128
|
|
|
128
129
|
// An object and its acquisitions
|
|
129
|
-
export class Project<TImage extends ProjectImage = ProjectImage, TAcquisition extends Acquisition<TImage> = Acquisition<TImage>> {
|
|
130
|
+
export class Project<TImage extends ProjectImage = ProjectImage, TAcquisition extends Acquisition<TImage, AnnotationsSet> = Acquisition<TImage>> {
|
|
130
131
|
objectPath: string
|
|
131
132
|
acquisitions: TAcquisition[]
|
|
132
133
|
// position in acquisitions
|
|
@@ -168,14 +169,14 @@ export class Project<TImage extends ProjectImage = ProjectImage, TAcquisition ex
|
|
|
168
169
|
}
|
|
169
170
|
|
|
170
171
|
// annotations of every acquisition, by acquisition id (export and persistence)
|
|
171
|
-
annotationsToJSON(): Record<string,
|
|
172
|
-
return Object.fromEntries(this.acquisitions.map((acquisition) => [acquisition.id, acquisition.annotations.toJSON()]))
|
|
172
|
+
annotationsToJSON(): Record<string, ReturnType<TAcquisition["annotations"]["toJSON"]>> {
|
|
173
|
+
return Object.fromEntries(this.acquisitions.map((acquisition) => [acquisition.id, acquisition.annotations.toJSON()])) as Record<string, ReturnType<TAcquisition["annotations"]["toJSON"]>>
|
|
173
174
|
}
|
|
174
175
|
|
|
175
176
|
// Reads annotationsToJSON(), or the annotations of a single acquisition (exports made before
|
|
176
177
|
// the acquisitions) which go to the selected one. replace: restore instead of adding.
|
|
177
|
-
importAnnotations(object: Record<string,
|
|
178
|
-
const apply = (acquisition: TAcquisition | undefined, annotations:
|
|
178
|
+
importAnnotations(object: Record<string, object> | object, replace: boolean = false) {
|
|
179
|
+
const apply = (acquisition: TAcquisition | undefined, annotations: object) => {
|
|
179
180
|
if (replace) {
|
|
180
181
|
acquisition?.annotations.restore(annotations)
|
|
181
182
|
} else {
|
|
@@ -183,18 +184,18 @@ export class Project<TImage extends ProjectImage = ProjectImage, TAcquisition ex
|
|
|
183
184
|
}
|
|
184
185
|
}
|
|
185
186
|
if ("landmarks" in object || "distances" in object) {
|
|
186
|
-
apply(this.acquisition, object
|
|
187
|
+
apply(this.acquisition, object)
|
|
187
188
|
return
|
|
188
189
|
}
|
|
189
|
-
Object.entries(object as Record<string,
|
|
190
|
+
Object.entries(object as Record<string, object>).forEach(([id, annotations]) => {
|
|
190
191
|
apply(this.getAcquisition(id), annotations)
|
|
191
192
|
})
|
|
192
193
|
}
|
|
193
194
|
|
|
194
|
-
// one row per landmark of every acquisition, with a header
|
|
195
|
+
// one row per landmark of every acquisition, with a header (the one of the annotations of the first acquisition)
|
|
195
196
|
annotationsToCSVRows(): string[][] {
|
|
196
197
|
return [
|
|
197
|
-
["Acquisition", ...
|
|
198
|
+
["Acquisition", ...(this.acquisitions[0]?.annotations.csvHeader() ?? new Annotations().csvHeader())],
|
|
198
199
|
...this.acquisitions.flatMap((acquisition) => acquisition.annotations.toCSVRows().map((row) => [acquisition.id, ...row])),
|
|
199
200
|
]
|
|
200
201
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import axios, { type AxiosResponse } from "axios";
|
|
2
2
|
import type { DataProvider } from "./providers";
|
|
3
3
|
import type { Coordinates } from "../models/coordinates";
|
|
4
|
+
import { segment } from "./routes";
|
|
4
5
|
|
|
5
6
|
// Orthanc plugins serve the images by instance id under /<plugin>/
|
|
6
7
|
export class OrthancProvider implements DataProvider {
|
|
@@ -18,11 +19,11 @@ export class OrthancProvider implements DataProvider {
|
|
|
18
19
|
|
|
19
20
|
// the instance id is enough to find the image
|
|
20
21
|
imageUrl(objectPath: string, imageName: string, action: string): string {
|
|
21
|
-
return this.url(imageName + "/" + action)
|
|
22
|
+
return this.url(segment(imageName) + "/" + action)
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
async getImages(objectPath: string): Promise<AxiosResponse> {
|
|
25
|
-
return axios.get(this.url(objectPath + "/images"))
|
|
26
|
+
return axios.get(this.url(segment(objectPath) + "/images"))
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
getFullImage(objectPath: string, imageName : string): string {
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
// An id is one segment of the route: encoded, so an object path or image name can't point to another route
|
|
2
|
+
export const segment = encodeURIComponent
|
|
3
|
+
|
|
1
4
|
// Routes exposed by every Optica backend (see core/api/optica_core/routes.py)
|
|
2
5
|
export const routes = {
|
|
3
|
-
images: (objectPath: string) => `${objectPath}/images`,
|
|
6
|
+
images: (objectPath: string) => `${segment(objectPath)}/images`,
|
|
4
7
|
// a route about one image: the common ones below, and the app ones (depth: "profile")
|
|
5
|
-
image: (objectPath: string, imageName: string, action: string) => `${objectPath}/${imageName}/${action}`,
|
|
8
|
+
image: (objectPath: string, imageName: string, action: string) => `${segment(objectPath)}/${segment(imageName)}/${action}`,
|
|
6
9
|
fullImage: (objectPath: string, imageName: string) => routes.image(objectPath, imageName, "full-image"),
|
|
7
10
|
thumbnail: (objectPath: string, imageName: string) => routes.image(objectPath, imageName, "thumbnail"),
|
|
8
11
|
position: (objectPath: string, imageName: string) => routes.image(objectPath, imageName, "position"),
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import type { DataProvider } from "../providers/providers";
|
|
2
2
|
import type { ProjectImage } from "../models/image";
|
|
3
3
|
import { Acquisition, Project, type AcquisitionObject } from "../models/project";
|
|
4
|
+
import type { AnnotationsSet } from "../models/annotations";
|
|
4
5
|
import { DataRepository } from "./data_repository";
|
|
5
6
|
|
|
6
7
|
// /images payload -> acquisitions; the default reads { acquisitions: [...] }
|
|
7
8
|
export type ProjectParser<TImage extends ProjectImage> = (data: any) => AcquisitionObject<TImage>[]
|
|
8
9
|
|
|
9
10
|
// builds the acquisitions, apps with their own Acquisition subclass pass one
|
|
10
|
-
export type AcquisitionFactory<TImage extends ProjectImage, TAcquisition extends Acquisition<TImage>> =
|
|
11
|
+
export type AcquisitionFactory<TImage extends ProjectImage, TAcquisition extends Acquisition<TImage, AnnotationsSet>> =
|
|
11
12
|
(object: AcquisitionObject<TImage>) => TAcquisition
|
|
12
13
|
|
|
13
14
|
export function parseAcquisitions<TImage extends ProjectImage>(data: any): AcquisitionObject<TImage>[] {
|
|
@@ -15,7 +16,7 @@ export function parseAcquisitions<TImage extends ProjectImage>(data: any): Acqui
|
|
|
15
16
|
}
|
|
16
17
|
|
|
17
18
|
// Loads /images as a Project, apps whose payload differs pass a parser
|
|
18
|
-
export class ProjectRepository<TImage extends ProjectImage = ProjectImage, TAcquisition extends Acquisition<TImage> = Acquisition<TImage>>
|
|
19
|
+
export class ProjectRepository<TImage extends ProjectImage = ProjectImage, TAcquisition extends Acquisition<TImage, AnnotationsSet> = Acquisition<TImage>>
|
|
19
20
|
extends DataRepository<Project<TImage, TAcquisition>> {
|
|
20
21
|
parse: ProjectParser<TImage>
|
|
21
22
|
createAcquisition: AcquisitionFactory<TImage, TAcquisition>
|
|
@@ -23,7 +24,7 @@ export class ProjectRepository<TImage extends ProjectImage = ProjectImage, TAcqu
|
|
|
23
24
|
constructor(
|
|
24
25
|
provider: DataProvider,
|
|
25
26
|
parse: ProjectParser<TImage> = parseAcquisitions,
|
|
26
|
-
createAcquisition: AcquisitionFactory<TImage, TAcquisition> = (object) => new Acquisition<TImage>(object) as TAcquisition,
|
|
27
|
+
createAcquisition: AcquisitionFactory<TImage, TAcquisition> = (object) => new Acquisition<TImage>(object) as unknown as TAcquisition,
|
|
27
28
|
) {
|
|
28
29
|
super(provider)
|
|
29
30
|
this.parse = parse
|
package/src/lib/persistence.ts
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import { watch } from "vue"
|
|
2
|
-
import type { AnnotationsObject, AnnotationsSelection } from "../data/models/annotations"
|
|
3
2
|
import type { Project } from "../data/models/project"
|
|
4
3
|
|
|
5
4
|
// What is stored: the annotations (same as an export) and the sidebar state, by acquisition id
|
|
6
5
|
type SavedAnnotations = {
|
|
7
|
-
annotations: Record<string,
|
|
8
|
-
selection: Record<string,
|
|
6
|
+
annotations: Record<string, object>
|
|
7
|
+
selection: Record<string, object>
|
|
9
8
|
}
|
|
10
9
|
|
|
11
|
-
// Keeps the annotations of the loaded project in `storage`, by object, across reloads
|
|
10
|
+
// Keeps the annotations of the loaded project in `storage`, by object, across reloads
|
|
11
|
+
// (any AnnotationsSet: the core Annotations or an app's own).
|
|
12
12
|
// The project itself is loaded again from the backend: only the annotations are saved.
|
|
13
13
|
// Call it in a component setup, `project` returns the project of the store.
|
|
14
14
|
export function usePersistedAnnotations(project: () => Project<any, any> | null | undefined, storage: Storage = sessionStorage) {
|
package/src/lib/utils.ts
CHANGED
|
@@ -31,6 +31,11 @@ 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
|
+
// short random base-36 id (landmarks)
|
|
35
|
+
export function randomID(): string {
|
|
36
|
+
return (Math.random() + 1).toString(36).substring(7)
|
|
37
|
+
}
|
|
38
|
+
|
|
34
39
|
export enum Scale {
|
|
35
40
|
px = 1,
|
|
36
41
|
m = 1000,
|
|
@@ -59,8 +64,8 @@ export const SPACE_TARGET = 0.2
|
|
|
59
64
|
export const ROUNDING = 2
|
|
60
65
|
|
|
61
66
|
// length in image units -> length shown in `scale`, corrected by the calibration factor
|
|
62
|
-
export function toScale(length: number, adjustFactor: number
|
|
63
|
-
return length * adjustFactor
|
|
67
|
+
export function toScale(length: number, adjustFactor: number): number {
|
|
68
|
+
return length * adjustFactor
|
|
64
69
|
}
|
|
65
70
|
|
|
66
71
|
// calibration factor so that `length` is shown as `target` in `scale`
|
|
@@ -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, thumbnails: false })
|
|
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: [], value: 0 }] })
|
|
94
94
|
expect(a.annotations.distances[0]).toBeInstanceOf(Distance)
|
|
95
95
|
})
|
|
96
96
|
|
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
<script setup lang="ts">
|
|
2
|
-
import type { CarouselEmits, CarouselProps, WithClassAsProps } from "./interface"
|
|
3
|
-
import { cn } from "../../../lib/utils"
|
|
4
|
-
import { useProvideCarousel } from "./useCarousel"
|
|
5
|
-
|
|
6
|
-
const props = withDefaults(defineProps<CarouselProps & WithClassAsProps>(), {
|
|
7
|
-
orientation: "horizontal",
|
|
8
|
-
})
|
|
9
|
-
|
|
10
|
-
const emits = defineEmits<CarouselEmits>()
|
|
11
|
-
|
|
12
|
-
const { canScrollNext, canScrollPrev, carouselApi, carouselRef, orientation, scrollNext, scrollPrev } = useProvideCarousel(props, emits)
|
|
13
|
-
|
|
14
|
-
defineExpose({
|
|
15
|
-
canScrollNext,
|
|
16
|
-
canScrollPrev,
|
|
17
|
-
carouselApi,
|
|
18
|
-
carouselRef,
|
|
19
|
-
orientation,
|
|
20
|
-
scrollNext,
|
|
21
|
-
scrollPrev,
|
|
22
|
-
})
|
|
23
|
-
|
|
24
|
-
function onKeyDown(event: KeyboardEvent) {
|
|
25
|
-
const prevKey = props.orientation === "vertical" ? "ArrowUp" : "ArrowLeft"
|
|
26
|
-
const nextKey = props.orientation === "vertical" ? "ArrowDown" : "ArrowRight"
|
|
27
|
-
|
|
28
|
-
if (event.key === prevKey) {
|
|
29
|
-
event.preventDefault()
|
|
30
|
-
scrollPrev()
|
|
31
|
-
|
|
32
|
-
return
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
if (event.key === nextKey) {
|
|
36
|
-
event.preventDefault()
|
|
37
|
-
scrollNext()
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
</script>
|
|
41
|
-
|
|
42
|
-
<template>
|
|
43
|
-
<div
|
|
44
|
-
:class="cn('relative', props.class)"
|
|
45
|
-
role="region"
|
|
46
|
-
aria-roledescription="carousel"
|
|
47
|
-
tabindex="0"
|
|
48
|
-
@keydown="onKeyDown"
|
|
49
|
-
>
|
|
50
|
-
<slot :can-scroll-next :can-scroll-prev :carousel-api :carousel-ref :orientation :scroll-next :scroll-prev />
|
|
51
|
-
</div>
|
|
52
|
-
</template>
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
<script setup lang="ts">
|
|
2
|
-
import type { WithClassAsProps } from "./interface"
|
|
3
|
-
import { cn } from "../../../lib/utils"
|
|
4
|
-
import { useCarousel } from "./useCarousel"
|
|
5
|
-
|
|
6
|
-
defineOptions({
|
|
7
|
-
inheritAttrs: false,
|
|
8
|
-
})
|
|
9
|
-
|
|
10
|
-
const props = defineProps<WithClassAsProps>()
|
|
11
|
-
|
|
12
|
-
const { carouselRef, orientation } = useCarousel()
|
|
13
|
-
</script>
|
|
14
|
-
|
|
15
|
-
<template>
|
|
16
|
-
<div ref="carouselRef" class="h-full overflow-hidden">
|
|
17
|
-
<div
|
|
18
|
-
:class="
|
|
19
|
-
cn(
|
|
20
|
-
'flex h-full',
|
|
21
|
-
orientation === 'horizontal' ? '-ml-4' : '-mt-4 flex-col',
|
|
22
|
-
props.class,
|
|
23
|
-
)"
|
|
24
|
-
v-bind="$attrs"
|
|
25
|
-
>
|
|
26
|
-
<slot />
|
|
27
|
-
</div>
|
|
28
|
-
</div>
|
|
29
|
-
</template>
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
<script setup lang="ts">
|
|
2
|
-
import type { WithClassAsProps } from "./interface"
|
|
3
|
-
import { cn } from "../../../lib/utils"
|
|
4
|
-
import { useCarousel } from "./useCarousel"
|
|
5
|
-
|
|
6
|
-
const props = defineProps<WithClassAsProps>()
|
|
7
|
-
|
|
8
|
-
const { orientation } = useCarousel()
|
|
9
|
-
</script>
|
|
10
|
-
|
|
11
|
-
<template>
|
|
12
|
-
<div
|
|
13
|
-
role="group"
|
|
14
|
-
aria-roledescription="slide"
|
|
15
|
-
:class="cn(
|
|
16
|
-
'min-w-0 shrink-0 grow-0 basis-full',
|
|
17
|
-
orientation === 'horizontal' ? 'pl-4' : 'pt-4',
|
|
18
|
-
props.class,
|
|
19
|
-
)"
|
|
20
|
-
>
|
|
21
|
-
<slot />
|
|
22
|
-
</div>
|
|
23
|
-
</template>
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
<script setup lang="ts">
|
|
2
|
-
import type { WithClassAsProps } from "./interface"
|
|
3
|
-
import { PhArrowRight } from '@phosphor-icons/vue'
|
|
4
|
-
import { cn } from "../../../lib/utils"
|
|
5
|
-
import { Button } from '../button'
|
|
6
|
-
import { useCarousel } from "./useCarousel"
|
|
7
|
-
|
|
8
|
-
const props = defineProps<WithClassAsProps>()
|
|
9
|
-
|
|
10
|
-
const { orientation, canScrollNext, scrollNext } = useCarousel()
|
|
11
|
-
</script>
|
|
12
|
-
|
|
13
|
-
<template>
|
|
14
|
-
<Button
|
|
15
|
-
:disabled="!canScrollNext"
|
|
16
|
-
:class="cn(
|
|
17
|
-
'touch-manipulation absolute h-8 w-8 rounded-full p-0',
|
|
18
|
-
orientation === 'horizontal'
|
|
19
|
-
? '-right-12 top-1/2 -translate-y-1/2'
|
|
20
|
-
: '-bottom-12 left-1/2 -translate-x-1/2 rotate-90',
|
|
21
|
-
props.class,
|
|
22
|
-
)"
|
|
23
|
-
variant="outline"
|
|
24
|
-
@click="scrollNext"
|
|
25
|
-
>
|
|
26
|
-
<slot>
|
|
27
|
-
<PhArrowRight class="h-4 w-4 text-current" />
|
|
28
|
-
<span class="sr-only">Next Slide</span>
|
|
29
|
-
</slot>
|
|
30
|
-
</Button>
|
|
31
|
-
</template>
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
<script setup lang="ts">
|
|
2
|
-
import type { WithClassAsProps } from "./interface"
|
|
3
|
-
import { PhArrowLeft } from '@phosphor-icons/vue'
|
|
4
|
-
import { cn } from "../../../lib/utils"
|
|
5
|
-
import { Button } from '../button'
|
|
6
|
-
import { useCarousel } from "./useCarousel"
|
|
7
|
-
|
|
8
|
-
const props = defineProps<WithClassAsProps>()
|
|
9
|
-
|
|
10
|
-
const { orientation, canScrollPrev, scrollPrev } = useCarousel()
|
|
11
|
-
</script>
|
|
12
|
-
|
|
13
|
-
<template>
|
|
14
|
-
<Button
|
|
15
|
-
:disabled="!canScrollPrev"
|
|
16
|
-
:class="cn(
|
|
17
|
-
'touch-manipulation absolute h-8 w-8 rounded-full p-0',
|
|
18
|
-
orientation === 'horizontal'
|
|
19
|
-
? '-left-12 top-1/2 -translate-y-1/2'
|
|
20
|
-
: '-top-12 left-1/2 -translate-x-1/2 rotate-90',
|
|
21
|
-
props.class,
|
|
22
|
-
)"
|
|
23
|
-
variant="outline"
|
|
24
|
-
@click="scrollPrev"
|
|
25
|
-
>
|
|
26
|
-
<slot>
|
|
27
|
-
<PhArrowLeft class="h-4 w-4 text-current" />
|
|
28
|
-
<span class="sr-only">Previous Slide</span>
|
|
29
|
-
</slot>
|
|
30
|
-
</Button>
|
|
31
|
-
</template>
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
export { default as Carousel } from "./Carousel.vue"
|
|
2
|
-
export { default as CarouselContent } from "./CarouselContent.vue"
|
|
3
|
-
export { default as CarouselItem } from "./CarouselItem.vue"
|
|
4
|
-
export { default as CarouselNext } from "./CarouselNext.vue"
|
|
5
|
-
export { default as CarouselPrevious } from "./CarouselPrevious.vue"
|
|
6
|
-
export type {
|
|
7
|
-
UnwrapRefCarouselApi as CarouselApi,
|
|
8
|
-
} from "./interface"
|
|
9
|
-
|
|
10
|
-
export { useCarousel } from "./useCarousel"
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
import type useEmblaCarousel from "embla-carousel-vue"
|
|
2
|
-
import type {
|
|
3
|
-
EmblaCarouselVueType,
|
|
4
|
-
} from "embla-carousel-vue"
|
|
5
|
-
import type { HTMLAttributes, UnwrapRef } from "vue"
|
|
6
|
-
|
|
7
|
-
type CarouselApi = EmblaCarouselVueType[1]
|
|
8
|
-
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
|
|
9
|
-
type CarouselOptions = UseCarouselParameters[0]
|
|
10
|
-
type CarouselPlugin = UseCarouselParameters[1]
|
|
11
|
-
|
|
12
|
-
export type UnwrapRefCarouselApi = UnwrapRef<CarouselApi>
|
|
13
|
-
|
|
14
|
-
export interface CarouselProps {
|
|
15
|
-
opts?: CarouselOptions
|
|
16
|
-
plugins?: CarouselPlugin
|
|
17
|
-
orientation?: "horizontal" | "vertical"
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export interface CarouselEmits {
|
|
21
|
-
(e: "init-api", payload: UnwrapRefCarouselApi): void
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export interface WithClassAsProps {
|
|
25
|
-
class?: HTMLAttributes["class"]
|
|
26
|
-
}
|
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
import type { UnwrapRefCarouselApi as CarouselApi, CarouselEmits, CarouselProps } from "./interface"
|
|
2
|
-
import { createInjectionState } from "@vueuse/core"
|
|
3
|
-
import emblaCarouselVue from "embla-carousel-vue"
|
|
4
|
-
import { onMounted, ref } from "vue"
|
|
5
|
-
|
|
6
|
-
const [useProvideCarousel, useInjectCarousel] = createInjectionState(
|
|
7
|
-
({
|
|
8
|
-
opts,
|
|
9
|
-
orientation,
|
|
10
|
-
plugins,
|
|
11
|
-
}: CarouselProps, emits: CarouselEmits) => {
|
|
12
|
-
const [emblaNode, emblaApi] = emblaCarouselVue({
|
|
13
|
-
...opts,
|
|
14
|
-
axis: orientation === "horizontal" ? "x" : "y",
|
|
15
|
-
}, plugins)
|
|
16
|
-
|
|
17
|
-
function scrollPrev() {
|
|
18
|
-
emblaApi.value?.scrollPrev()
|
|
19
|
-
}
|
|
20
|
-
function scrollNext() {
|
|
21
|
-
emblaApi.value?.scrollNext()
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
const canScrollNext = ref(false)
|
|
25
|
-
const canScrollPrev = ref(false)
|
|
26
|
-
|
|
27
|
-
function onSelect(api: CarouselApi) {
|
|
28
|
-
canScrollNext.value = api?.canScrollNext() || false
|
|
29
|
-
canScrollPrev.value = api?.canScrollPrev() || false
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
onMounted(() => {
|
|
33
|
-
if (!emblaApi.value)
|
|
34
|
-
return
|
|
35
|
-
|
|
36
|
-
emblaApi.value?.on("init", onSelect)
|
|
37
|
-
emblaApi.value?.on("reInit", onSelect)
|
|
38
|
-
emblaApi.value?.on("select", onSelect)
|
|
39
|
-
|
|
40
|
-
emits("init-api", emblaApi.value)
|
|
41
|
-
})
|
|
42
|
-
|
|
43
|
-
return { carouselRef: emblaNode, carouselApi: emblaApi, canScrollPrev, canScrollNext, scrollPrev, scrollNext, orientation }
|
|
44
|
-
},
|
|
45
|
-
)
|
|
46
|
-
|
|
47
|
-
function useCarousel() {
|
|
48
|
-
const carouselState = useInjectCarousel()
|
|
49
|
-
|
|
50
|
-
if (!carouselState)
|
|
51
|
-
throw new Error("useCarousel must be used within a <Carousel />")
|
|
52
|
-
|
|
53
|
-
return carouselState
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export { useCarousel, useProvideCarousel }
|
|
@@ -1,87 +0,0 @@
|
|
|
1
|
-
<script setup lang="ts" generic="D extends MeasuredDistance">
|
|
2
|
-
import { round } from "mathjs"
|
|
3
|
-
import { PhCircle, PhX } from "@phosphor-icons/vue"
|
|
4
|
-
import { adjustFactorFor, toScale, type ScaleUnit } from "../../../lib/utils"
|
|
5
|
-
import { Button } from "../button"
|
|
6
|
-
import { Input } from "../input"
|
|
7
|
-
import { Label } from "../label"
|
|
8
|
-
import { Separator } from "../separator"
|
|
9
|
-
import { SelectScale } from "../select-scale"
|
|
10
|
-
import { defaultMeasure } from "./measure"
|
|
11
|
-
import type { MeasuredDistance, Measure } from "./types"
|
|
12
|
-
|
|
13
|
-
const distances = defineModel<D[]>({ required: true })
|
|
14
|
-
const scale = defineModel<ScaleUnit>('scale', { required: true })
|
|
15
|
-
// calibration: typing a known length on a distance sets it
|
|
16
|
-
const adjustFactor = defineModel<number>('adjustFactor', { required: true })
|
|
17
|
-
|
|
18
|
-
const props = withDefaults(defineProps<{
|
|
19
|
-
measure?: Measure<D>
|
|
20
|
-
// units of the scale select
|
|
21
|
-
units?: ScaleUnit[]
|
|
22
|
-
// header with the scale select and the reset button, hidden when empty
|
|
23
|
-
title?: string
|
|
24
|
-
rounding?: number
|
|
25
|
-
}>(), {
|
|
26
|
-
title: "Measures",
|
|
27
|
-
rounding: 5,
|
|
28
|
-
})
|
|
29
|
-
|
|
30
|
-
const STEP = 0.01
|
|
31
|
-
|
|
32
|
-
function length(distance: D): number {
|
|
33
|
-
const value = (props.measure ?? defaultMeasure)(distance, scale.value)
|
|
34
|
-
return value == undefined ? 0 : toScale(value, adjustFactor.value, scale.value)
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function calibrate(payload: string | number, distance: D) {
|
|
38
|
-
const value = (props.measure ?? defaultMeasure)(distance, scale.value)
|
|
39
|
-
const target = Number(payload)
|
|
40
|
-
if (value && target > 0) {
|
|
41
|
-
adjustFactor.value = adjustFactorFor(value, target, scale.value)
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function changeLabel(payload: string | number, distance: D) {
|
|
46
|
-
distance.label = payload.toString()
|
|
47
|
-
}
|
|
48
|
-
</script>
|
|
49
|
-
|
|
50
|
-
<template>
|
|
51
|
-
<div class="w-full">
|
|
52
|
-
<div v-if="title" class="flex flex-row">
|
|
53
|
-
<h2 class="flex items-center px-7 text-lg font-semibold tracking-tight">{{ title }}</h2>
|
|
54
|
-
<div class="w-full h-full flex justify-end space-x-2">
|
|
55
|
-
<SelectScale v-model="scale" :units="units" />
|
|
56
|
-
<Button variant="secondary" @click="adjustFactor = 1">Reset Scale</Button>
|
|
57
|
-
</div>
|
|
58
|
-
</div>
|
|
59
|
-
<div class="flex min-h-48 min-w-full w-fit flex-col border">
|
|
60
|
-
<div v-for="(distance, index) in distances" :key="index" class="flex flex-col min-w-full w-fit h-10">
|
|
61
|
-
<div class="flex flex-row items-center justify-between space-x-3 px-3 w-full h-full">
|
|
62
|
-
<div class="flex flex-row items-center justify-start space-x-3">
|
|
63
|
-
<PhCircle weight="fill" :color="distance.getColorHEX()" />
|
|
64
|
-
<Label v-show="!distance.edit_label" class="flex whitespace-nowrap w-auto"
|
|
65
|
-
@dblclick="distance.edit_label = true">{{ distance.label }}</Label>
|
|
66
|
-
<Input v-show="distance.edit_label" type="text" :model-value="distance.label" class="flex h-auto w-36 px-0"
|
|
67
|
-
@focusout="distance.edit_label = false" @keyup.enter="distance.edit_label = false"
|
|
68
|
-
@update:model-value="changeLabel($event, distance)" />
|
|
69
|
-
</div>
|
|
70
|
-
<div class="flex flex-row items-center justify-end space-x-3">
|
|
71
|
-
<Label v-show="!distance.edit_distance" class="flex whitespace-nowrap w-auto"
|
|
72
|
-
@dblclick="distance.edit_distance = true">
|
|
73
|
-
{{ round(length(distance), rounding) }} {{ scale }}
|
|
74
|
-
</Label>
|
|
75
|
-
<Input v-show="distance.edit_distance" type="number" :min="0" :step="STEP" :model-value="length(distance)"
|
|
76
|
-
class="flex h-auto w-auto px-0" @focusout="distance.edit_distance = false"
|
|
77
|
-
@keyup.enter="distance.edit_distance = false" @update:model-value="calibrate($event, distance)" />
|
|
78
|
-
<Button class="relative w-6 h-6 p-0" variant="destructive" @click="distances.splice(index, 1)">
|
|
79
|
-
<PhX class="relative w-4 h-4 p-0" />
|
|
80
|
-
</Button>
|
|
81
|
-
</div>
|
|
82
|
-
</div>
|
|
83
|
-
<Separator class="w-full" />
|
|
84
|
-
</div>
|
|
85
|
-
</div>
|
|
86
|
-
</div>
|
|
87
|
-
</template>
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
import type { Coordinates } from "../../../data/models/coordinates"
|
|
2
|
-
import { computeDistance, type ScaleUnit } from "../../../lib/utils"
|
|
3
|
-
|
|
4
|
-
// Measure of the core Distance: physical length, pixel length for the "px" scale
|
|
5
|
-
export function defaultMeasure(distance: unknown, scale: ScaleUnit): number | undefined {
|
|
6
|
-
const d = distance as { distance?: Coordinates[], distancePx?: Coordinates[] }
|
|
7
|
-
const intervals = scale == "px" ? d.distancePx : d.distance
|
|
8
|
-
return Array.isArray(intervals) ? computeDistance(intervals) : undefined
|
|
9
|
-
}
|