optica-core 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/package.json +10 -8
  2. package/src/components/ui/app-menu/AppMenu.vue +1 -1
  3. package/src/components/ui/distance/DistanceIteration.vue +4 -6
  4. package/src/components/ui/distance/DistanceList.vue +3 -4
  5. package/src/components/ui/distance/index.ts +0 -2
  6. package/src/components/ui/distance/types.ts +5 -10
  7. package/src/components/ui/image-overview/ImageOverview.vue +58 -0
  8. package/src/components/ui/image-overview/index.ts +1 -0
  9. package/src/components/ui/image-viewer/drawing.ts +1 -0
  10. package/src/components/ui/project-loader/ProjectLoader.vue +5 -0
  11. package/src/components/ui/slider/Slider.vue +17 -5
  12. package/src/components/ui/toggle-image/ToggleImage.vue +29 -0
  13. package/src/components/ui/toggle-image/index.ts +2 -0
  14. package/src/components/ui/toggle-image/types.ts +5 -0
  15. package/src/data/models/annotations.ts +50 -8
  16. package/src/data/models/camera.ts +5 -0
  17. package/src/data/models/distance.ts +8 -5
  18. package/src/data/models/project.ts +18 -17
  19. package/src/data/providers/orthanc_providers.ts +3 -2
  20. package/src/data/providers/routes.ts +5 -2
  21. package/src/data/repositories/project_repository.ts +4 -3
  22. package/src/lib/persistence.ts +4 -4
  23. package/src/lib/utils.ts +7 -2
  24. package/tests/annotations.test.ts +1 -1
  25. package/vite/index.d.ts +14 -0
  26. package/vite/index.js +83 -0
  27. package/optica-core-0.1.0.tgz +0 -0
  28. package/src/components/ui/carousel/Carousel.vue +0 -52
  29. package/src/components/ui/carousel/CarouselContent.vue +0 -29
  30. package/src/components/ui/carousel/CarouselItem.vue +0 -23
  31. package/src/components/ui/carousel/CarouselNext.vue +0 -31
  32. package/src/components/ui/carousel/CarouselPrevious.vue +0 -31
  33. package/src/components/ui/carousel/index.ts +0 -10
  34. package/src/components/ui/carousel/interface.ts +0 -26
  35. package/src/components/ui/carousel/useCarousel.ts +0 -56
  36. package/src/components/ui/distance/DistanceMeasures.vue +0 -87
  37. 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.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Shared frontend core of the Optica viewers",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "type": "module",
@@ -16,11 +16,18 @@
16
16
  ".": "./src/index.ts",
17
17
  "./ui/*": "./src/components/ui/*/index.ts",
18
18
  "./icons/*": "./src/components/icons/*.vue",
19
- "./assets/*": "./src/assets/*"
19
+ "./assets/*": "./src/assets/*",
20
+ "./vite": {
21
+ "types": "./vite/index.d.ts",
22
+ "default": "./vite/index.js"
23
+ }
20
24
  },
21
25
  "scripts": {
22
26
  "type-check": "vue-tsc --noEmit -p tsconfig.json",
23
- "test": "vitest run"
27
+ "test": "vitest run",
28
+ "push": "npm version patch && git push",
29
+ "push-minor": "npm version minor && git push",
30
+ "push-major": "npm version major && git push"
24
31
  },
25
32
  "peerDependencies": {
26
33
  "@phosphor-icons/vue": "^2.2.1",
@@ -30,7 +37,6 @@
30
37
  "class-variance-authority": "^0.7.1",
31
38
  "clsx": "^2.1.1",
32
39
  "color": "^4.2.3",
33
- "embla-carousel-vue": "^8.6.0",
34
40
  "mathjs": "^15.2.0",
35
41
  "reka-ui": "^2.11.0",
36
42
  "tailwind-merge": "^2.6.1",
@@ -41,9 +47,6 @@
41
47
  "vuedraggable": "^4.1.0"
42
48
  },
43
49
  "peerDependenciesMeta": {
44
- "embla-carousel-vue": {
45
- "optional": true
46
- },
47
50
  "vee-validate": {
48
51
  "optional": true
49
52
  },
@@ -61,7 +64,6 @@
61
64
  "class-variance-authority": "^0.7.1",
62
65
  "clsx": "^2.1.1",
63
66
  "color": "^4.2.3",
64
- "embla-carousel-vue": "^8.6.0",
65
67
  "mathjs": "^15.2.0",
66
68
  "reka-ui": "^2.11.0",
67
69
  "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>Thank to everyone that contributed to this project</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 { defaultMeasure } from "./measure"
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 = (props.measure ?? defaultMeasure)(props.distance, scale.value)
50
- return value == undefined ? 0 : round(toScale(value, props.adjustFactor, scale.value), ROUNDING)
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, Measure } from "./types"
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" :measure="measure" :units="units"
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" :measure="measure" :units="units" @delete="deleteDistance"
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 measures table needs from a distance
5
- export interface MeasuredDistance {
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 & { class?: HTMLAttributes["class"] }>()
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="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"
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="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"
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>
@@ -0,0 +1,2 @@
1
+ export { default as ToggleImage } from "./ToggleImage.vue"
2
+ export type * from "./types"
@@ -0,0 +1,5 @@
1
+ // one button of the image toggle
2
+ export interface ToggleImageItem {
3
+ value: string
4
+ label: string
5
+ }
@@ -1,13 +1,19 @@
1
- import { isScaleUnit, type ScaleUnit } from "../../lib/utils"
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: DistanceObject[]
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
- export const ANNOTATIONS_CSV_HEADER = ["Group", "Group Label", "Landmark Label", "Color", "Pose_X", "Pose_Y", "Image", "ImageLabel", "X", "Y", "Z"]
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 = (Math.random() + 1).toString(36).substring(2)
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) => distance.toJSON()),
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 = (Math.random() + 1).toString(36).substring(2)
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
- get length() : number | undefined {
54
- const distance = this.distance
55
- return distance === undefined ? undefined : computeDistance(distance)
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 { ANNOTATIONS_CSV_HEADER, Annotations, type AnnotationsObject } from "./annotations"
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
- export class Acquisition<TImage extends ProjectImage> {
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: 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 Annotations subclass override it (and `declare` the annotations type)
48
- protected createAnnotations(): Annotations {
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, AnnotationsObject> {
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, Partial<AnnotationsObject>> | Partial<AnnotationsObject>, replace: boolean = false) {
178
- const apply = (acquisition: TAcquisition | undefined, annotations: Partial<AnnotationsObject>) => {
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 as Partial<AnnotationsObject>)
187
+ apply(this.acquisition, object)
187
188
  return
188
189
  }
189
- Object.entries(object as Record<string, Partial<AnnotationsObject>>).forEach(([id, annotations]) => {
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", ...ANNOTATIONS_CSV_HEADER],
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
@@ -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, AnnotationsObject>
8
- selection: Record<string, AnnotationsSelection>
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, scale: ScaleUnit): number {
63
- return length * adjustFactor / Scale[scale]
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
 
@@ -0,0 +1,14 @@
1
+ import type { ManualChunkMeta } from 'rollup'
2
+
3
+ export interface ManualChunksOptions {
4
+ /** groups smaller than this (in bytes of source) are packed into vendor chunks, default 100 KiB */
5
+ minGroupSize?: number
6
+ /** maximum size (in bytes of source) of a vendor chunk, default 500 KiB */
7
+ maxVendorSize?: number
8
+ /** package.json whose dependencies are the direct dependencies, default `<cwd>/package.json` */
9
+ packageJson?: string
10
+ }
11
+
12
+ export function createManualChunks(
13
+ options?: ManualChunksOptions,
14
+ ): (id: string, meta: ManualChunkMeta) => string | undefined
package/vite/index.js ADDED
@@ -0,0 +1,83 @@
1
+ // Build helpers for the vite.config.ts of the Optica plugins.
2
+ // Plain JS (+ index.d.ts): Node loads it from node_modules when Vite reads the config, and cannot strip types there.
3
+ import { readFileSync } from 'node:fs'
4
+ import { resolve } from 'node:path'
5
+
6
+ const packageName = (id) => {
7
+ if (!id.includes('node_modules/')) return undefined
8
+ const parts = id.split('node_modules/').pop().split('/')
9
+ return parts[0].startsWith('@') ? `${parts[0]}/${parts[1]}` : parts[0]
10
+ }
11
+
12
+ // Vendor chunking: every package of node_modules is grouped with the direct dependency
13
+ // (from package.json) that imports it, e.g. decimal.js goes with mathjs.
14
+ // Groups smaller than minGroupSize are then packed together into "vendor-N" chunks of at most maxVendorSize.
15
+ // Sizes are measured on the sources before tree-shaking, so they overestimate the output.
16
+ export const createManualChunks = ({
17
+ minGroupSize = 100 * 1024,
18
+ maxVendorSize = 500 * 1024,
19
+ packageJson = resolve(process.cwd(), 'package.json'),
20
+ } = {}) => {
21
+ const directDeps = new Set(Object.keys(JSON.parse(readFileSync(packageJson, 'utf-8')).dependencies ?? {}))
22
+ let chunkOfPackage
23
+
24
+ const computeChunks = ({ getModuleIds, getModuleInfo }) => {
25
+ const modulesOfPackage = new Map()
26
+ for (const id of getModuleIds()) {
27
+ const pkg = packageName(id)
28
+ if (pkg) modulesOfPackage.set(pkg, [...(modulesOfPackage.get(pkg) ?? []), id])
29
+ }
30
+
31
+ // walk up the importers until reaching a direct dependency or the app code
32
+ const ownersOf = (pkg) => {
33
+ const owners = new Set()
34
+ const visited = new Set()
35
+ const queue = [...modulesOfPackage.get(pkg)]
36
+ while (queue.length) {
37
+ const id = queue.pop()
38
+ if (visited.has(id)) continue
39
+ visited.add(id)
40
+ const info = getModuleInfo(id)
41
+ for (const importer of [...(info?.importers ?? []), ...(info?.dynamicImporters ?? [])]) {
42
+ const importerPkg = packageName(importer)
43
+ if (!importerPkg) owners.add(pkg)
44
+ else if (importerPkg === pkg) queue.push(importer)
45
+ else if (directDeps.has(importerPkg)) owners.add(importerPkg)
46
+ else queue.push(importer)
47
+ }
48
+ }
49
+ return owners
50
+ }
51
+
52
+ const groupOfPackage = new Map()
53
+ const groupSize = new Map()
54
+ for (const [pkg, ids] of modulesOfPackage) {
55
+ const owners = directDeps.has(pkg) ? new Set([pkg]) : ownersOf(pkg)
56
+ const group = owners.size === 1 ? [...owners][0] : 'shared'
57
+ groupOfPackage.set(pkg, group)
58
+ const size = ids.reduce((total, id) => total + (getModuleInfo(id)?.code?.length ?? 0), 0)
59
+ groupSize.set(group, (groupSize.get(group) ?? 0) + size)
60
+ }
61
+
62
+ // big groups get their own chunk, small ones are packed into vendor chunks
63
+ const chunkOfGroup = new Map()
64
+ const vendorSizes = []
65
+ const smallGroups = [...groupSize].filter(([group, size]) => group === 'shared' || size < minGroupSize)
66
+ for (const [group] of groupSize) chunkOfGroup.set(group, group.replace('/', '-'))
67
+ for (const [group, size] of smallGroups.sort((a, b) => b[1] - a[1])) {
68
+ let index = vendorSizes.findIndex((total) => total + size <= maxVendorSize)
69
+ if (index === -1) index = vendorSizes.push(0) - 1
70
+ vendorSizes[index] += size
71
+ chunkOfGroup.set(group, `vendor-${index}`)
72
+ }
73
+
74
+ return new Map([...groupOfPackage].map(([pkg, group]) => [pkg, chunkOfGroup.get(group)]))
75
+ }
76
+
77
+ return (id, meta) => {
78
+ const pkg = packageName(id)
79
+ if (!pkg) return undefined
80
+ chunkOfPackage ??= computeChunks(meta)
81
+ return chunkOfPackage.get(pkg)
82
+ }
83
+ }
Binary file
@@ -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
- }