Skip to content

Commit 6cd05da

Browse files
committed
feat(locate-features): add screens
1 parent 9a38b1c commit 6cd05da

9 files changed

Lines changed: 614 additions & 4 deletions
Lines changed: 294 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,294 @@
1+
<script lang="ts" setup>
2+
import { useI18n } from 'vue-i18n'
3+
4+
const { t } = useI18n()
5+
import type {
6+
Project,
7+
TaskGroup,
8+
Tutorial,
9+
CustomOption,
10+
TutorialTileTask,
11+
TileTask,
12+
} from '@/utils/types'
13+
import { computed, inject, onMounted, ref, shallowRef, useTemplateRef, watchEffect } from 'vue'
14+
import { isDefined, isNotDefined, listToMap } from '@togglecorp/fujs'
15+
16+
import createInformationPages from '@/utils/createInformationPages'
17+
import { createFallbackInformationPages } from '@/utils/domain'
18+
import LocateFeaturesProjectTask from '@/components/LocateFeaturesProjectTask.vue'
19+
import LocateFeaturesProjectInstructions from '@/components/LocateFeaturesProjectInstructions.vue'
20+
import LocateFeaturesProjectTutorial from '@/components/LocateFeaturesProjectTutorial.vue'
21+
import buildTasks from '@/utils/buildTasks'
22+
23+
import ProjectInfo from './ProjectInfo.vue'
24+
import ProjectHeader from './ProjectHeader.vue'
25+
import TaskProgress from './TaskProgress.vue'
26+
import type { VContainer } from 'vuetify/components'
27+
import TileMap from './TileMap.vue'
28+
29+
interface Props {
30+
group: TaskGroup
31+
first: boolean
32+
options: CustomOption[]
33+
project: Project
34+
tutorial: Tutorial
35+
tutorialTasks: TutorialTileTask[]
36+
tasks: TileTask[] | undefined
37+
}
38+
39+
const taskOffset = ref(0)
40+
const projectInfoRef = useTemplateRef('projectInfo')
41+
42+
const props = defineProps<Props>()
43+
const taskContainer = shallowRef<VContainer | null>(null)
44+
45+
const logMappingStarted = inject<(projectType: string) => void>('logMappingStarted')
46+
const saveResults =
47+
inject<(results: Record<string, number[]>, startTime: string) => void>('saveResults')
48+
const arrowKeys = ref(true)
49+
const startTime = shallowRef<string>()
50+
const instruction = computed(() =>
51+
isDefined(props.project.projectInstruction)
52+
? props.project.projectInstruction
53+
: t('validateProject.doesTheShapeOutline', { feature: props.project.lookFor }),
54+
)
55+
56+
const emit = defineEmits<{ created: [] }>()
57+
const results = ref<Record<string, number[]>>({})
58+
const selectedTaskIndices = ref<Record<string, boolean[]>>({})
59+
const tileSize = ref<number>(1);
60+
61+
const subGridSizeExponent = computed(() => {
62+
const subGridSizeToExponentMapping: Record<string, number> = {
63+
'2x2': 1,
64+
'4x4': 2,
65+
'8x8': 3,
66+
}
67+
68+
if ('subGridSize' in props.project && typeof props.project.subGridSize === 'string') {
69+
return subGridSizeToExponentMapping[props.project.subGridSize] ?? 1;
70+
}
71+
72+
return 1;
73+
})
74+
75+
const processedTasks = computed(() => {
76+
const tasks = props.tasks?.length ? props.tasks : buildTasks(props.project, props.group)
77+
78+
const sortedTasks = tasks.sort((a, b) => (a.taskId > b.taskId ? 1 : -1))
79+
80+
return sortedTasks
81+
})
82+
83+
const numSubGridElements = computed(() => ((2 ** subGridSizeExponent.value) ** 2))
84+
const defaultSelectionValue = computed(() => (
85+
Array.from(new Array(numSubGridElements.value).keys()).map(
86+
() => false,
87+
)
88+
))
89+
90+
91+
watchEffect(() => {
92+
results.value = listToMap(
93+
processedTasks.value,
94+
({ taskId }) => taskId,
95+
() => Array.from(new Array(numSubGridElements.value).keys()).map(
96+
() => props.options[0].value,
97+
),
98+
)
99+
100+
selectedTaskIndices.value = listToMap(
101+
processedTasks.value,
102+
({ taskId }) => taskId,
103+
() => defaultSelectionValue.value,
104+
)
105+
106+
startTime.value = new Date().toISOString()
107+
})
108+
109+
const optionMapping = computed(() =>
110+
listToMap(
111+
props.options,
112+
({ value }) => value,
113+
)
114+
)
115+
const nextOptionMapping = computed(() =>
116+
listToMap(
117+
props.options,
118+
({ value }) => value,
119+
(_, __, index) => {
120+
if (index === props.options.length - 1) {
121+
return props.options[0].value
122+
}
123+
124+
return props.options[index + 1].value
125+
},
126+
),
127+
)
128+
129+
130+
const currentTask = computed(() => processedTasks.value?.[taskOffset.value] ?? {})
131+
const currentTaskValue = computed(() => results.value[currentTask.value.taskId])
132+
const currentTaskSelections = computed(() => selectedTaskIndices.value[currentTask.value.taskId])
133+
const numSelectedTasks = computed(() => currentTaskSelections.value.filter((isSelected) => isSelected).length)
134+
135+
onMounted(() => {
136+
logMappingStarted?.(props.project.projectType)
137+
emit('created')
138+
})
139+
140+
const isLastTask = computed(() => processedTasks.value.length - 1 === taskOffset.value)
141+
142+
function handleBack() {
143+
if (taskOffset.value > 0) {
144+
taskOffset.value = taskOffset.value - 1
145+
}
146+
}
147+
148+
function handleForward() {
149+
if (taskOffset.value < processedTasks.value.length - 1) {
150+
taskOffset.value = taskOffset.value + 1
151+
}
152+
}
153+
154+
function handleTaskValueChange(taskId: string, newValue: number[]) {
155+
results.value[taskId] = newValue
156+
}
157+
158+
function handleTaskSelectionChange(taskId: string, newValue: boolean[]) {
159+
selectedTaskIndices.value[taskId] = newValue
160+
}
161+
162+
function handleTaskContainerResize() {
163+
const el = taskContainer.value?.$el as HTMLDivElement | null;
164+
const bcr = el?.getBoundingClientRect();
165+
166+
if (isNotDefined(bcr)) {
167+
return undefined;
168+
}
169+
170+
const { width, height } = bcr;
171+
tileSize.value = Math.min(width, height);
172+
}
173+
174+
function handleClearTaskSelection() {
175+
selectedTaskIndices.value[currentTask.value.taskId] = defaultSelectionValue.value;
176+
}
177+
178+
const tileMapPage = computed(() => [currentTask.value])
179+
const allTilesSelected = computed(() => numSelectedTasks.value === numSubGridElements.value)
180+
181+
function handleSelectAll() {
182+
if (allTilesSelected.value) {
183+
handleClearTaskSelection()
184+
} else {
185+
selectedTaskIndices.value[currentTask.value.taskId] = Array.from(
186+
new Array(numSubGridElements.value).keys()).map(() => true
187+
)
188+
}
189+
}
190+
191+
</script>
192+
193+
<template>
194+
<ProjectHeader :mission="instruction">
195+
<v-chip v-if="numSelectedTasks > 0" color="primary" :ripple="false">
196+
{{ numSelectedTasks }}
197+
<span class="hidden-md-and-down">&nbsp;{{ $t('findProject.selected') }}</span>
198+
<v-icon @click="handleClearTaskSelection">mdi-close</v-icon>
199+
</v-chip>
200+
<v-btn
201+
:title="allTilesSelected ? $t('findProject.clearSelection') : $t('findProject.selectAll')"
202+
:icon="'mdi-select-'.concat(allTilesSelected ? 'off' : 'all')"
203+
@click="handleSelectAll"
204+
color="primary"
205+
/>
206+
<TileMap
207+
:page="tileMapPage"
208+
:zoomLevel="project.zoomLevel"
209+
/>
210+
<ProjectInfo
211+
ref="projectInfo"
212+
:first="first"
213+
:informationPages="createInformationPages(props.tutorial, props.project, createFallbackInformationPages)"
214+
:manualUrl="project?.manualUrl"
215+
@toggle-dialog="arrowKeys = !arrowKeys"
216+
>
217+
<template #instructions>
218+
<LocateFeaturesProjectInstructions
219+
:instruction="instruction"
220+
:options="props.options"
221+
:exampleTileUrl="processedTasks[0].url"
222+
/>
223+
</template>
224+
<template #tutorial>
225+
<LocateFeaturesProjectTutorial
226+
:tutorial="tutorial"
227+
:tasks="tutorialTasks"
228+
:options="options"
229+
@tutorialComplete="projectInfoRef?.toggleDialog"
230+
/>
231+
</template>
232+
</ProjectInfo>
233+
</ProjectHeader>
234+
<v-container
235+
class="ma-0 pa-0 container"
236+
ref="taskContainer"
237+
v-resize="handleTaskContainerResize"
238+
>
239+
<LocateFeaturesProjectTask
240+
:v-if="currentTask"
241+
:task="currentTask"
242+
:subGridSizeExponent="subGridSizeExponent"
243+
:value="currentTaskValue"
244+
@onValueChange="handleTaskValueChange"
245+
:optionMapping="optionMapping"
246+
:nextOptionMapping="nextOptionMapping"
247+
:tileSize="tileSize"
248+
:selectedIndices="selectedTaskIndices[currentTask.taskId]"
249+
@onSelectionChange="handleTaskSelectionChange"
250+
/>
251+
</v-container>
252+
<v-toolbar color="white" density="compact" extension-height="20" extended>
253+
<v-spacer />
254+
<v-btn
255+
:title="$t('findProject.moveLeft')"
256+
icon="mdi-chevron-left"
257+
color="secondary"
258+
:disabled="taskOffset <= 0"
259+
@click="handleBack"
260+
v-shortkey.once="[arrowKeys ? 'arrowleft' : '']"
261+
@shortkey="handleBack"
262+
/>
263+
<v-btn
264+
v-if="isDefined(startTime)"
265+
:title="$t('projectView.saveResults')"
266+
icon="mdi-content-save"
267+
color="primary"
268+
:disabled="!isLastTask"
269+
@click="saveResults?.(results, startTime)"
270+
/>
271+
<v-btn
272+
:title="$t('findProject.moveRight')"
273+
icon="mdi-chevron-right"
274+
color="secondary"
275+
:disabled="isLastTask"
276+
@click="handleForward"
277+
v-shortkey.once="[arrowKeys ? 'arrowright' : '']"
278+
@shortkey="handleForward"
279+
/>
280+
<v-spacer />
281+
<template #extension>
282+
<TaskProgress :progress="taskOffset + 1" :total="processedTasks.length" />
283+
</template>
284+
</v-toolbar>
285+
</template>
286+
287+
<style scoped>
288+
.container {
289+
height: calc(100vh - 20rem);
290+
display: flex;
291+
align-items: center;
292+
justify-content: center;
293+
}
294+
</style>
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
<script lang="ts" setup>
2+
import type { CustomOption } from '@/utils/types'
3+
import TileOverlay from './TileOverlay.vue';
4+
import ImageTile from './ImageTile.vue';
5+
6+
interface Props {
7+
instruction: string;
8+
options: CustomOption[];
9+
exampleTileUrl?: string;
10+
}
11+
12+
const props = defineProps<Props>()
13+
14+
</script>
15+
16+
<template>
17+
<v-card-text>
18+
<div class="text-h6">
19+
{{ $t('findProjectInstructions.classifyTitle') }}
20+
</div>
21+
<div class="text-p">
22+
{{ props.instruction }} {{ $t('findProjectInstructions.classifyInstruction') }}.
23+
</div>
24+
<v-row class="mt-2" dense>
25+
<v-col sm="auto" lg="auto" v-for="(option, index) in options" :key="index">
26+
<div class="example-tile">
27+
<tile-overlay
28+
class="tile-overlay"
29+
persistentLabel
30+
:color="option.iconColor"
31+
:label="option.title"
32+
/>
33+
<image-tile
34+
:url="exampleTileUrl"
35+
/>
36+
</div>
37+
</v-col>
38+
</v-row>
39+
<div class="text-h6 mt-10">{{ $t('projectInstructions.useButtonsToNavigate') }}</div>
40+
<div class="text-p mt-2">
41+
<v-row class="align-center" dense>
42+
<v-col cols="auto" class="mr-4">
43+
<v-btn icon="mdi-chevron-left" color="secondary" class="mr-2" variant="text" />
44+
<v-btn icon="mdi-chevron-right" color="secondary" variant="text" />
45+
</v-col>
46+
<v-col>{{ $t('projectInstructions.move') }}</v-col>
47+
</v-row>
48+
</div>
49+
50+
<div class="text-h6 mt-10">{{ $t('projectInstructions.saveYourAnswers') }}</div>
51+
<div class="text-p mt-2">
52+
<v-row class="align-center" dense>
53+
<v-col cols="auto" class="mr-4">
54+
<v-btn icon="mdi-content-save" color="primary" variant="text" />
55+
</v-col>
56+
<v-col>{{ $t('validateProjectInstructions.seenAll') }}</v-col>
57+
</v-row>
58+
</div>
59+
<div class="text-h6 mt-10">{{ $t('projectInstructions.dontWorry') }}</div>
60+
<div class="text-p">{{ $t('projectInstructions.everyTaskIsViewedBy') }}.</div>
61+
</v-card-text>
62+
</template>
63+
64+
<style scoped>
65+
.example-tile {
66+
width: 7rem;
67+
height: 7rem;
68+
position: relative;
69+
isolation: isolate;
70+
71+
.tile-overlay {
72+
position: absolute;
73+
z-index: 1;
74+
width: 100%;
75+
height: 100%;
76+
}
77+
}
78+
</style>

0 commit comments

Comments
 (0)