Skip to content
Open
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
195 changes: 195 additions & 0 deletions src/components/characterization-results/RawResultTable.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
<!--
RawResultTable

Renders result rows that the mapper could not classify as prevalence or
distribution — typically the output of a custom-SQL feature analysis that
emits its own columns. Rather than dropping such rows, we show them
verbatim: columns are derived dynamically from whatever keys the rows
carry, so a spec-conformant custom SQL is always at least visible.

This is intentionally a plain, shape-agnostic fallback. Purpose-built
visualisations for categorical / series / cross-tab outputs come later;
the contract this table honours is simply "never make custom output
invisible".
-->
<template>
<AtlasCard
padding="none"
class="raw-result-table"
:data-testid="`char-results-raw-${analysisId}`"
>
<div class="raw-result-table__header">
<div class="raw-result-table__eyebrow-row">
<span class="text-eyebrow">{{ analysisName }}</span>
<span class="raw-result-table__accent-rule" />
</div>
<h3 class="raw-result-table__title">
{{ tv('characterizations.results.table.raw', 'Custom output') }}
<span class="raw-result-table__count">({{ rows.length }})</span>
<span
v-if="hasCustomFe"
class="raw-result-table__badge"
:title="tv('characterizations.results.table.customFeHint',
'Produced by a custom-SQL feature analysis')"
>
{{ tv('characterizations.results.table.customFe', 'Custom SQL') }}
</span>
</h3>
</div>

<AtlasDataTable
:items="tableRows"
:headers="headers"
:items-per-page="25"
:items-per-page-options="[10, 25, 50, 100, -1]"
class="raw-result-table__table"
:data-testid="`char-results-raw-table-${analysisId}`"
/>
</AtlasCard>
</template>

<script setup lang="ts">
import { computed } from 'vue'

import { useI18n } from '@/composables/useI18n'
import { AtlasCard, AtlasDataTable } from '@/components/ui'

interface Props {
analysisId: number
analysisName: string
rows: Record<string, unknown>[]
}

const props = defineProps<Props>()
const { tv } = useI18n()

// Columns that live in the group header or are surfaced as a badge — hidden
// from the table body to avoid noise.
const HIDDEN_KEYS = new Set(['analysisId', 'analysisName', 'faType'])

// Well-known columns get a stable leading order; unknown (custom) columns
// follow, alphabetically, so the interesting bespoke output is easy to scan.
const PREFERRED_ORDER = [
'covariateId',
'covariateName',
'covariateShortName',
'conceptId',
'conceptName',
'domainId',
'resultType',
'cohortId',
'cohortName',
'strataId',
'strataName',
]

const hasCustomFe = computed<boolean>(() =>
props.rows.some(r => r.faType === 'CUSTOM_FE')
)

const columnKeys = computed<string[]>(() => {
const seen = new Set<string>()
for (const row of props.rows) {
for (const key of Object.keys(row)) {
if (!HIDDEN_KEYS.has(key)) seen.add(key)
}
}
const preferred = PREFERRED_ORDER.filter(k => seen.has(k))
const rest = Array.from(seen)
.filter(k => !PREFERRED_ORDER.includes(k))
.sort((a, b) => a.localeCompare(b))
return [...preferred, ...rest]
})

const headers = computed(() =>
columnKeys.value.map(key => ({
title: humanize(key),
key,
align: 'start' as const,
}))
)

const tableRows = computed<Record<string, string | number>[]>(() =>
props.rows.map(row => {
const flat: Record<string, string | number> = {}
for (const key of columnKeys.value) {
flat[key] = formatValue(row[key])
}
return flat
})
)

function humanize(key: string): string {
const spaced = key.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/[_-]+/g, ' ')
return spaced.charAt(0).toUpperCase() + spaced.slice(1)
}

function formatValue(value: unknown): string | number {
if (value === null || value === undefined) return '—'
if (typeof value === 'number') {
return Number.isInteger(value) && Math.abs(value) >= 1000
? value.toLocaleString()
: value
}
if (typeof value === 'string' || typeof value === 'boolean') return String(value)
try {
return JSON.stringify(value)
} catch {
return String(value)
}
}
</script>

<style scoped>
.raw-result-table {
margin-bottom: 16px;
}

.raw-result-table__header {
padding: 20px 20px 12px;
}

.raw-result-table__eyebrow-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 6px;
}

.raw-result-table__accent-rule {
display: inline-block;
width: 28px;
height: 2px;
background-color: rgb(var(--v-theme-orange));
border-radius: 2px;
}

.raw-result-table__title {
display: flex;
align-items: baseline;
gap: 8px;
font-size: 18px;
font-weight: 500;
line-height: 1.3;
margin: 0;
color: rgb(var(--v-theme-primary));
}

.raw-result-table__count {
font-size: 0.85rem;
color: rgba(var(--v-theme-on-surface), 0.6);
font-weight: 400;
}

.raw-result-table__badge {
align-self: center;
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.02em;
text-transform: uppercase;
padding: 2px 8px;
border-radius: 10px;
color: rgb(var(--v-theme-orange));
background: rgba(var(--v-theme-orange), 0.12);
}
</style>
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,16 @@
:rows="g.rows"
:cohorts="g.cohorts"
/>
<RawResultTable
v-for="g in unmappedGroups"
:key="`raw-${g.analysisId}`"
:analysis-id="g.analysisId"
:analysis-name="g.analysisName"
:rows="g.rows"
/>
<div
v-if="prevalenceGroups.length === 0 && distributionGroups.length === 0"
v-if="prevalenceGroups.length === 0 && distributionGroups.length === 0
&& unmappedGroups.length === 0"
class="char-per-analysis__empty"
>
{{ tv('common.noData', 'No rows match the current filter.') }}
Expand All @@ -31,6 +39,7 @@ import { computed } from 'vue'
import { useI18n } from '@/composables/useI18n'
import PrevalenceTable from '@/components/characterization-results/PrevalenceTable.vue'
import DistributionTable from '@/components/characterization-results/DistributionTable.vue'
import RawResultTable from '@/components/characterization-results/RawResultTable.vue'
import { DEFAULT_STRATA_KEY } from '@/utils/characterization-result-mapper'
import type {
DistributionStat, LinkedCohort, PrevalenceStat,
Expand All @@ -39,6 +48,7 @@ import type {
const props = defineProps<{
prevalence: PrevalenceStat[]
distribution: DistributionStat[]
unmapped?: Record<string, unknown>[]
cohorts: LinkedCohort[]
threshold: number
selectedAnalysisIds: number[]
Expand Down Expand Up @@ -108,6 +118,28 @@ const distributionGroups = computed<Group<DistributionStat>[]>(() => {
}
return Array.from(groups.values())
})

interface RawGroup { analysisId: number; analysisName: string; rows: Record<string, unknown>[] }

const unmappedGroups = computed<RawGroup[]>(() => {
const groups = new Map<number, RawGroup>()
for (const row of props.unmapped ?? []) {
const analysisId = typeof row.analysisId === 'number' ? row.analysisId : -1
if (!passesAnalysis(analysisId)) continue
const domainId = typeof row.domainId === 'string' ? row.domainId : undefined
if (!passesDomain(domainId)) continue
let g = groups.get(analysisId)
if (!g) {
const analysisName = typeof row.analysisName === 'string' && row.analysisName
? row.analysisName
: `Analysis ${analysisId}`
g = { analysisId, analysisName, rows: [] }
groups.set(analysisId, g)
}
g.rows.push(row)
}
return Array.from(groups.values())
})
</script>

<style scoped>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@
v-else
:prevalence="prevalence"
:distribution="distribution"
:unmapped="unmapped"
:cohorts="cohorts"
:threshold="filters.threshold"
:selected-analysis-ids="filters.selectedAnalysisIds"
Expand Down Expand Up @@ -195,7 +196,7 @@ const { tv } = useI18n()
const store = useCharacterizationStore()
const sourcesStore = useDataSourcesStore()
const cohortSizes = ref<Record<string, number>>({})
const { execution, prevalence, distribution, resultCount, error, load, reset } = useCharacterizationResults()
const { execution, prevalence, distribution, unmapped, resultCount, error, load, reset } = useCharacterizationResults()

const railOpen = ref(!route.params.id)
const viewMode = ref<ViewMode>('perAnalysis')
Expand Down
6 changes: 5 additions & 1 deletion src/composables/useCharacterizationResults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export function useCharacterizationResults() {
const resultCount = ref<number>(0)
const prevalence = ref<PrevalenceStat[]>([])
const distribution = ref<DistributionStat[]>([])
const unmapped = ref<Record<string, unknown>[]>([])
const loading = ref<boolean>(false)
const error = ref<string | null>(null)

Expand All @@ -25,6 +26,7 @@ export function useCharacterizationResults() {
resultCount.value = 0
prevalence.value = []
distribution.value = []
unmapped.value = []
error.value = null
loading.value = true
try {
Expand All @@ -38,6 +40,7 @@ export function useCharacterizationResults() {
resultCount.value = count
prevalence.value = mapped.prevalence
distribution.value = mapped.distribution
unmapped.value = mapped.unmapped
return true
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to load results'
Expand All @@ -53,9 +56,10 @@ export function useCharacterizationResults() {
resultCount.value = 0
prevalence.value = []
distribution.value = []
unmapped.value = []
error.value = null
loading.value = false
}

return { execution, resultCount, prevalence, distribution, loading, error, load, reset }
return { execution, resultCount, prevalence, distribution, unmapped, loading, error, load, reset }
}
24 changes: 21 additions & 3 deletions src/utils/characterization-result-mapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,14 @@ export type CharacterizationStatType = 'PREVALENCE' | 'DISTRIBUTION'
export interface MappedCharacterizationResults {
prevalence: PrevalenceStat[]
distribution: DistributionStat[]
/**
* Rows the classifier could not fit into the prevalence / distribution
* shapes (e.g. custom-SQL feature analyses that emit their own columns).
* Instead of silently dropping them, we surface the raw records so the
* viewer can render them verbatim. Kept as loose records because the
* column set is, by definition, unknown here.
*/
unmapped: Record<string, unknown>[]
}

/** Internal: a single raw row before classification. */
Expand Down Expand Up @@ -272,24 +280,27 @@ export function computeBinaryStdDiff(
export function mapCharacterizationResults(raw: unknown[]): MappedCharacterizationResults {
const prevalenceMap = new Map<string, PrevalenceStat>()
const distributionMap = new Map<string, DistributionStat>()
const unmapped: Record<string, unknown>[] = []
let skipped = 0

for (const value of raw) {
const rows = toRawRows(value)
if (rows.length === 0) {
// Malformed row (missing analysisId/covariateId) — not a spec-conformant
// result row, so nothing meaningful to surface. Dropped as before.
skipped++
continue
}
for (const row of rows) {
const type = classifyRow(row)
if (type === null) {
skipped++
unmapped.push(row as Record<string, unknown>)
continue
}
const groupKey = `${row.analysisId}::${row.covariateId}`
const cKey = cohortKey(row)
if (!cKey) {
skipped++
unmapped.push(row as Record<string, unknown>)
continue
}
const sKey = strataKey(row)
Expand Down Expand Up @@ -353,11 +364,18 @@ export function mapCharacterizationResults(raw: unknown[]): MappedCharacterizati
}

if (skipped > 0) {
logger.debug('CharacterizationResultMapper', `Skipped ${skipped} unclassifiable result row(s)`)
logger.debug('CharacterizationResultMapper', `Skipped ${skipped} non-record result row(s)`)
}
if (unmapped.length > 0) {
logger.debug(
'CharacterizationResultMapper',
`Surfacing ${unmapped.length} unmapped result row(s) as raw output`
)
}

return {
prevalence: Array.from(prevalenceMap.values()),
distribution: Array.from(distributionMap.values()),
unmapped,
}
}
Loading
Loading