Skip to content

Commit

Permalink
feat: add sorting by metric to table view (#93)
Browse files Browse the repository at this point in the history
* fix: use api for latest runs instead of local filtering

* refactor: use explicit types

* docs: add comment explaining the workaround

* wip

* wip

* wip

* feat: sort table by metrics while keeping the grouping as is

* feat: add choice to sort without keeping the grouping
  • Loading branch information
jfrer authored Jul 23, 2024
1 parent 05f6cb0 commit 0fba3fc
Show file tree
Hide file tree
Showing 6 changed files with 209 additions and 32 deletions.
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
}[]
}

0 comments on commit 0fba3fc

Please sign in to comment.