Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add sorting by metric to table view #93

Merged
merged 10 commits into from
Jul 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 60 additions & 28 deletions src/components/workflows/WorkflowsTable.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,48 +2,58 @@
import { watch, ref, onMounted, computed } from "vue"
import { useI18n } from "vue-i18n"
import { createReadableMetricValue, getEvalColor, mapGtId } from "@/helpers/utils"
import type { EvaluationRun } from "@/types"
import type { EvalDefinitions, EvaluationResultsDocumentWide, EvaluationRun, GroupedTableData } from "@/types"
import Dropdown from 'primevue/dropdown'
import Checkbox from 'primevue/checkbox'
import workflowsStore from "@/store/workflows-store"
import api from "@/helpers/api"
import filtersStore from "@/store/filters-store"
import TrendLegend from "@/components/workflows/TrendLegend.vue"
import WorkflowsTableSorter from "@/components/workflows/timeline/WorkflowTableSorter.vue"

const { t } = useI18n()

const groupedData = ref({})
const evals = ref([])
const groupedData = ref<GroupedTableData>({})
const sortedData = ref<GroupedTableData>({})
const evals = ref<string[]>([])
const sortBy = ref<keyof EvaluationResultsDocumentWide | null>(null)

const sortOptions = ref([{
const tableData = computed<GroupedTableData>(() => {
return (sortBy.value === null || Object.keys(sortedData.value).length === 0) ? groupedData.value : sortedData.value
})

const keepGroupsWhenSorting = ref(true)

const groupingOptions = ref([{
value: 'documents',
label: t('documents')
}, {
value: 'workflows',
label: t('workflows')
}])

const sortBy = ref(sortOptions.value[0])
const groupBy = ref(groupingOptions.value[0])
const latestRuns = ref<EvaluationRun[]>([])
const filteredRuns = ref<EvaluationRun[]>([])
const evalDefinitions = ref([])
const evalDefinitions = ref<EvalDefinitions>({})
const loading = ref(false)

onMounted(async () => {
loading.value = true
latestRuns.value = workflowsStore.getLatestRuns()
evalDefinitions.value = await api.getEvalDefinitions()
setFilteredRuns()
groupRuns(sortBy.value.value)
groupRuns(groupBy.value.value)
loading.value = false
})

watch(() => filtersStore.gt, () => {
setFilteredRuns()
groupRuns(sortBy.value.value)
groupRuns(groupBy.value.value)
})

watch(sortBy, () => {
groupRuns(sortBy.value.value)
watch(groupBy, () => {
groupRuns(groupBy.value.value)
})

function setFilteredRuns() {
Expand All @@ -53,6 +63,7 @@ function setFilteredRuns() {
function groupRuns(groupBy: string) {
if (groupBy === 'workflows') groupByWorkflows()
else if (groupBy === 'documents') groupByDocuments()
sortBy.value = null
}

const groupByWorkflows = () => {
Expand All @@ -66,7 +77,7 @@ const groupByWorkflows = () => {
label: workflowsStore.getGtById(mapGtId(cur.metadata.gt_workspace.id))?.label,
evaluations: Object.keys(cur.evaluation_results.document_wide).map(key => ({
name: key,
value: cur.evaluation_results.document_wide[key]
value: cur.evaluation_results.document_wide[key as keyof EvaluationResultsDocumentWide]
}))
}
if (!acc[ocrWorkflowId]) {
Expand All @@ -77,12 +88,12 @@ const groupByWorkflows = () => {
} else {
acc[ocrWorkflowId].subjects.push(subject)
acc[ocrWorkflowId].subjects.sort((a, b) => {
if (a.label > b.label) return 1
if ((a.label && b.label) && a.label > b.label) return 1
else return -1
})
}
return acc
}, {})
}, {} as GroupedTableData)
}

const groupByDocuments = () => {
Expand All @@ -94,7 +105,7 @@ const groupByDocuments = () => {
label: workflowsStore.getWorkflowById(mapGtId(cur.metadata.ocr_workflow['id']))?.label,
evaluations: Object.keys(cur.evaluation_results.document_wide).map(key => ({
name: key,
value: cur.evaluation_results.document_wide[key]
value: cur.evaluation_results.document_wide[key as keyof EvaluationResultsDocumentWide]
}))
}
if (!acc[gtWorkspaceId]) {
Expand All @@ -105,12 +116,21 @@ const groupByDocuments = () => {
} else {
acc[gtWorkspaceId].subjects.push(subject)
acc[gtWorkspaceId].subjects.sort((a, b) => {
if (a.label > b.label) return 1
if ((a.label && b.label) && a.label > b.label) return 1
else return -1
})
}
return acc
}, {})
}, {} as GroupedTableData)
}

const getSortValue = (key: keyof EvaluationResultsDocumentWide) => {
return sortBy.value === key
}

const setSorted = (event: GroupedTableData, key: keyof EvaluationResultsDocumentWide) => {
sortBy.value = key
sortedData.value = event
}
</script>

Expand All @@ -119,21 +139,33 @@ const groupByDocuments = () => {
Loading...
</template>
<template v-else>
<div class="flex flex-col" v-if="Object.keys(groupedData).length > 0">
<div class="flex flex-col" v-if="Object.keys(tableData).length > 0">
<div class="flex items-center mb-4 ml-auto">
<label for="keepGroupsCheckbox" class="mr-2">{{ $t('keep_grouping_when_sorting') }}</label>
<Checkbox v-model="keepGroupsWhenSorting" input-id="keepGroupsCheckbox" binary class="mr-8"></Checkbox>

<p class="mr-2">{{ $t('group_by') }}:</p>
<Dropdown v-model="sortBy" :options="sortOptions" optionLabel="label" placeholder="Choose something.." class="" />
<Dropdown v-model="groupBy" :options="groupingOptions" optionLabel="label" placeholder="Choose something.." class="" />
</div>
<TrendLegend :show-text-colors="false" class="ml-auto mb-4"/>
</div>
<table v-if="Object.keys(groupedData).length > 0" class="w-full border border-collapse rounded text-sm">
<table v-if="Object.keys(tableData).length > 0" class="w-full border border-collapse rounded text-sm">
<thead>
<tr>
<th class="p-2 border">{{ sortBy.value === 'documents' ? $t('documents') : $t('workflows') }}</th>
<th class="p-2 border">{{ sortBy.value === 'documents' ? $t('workflows') : $t('documents') }}</th>
<th class="p-2 border">{{ groupBy.value === 'documents' ? $t('documents') : $t('workflows') }}</th>
<th class="p-2 border">{{ groupBy.value === 'documents' ? $t('workflows') : $t('documents') }}</th>
<th v-for="(evalKey, i) in evals" :key="i" class="p-2 border">
<span class="def-label flex items-center justify-center cursor-pointer">
{{ evalDefinitions[evalKey] ? evalDefinitions[evalKey].label : evalKey }}
<WorkflowsTableSorter
:grouped-data="groupedData"
:metric="(evalKey as keyof EvaluationResultsDocumentWide)"
:sort="getSortValue(evalKey as keyof EvaluationResultsDocumentWide)"
:keep-grouping="keepGroupsWhenSorting"
@sorted-data="setSorted($event, evalKey as keyof EvaluationResultsDocumentWide)"
@unsorted-data="sortBy = null"
>
{{ evalDefinitions[evalKey] ? evalDefinitions[evalKey].label : evalKey }}
</WorkflowsTableSorter>
<i-icon name="ink-info"/>
<div class="def-tooltip">
<div class="flex p-2 bg-white border rounded">
Expand All @@ -146,22 +178,22 @@ const groupByDocuments = () => {
</tr>
</thead>
<tbody>
<template v-for="(key, i) in Object.keys(groupedData)" :key="i">
<tr v-for="(subject, j) in groupedData[key].subjects" :key="j">
<td v-if="j === 0" :rowspan="groupedData[key].subjects.length" class="align-top pl-2 border w-1/3">
<span class="font-bold">{{ groupedData[key].label }}</span>
<template v-for="(key, i) in Object.keys(tableData)" :key="i">
<tr v-for="(subject, j) in tableData[key].subjects" :key="j">
<td v-if="j === 0" :rowspan="tableData[key].subjects.length" class="align-top pl-2 border w-1/3">
<span class="font-bold">{{ tableData[key].label }}</span>
</td>
<td class="align-top pl-2 border">{{ subject.label }}</td>
<td
v-for="({ name, value }, k) in subject.evaluations"
:key="k"
class="text-center pt-1 border"
:class="(j === groupedData[key].subjects.length - 1) ? 'pb-5' : 'pb-1'"
:class="(j === tableData[key].subjects.length - 1) ? 'pb-5' : 'pb-1'"
>
<span
class="metric inline-block cursor-pointer text-sm leading-none p-1 rounded-lg min-w-[48px]"
:class="getEvalColor(name, value)">
{{ createReadableMetricValue(name, value) }}
{{ createReadableMetricValue(name as keyof EvaluationResultsDocumentWide, value) }}
</span>
</td>
</tr>
Expand Down
120 changes: 120 additions & 0 deletions src/components/workflows/timeline/WorkflowTableSorter.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
<script setup lang="ts">
import Button from "primevue/button"
import { Icon } from "@iconify/vue"
import { ref, watch } from "vue"
import type { GroupedTableData, EvaluationResultsDocumentWide, GroupedTableDataSubject } from "@/types"
const props = withDefaults(
defineProps<{
groupedData: GroupedTableData
metric: keyof EvaluationResultsDocumentWide
sort?: boolean
keepGrouping?: boolean
}>(),
{
sort: false,
keepGrouping: true
}
)
const emit = defineEmits<{
(event: 'sortedData', payload: GroupedTableData): void,
(event: 'unsortedData'): void
}>()

type GroupedEntry = [string, GroupedTableData[keyof GroupedTableData]]

enum sortStates {
none,
desc,
asc,
__LENGTH
}
const sortState = ref(sortStates.none)

//set sortState to none if sort changes to false to enable sorting by only a single column at a time
watch(() => props.sort, () => {
if(!props.sort) sortState.value = sortStates.none
})

watch(() => props.keepGrouping, () => {
if(sortState.value !== sortStates.none) sort()
})

const cycleAndSort = () => {
cycleSortState()
sort()
}

const cycleSortState = () => {
sortState.value = (sortState.value + 1) % sortStates.__LENGTH
}

const sort = () => {
if (sortState.value === sortStates.none) {
emit('unsortedData')
return
}
const groupedEntries = Object.entries(props.groupedData)
const entries = props.keepGrouping ? groupedEntries : transformToSingleSubjectGrouping(groupedEntries)
const entriesWithSortedSubjects = entries.map(elem => {
const sorted: typeof elem[1] = { label: elem[1].label, subjects: sortSubjects(elem[1].subjects, sortState.value === sortStates.desc) }
return [elem[0], sorted]
})
const sortedData: GroupedTableData = Object.fromEntries(
entriesWithSortedSubjects
.sort((left, right) => {
const leftData = left[1] as GroupedTableData[keyof GroupedTableData]
const rightData = right[1] as GroupedTableData[keyof GroupedTableData]

const compareByFirstSubject = (subjectsLeft: GroupedTableDataSubject[], subjectsRight: GroupedTableDataSubject[]) => {
return compareSubjects(subjectsLeft[0], subjectsRight[0])
}

return sortState.value === sortStates.desc ?
compareByFirstSubject(rightData.subjects, leftData.subjects)
: compareByFirstSubject(leftData.subjects, rightData.subjects)
})
)
emit('sortedData', sortedData)
}

const sortSubjects = (subjects: GroupedTableDataSubject[], desc: boolean) => {
return [...subjects] //copy to avoid sorting in place
.sort((left: GroupedTableDataSubject, right: GroupedTableDataSubject) => {
return desc ? compareSubjects(right, left) : compareSubjects(left, right)
})
}

const compareSubjects = (left: GroupedTableDataSubject, right: GroupedTableDataSubject) => {
const transformValue = (value: number | number [] | null | undefined): number => {
const definedValue = value ?? 0
return Array.isArray(definedValue) ? definedValue[0] : definedValue
}
const evaluationLeft = left.evaluations.find(elem => props.metric === elem.name)
const evaluationRight = right.evaluations.find(elem => props.metric === elem.name)
const valueLeft = transformValue(evaluationLeft?.value)
const valueRight = transformValue(evaluationRight?.value)
return valueLeft - valueRight
}

const transformToSingleSubjectGrouping = (groupedEntries: GroupedEntry[]): GroupedEntry[] => {
return groupedEntries.reduce((acc, curr) => {
curr[1].subjects.forEach((subject, index) => {
acc.push([`${curr[0]}_${index}`, { label: curr[1].label, subjects: [subject] }])
})
return acc
}, [] as GroupedEntry[])
}

</script>
<template>
<Button @click="cycleAndSort()" unstyled>
<div class="flex items-center space-x-2">
<slot/>
<span class="flex h-6 w-6 justify-center items-center">
<Icon v-if="sortState === sortStates.desc" icon="typcn:arrow-sorted-down"/>
<Icon v-else-if="sortState === sortStates.asc" icon="typcn:arrow-sorted-up"/>
<Icon v-else icon="typcn:arrow-unsorted"></Icon>
</span>
</div>
</Button>
</template>
4 changes: 2 additions & 2 deletions src/helpers/api.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { EvaluationRun, GroundTruth, Workflow } from "@/types"
import type { EvalDefinitions, EvaluationRun, GroundTruth, Workflow } from "@/types"

const baseUrlOld = 'https://raw.githubusercontent.com/OCR-D/quiver-back-end/main/data'
const baseUrl = 'https://quiver-dev.sub.uni-goettingen.de/api'
Expand All @@ -10,7 +10,7 @@ async function getOcrdAllReleases() {
return await request(baseUrlOld + '/ocrd_all_releases.json')
}

async function getEvalDefinitions() {
async function getEvalDefinitions(): Promise<EvalDefinitions> {
return await request(baseUrlOld + '/metrics_definitions.json')
}

Expand Down
3 changes: 2 additions & 1 deletion src/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -86,5 +86,6 @@
"filter_by_processor": "Nach Prozessor filtern",
"select_a_date_range": "Zeitraum auswählen",
"select_a_workflow": "Workflow auswählen",
"select_a_processor": "Prozessor auswählen"
"select_a_processor": "Prozessor auswählen",
"keep_grouping_when_sorting": "Gruppierung beim Sortieren beibehalten"
}
3 changes: 2 additions & 1 deletion src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -80,5 +80,6 @@
"filter_by_processor": "Filter by processor",
"select_a_date_range": "Select a date range",
"select_a_workflow": "Select a workflow",
"select_a_processor": "Select a processor"
"select_a_processor": "Select a processor",
"keep_grouping_when_sorting": "Keep grouping when sorting"
}
23 changes: 23 additions & 0 deletions src/types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,3 +141,26 @@ export interface ReleaseInfo {
html_url: string
}

export interface EvalDefinitions {
[id: string]: {
label: string,
short_descr: string,
url: string
}
}

export interface GroupedTableData {
[gtWorkId: string]: {
label: string | undefined,
subjects: GroupedTableDataSubject[]
}
}


export interface GroupedTableDataSubject {
label: string | undefined
evaluations: {
name: string
value: number | number[] | null
}[]
}
Loading