ファイル
btrc-hub/frontend/src/components/counts/CountsSummaryResults.tsx
T
2026-10-28 21:54:35 +09:00

312 行
16 KiB
TypeScript

import SortableColumns from '@/components/common/SortableColumns'
import { countsSummaryValue } from '@/lib/countsDisplay'
import PrefetchLink from '@/components/PrefetchLink'
import CountsRowNumber from '@/components/counts/CountsRowNumber'
import CountsSelectableTable from '@/components/counts/CountsSelectableTable'
import CountsSortHeader from '@/components/counts/CountsSortHeader'
import { changeCountSort, countsRequestParams, readCountSort, writeCountSort } from '@/lib/counts'
import { countsAxisLabel, useCountsDefinition }
from '@/lib/countsDefinitions'
import { cn } from '@/lib/utils'
import { useLocale } from '@/lib/useLocale'
import { useUITranslation } from '@/lib/useUITranslation'
import { queryRecordParams, setQueryRecords, readCountState, encodeCountState, setCountState } from '@/lib/queryState'
import type { CountsPivotHeader, CountsResource, CountsResult } from '@/lib/counts'
import type { CountsAxis } from '@/lib/countsDefinitions'
const CountsSummaryTable = (
{ resource, data, params, pending, update, dragIds = {} }: {
dragIds?: Record<string, string>
resource: CountsResource; data: CountsResult; params: URLSearchParams
pending: boolean; update: (key: string, value: string, extra?: Record<string, string>) => void },
) => {
const definition = useCountsDefinition ()
const locale = useLocale ()
const { message } = useUITranslation ('counts')
const numbered = params.get ('row_numbers') === '1'
const rows = data.rows ?? []
const cols = data.cols ?? []
const cells = data.cells ?? []
const rowHeaders: CountsPivotHeader[] = data.rowHeaders ?? [...new Set (
cells.map (cell => JSON.stringify (cell.values.slice (0, rows.length))))]
.map (key => ({ values: JSON.parse (key), kind: 'data' }))
const columnHeaders: CountsPivotHeader[] = data.columnHeaders
?? (data.columnValues ?? [[]]).map (values => ({ values, kind: 'data' }))
const sorts = readCountSort (params.get ('sort'))
const change = (key: string, additive: boolean) => update (
'sort', writeCountSort (changeCountSort (sorts, key, additive)))
const display = (value: string | number | null, axis: CountsAxis, index: number) =>
countsSummaryValue (value, axis, index, data, locale)
const aggregateLabel = (header: CountsPivotHeader) => message (
header.labelKey ?? (header.kind === 'grand_total' ? (rows.length + cols.length === 1
? 'singleTotal' : 'grandTotal') : 'subtotal')).text
const leading = Math.max (rows.length, cols.length > 0 ? 1 : 0)
const levels = Math.max (1, cols.length)
const headingLevels = cols.length === 0 ? 1 : levels * 2
const names = (header: CountsPivotHeader, axes: CountsAxis[], offset: number) =>
header.values.map ((value, index) => display (value, axes[index], offset + index))
const columnKey = (header: CountsPivotHeader) => `${header.kind}:${JSON.stringify (header.values)}`
const measures = readCountState<Record<string, string | number | null>[]> (
params, 'measures', [])
const measureKey = (header: CountsPivotHeader) => {
if (header.values.length === 0) return 'count'
const ids = [...new Set (measures.map (entry => String (entry.key)))]
const existing = ids.find (id => JSON.stringify (measures.filter (entry => entry.key === id)
.sort ((a, b) => Number (a.axis) - Number (b.axis)).map (entry => entry.value))
=== JSON.stringify (header.values))
return existing ?? `m${Array.from ({ length: 100 }, (_, index) => index)
.find (index => !ids.includes (`m${index}`)) ?? 99}`
}
const changeMeasure = (header: CountsPivotHeader, additive: boolean) => {
const key = measureKey (header)
const next = new URLSearchParams (params)
const nextSort = changeCountSort (sorts, key, additive)
const active = new Set (nextSort.map (sort => sort.key))
const records = measures.filter (record => record.key !== key && active.has (String (record.key)))
setQueryRecords (next, 'measures', [...records, ...(active.has (key)
? header.values.map ((value, axis) => ({ key, axis, value })) : [])])
update ('sort', writeCountSort (nextSort),
queryRecordParams (next, 'measures'))
}
const keys = [...(numbered ? ['row_number'] : []),
...Array.from ({ length: leading }, (_, index) => `a${index}`),
...columnHeaders.map (columnKey)]
const full = new Map (cells.map (cell => [JSON.stringify (cell.values), cell.count]))
const totals = new Map ((data.pivotTotals ?? []).map (cell => [
JSON.stringify ([cell.row, cell.column]), cell.count]))
const totalLabels = new Map ((data.pivotTotals ?? []).map (cell => [
JSON.stringify ([cell.row, cell.column]), cell.labelKey]))
const count = (row: CountsPivotHeader, column: CountsPivotHeader) =>
(row.values.length === rows.length && column.values.length === cols.length)
? full.get (JSON.stringify ([...row.values, ...column.values])) ?? 0
: totals.get (JSON.stringify ([row.values, column.values])) ?? 0
const drill = (row: CountsPivotHeader, column: CountsPivotHeader) => {
const next = new URLSearchParams (params)
const existing = readCountState<Record<string, string | number | null>[]> (params, 'drill', [])
setCountState (next, 'drill', encodeCountState ('drill', [...existing,
...row.values.map ((value, index) => ({ ...rows[index], value })),
...column.values.map ((value, index) => ({ ...cols[index], value }))]))
next.set ('mode', 'details')
next.delete ('sort')
next.delete ('page')
return `/counts/${resource}?${next}`
}
let ordinal = (data.page - 1) * data.limit
const rowLabels = rowHeaders.map (header => {
const labels = names (header, rows, 0)
// With no row axes the sole result row is not an additional rollup row.
const number = rows.length === 0 ? '1' : (header.kind === 'data' ? String (++ordinal) : '')
if (header.kind !== 'data') labels.push (aggregateLabel (header))
return [...(numbered ? [number] : []),
...Array.from ({ length: leading }, (_, index) => labels[index] ?? '')]
})
const spans = rowHeaders.map ((row, index) => rows.map ((_, axis) => {
if (row.values.length <= axis) return 1
const prefix = JSON.stringify (row.values.slice (0, axis + 1))
const previous = rowHeaders[index - 1]
if (previous != null && previous.values.length > axis
&& JSON.stringify (previous.values.slice (0, axis + 1)) === prefix) return 0
let span = 1
while (index + span < rowHeaders.length)
{
const next = rowHeaders[index + span]
if (next.values.length <= axis
|| JSON.stringify (next.values.slice (0, axis + 1)) !== prefix) break
++span
}
return span
}))
rowLabels.forEach ((labels, row) => spans[row].forEach ((span, column) => {
if (span === 0) labels[column + (numbered ? 1 : 0)] = ''
}))
// This unmerged matrix is also the single source for TSV and HTML clipboard headings.
const valueHeadingRows = Array.from ({ length: levels }, (_, level) => [
...(numbered ? [level === 0 ? message ('rowNumberHeading').text : ''] : []),
...Array.from ({ length: leading }, (_, index) =>
level === 0 && rows[index] != null
? countsAxisLabel (rows[index], definition, locale).text : ''),
...columnHeaders.map ((header, index) => {
if (cols.length === 0) return message ('count').text
if (level > header.values.length) return ''
if (level === header.values.length)
return header.kind === 'data' ? '' : aggregateLabel (header)
const prefix = header.values.slice (0, level + 1)
const previous = columnHeaders[index - 1]
if (level < levels - 1 && previous != null
&& JSON.stringify (previous.values.slice (0, level + 1)) === JSON.stringify (prefix))
return ''
return display (header.values[level], cols[level], rows.length + level)
})])
const axisHeadingRows = cols.map ((axis, level) => [
...(numbered ? [''] : []), ...Array.from ({ length: leading }, () => ''),
...columnHeaders.map ((header, index) => {
if (header.values.length < level) return ''
const previous = columnHeaders[index - 1]
const prefix = JSON.stringify (header.values.slice (0, level))
return (previous != null && previous.values.length >= level
&& JSON.stringify (previous.values.slice (0, level)) === prefix)
? '' : countsAxisLabel (axis, definition, locale).text
})])
const headingRows = cols.length === 0 ? valueHeadingRows
: axisHeadingRows.flatMap ((headings, level) => [headings, valueHeadingRows[level]])
if (cols.length > 0)
{
for (let index = 0; index < leading + (numbered ? 1 : 0); ++index)
{
headingRows[0][index] = valueHeadingRows[0][index]
headingRows[1][index] = ''
}
}
const axisHeaderCells = (level: number) => {
const output = []
for (let index = 0; index < columnHeaders.length; ++index)
{
const header = columnHeaders[index]
if (header.values.length < level) continue
const prefix = JSON.stringify (header.values.slice (0, level))
let span = 1
while (index + span < columnHeaders.length)
{
const sibling = columnHeaders[index + span]
if (sibling.values.length < level
|| JSON.stringify (sibling.values.slice (0, level)) !== prefix) break
++span
}
const name = countsAxisLabel (cols[level], definition, locale)
output.push (<th key={index} colSpan={span} scope="colgroup"
lang={name.language} className="px-2 py-1">{name.text}</th>)
index += span - 1
}
return output
}
const matrix = rowHeaders.map ((header, index) => [...rowLabels[index],
...columnHeaders.map (column => String (count (header, column)))])
const headerCells = (level: number) => {
const output = []
for (let index = 0; index < columnHeaders.length; ++index)
{
const header = columnHeaders[index]
if (cols.length > 0 && level > header.values.length) continue
const aggregate = header.kind !== 'data' && level === header.values.length
const leaf = level === levels - 1 || aggregate
const text =
cols.length === 0
? message ('count').text
: (aggregate ? aggregateLabel (header)
: display (header.values[level], cols[level], rows.length + level))
let span = 1
if (!leaf)
{
const prefix = JSON.stringify (header.values.slice (0, level + 1))
while (index + span < columnHeaders.length)
{
const sibling = columnHeaders[index + span]
if (sibling.values.length <= level
|| JSON.stringify (sibling.values.slice (0, level + 1)) !== prefix) break
++span
}
}
const sortKey = leaf ? measureKey (header) : `a${rows.length + level}`
const field = leaf ? columnKey (header) : sortKey
output.push (<CountsSortHeader key={`${level}:${field}:${index}`} field={field}
colSpan={span} resizable={leaf}
rowSpan={(aggregate && cols.length > 0) ? (levels - level) * 2 - 1 : 1}
totalKind={header.kind !== 'data' ? header.kind : undefined}
dragId={level === 0 && header.values.length > 0
? dragIds[JSON.stringify (header.values.slice (0, 1))] : undefined}
label={{ text, language: message ('count').language }}
sorts={sorts.map (sort => sort.key === sortKey ? { ...sort, key: field } : sort)}
change={(_, additive) => leaf ? changeMeasure (header, additive)
: change (sortKey, additive)}/>)
index += span - 1
}
return output
}
return <div className="min-h-0 flex-1 overflow-auto" data-counts-results aria-busy={pending}>
<CountsSelectableTable family={`pivot_${resource}`} columns={keys}
stretched={keys.filter (key => key !== 'row_number').length > 1}
copyTable={() => ({ columns: keys.map ((_, index) => ({ label: headingRows[0][index] })),
headerRows: headingRows, rows: matrix })}
identity={`${countsRequestParams (params)}:${JSON.stringify (keys)}`}
className="counts-summary text-left text-sm">
<thead className="sticky top-0 z-20">
{headingRows.map ((_, tier) => <tr key={tier}>
{tier === 0 && <>
{numbered && <CountsRowNumber rowSpan={headingLevels}/>}
{Array.from ({ length: leading }, (_, index) => rows[index] == null
? <th key={index} rowSpan={headingLevels}/>
: <CountsSortHeader key={rows[index].field} field={`a${index}`}
rowSpan={headingLevels}
label={countsAxisLabel (rows[index], definition, locale)}
sorts={sorts} change={change}/>)}
</>}
{(cols.length > 0 && tier % 2 === 0)
? axisHeaderCells (tier / 2) : headerCells (Math.floor (tier / 2))}
</tr>)}
</thead>
<tbody>{rowHeaders.map ((row, index) => <tr key={columnKey (row)}>
{rowLabels[index].map ((value, offset) => {
const axis = offset - (numbered ? 1 : 0)
if (axis >= 0 && row.kind !== 'data' && axis > row.values.length) return null
const aggregate = axis >= 0 && row.kind !== 'data' && axis === row.values.length
const span = axis < 0 ? 1 : spans[index][axis] ?? 1
return span === 0 ? null : <td key={offset} rowSpan={span}
colSpan={aggregate ? leading - axis : 1}
role={axis >= 0 ? 'rowheader' : undefined} data-counts-copy={value}
data-counts-aggregate={row.kind !== 'data' ? '' : undefined}
data-counts-total={row.kind !== 'data' ? row.kind : undefined}
data-counts-row-total={row.kind !== 'data' ? row.kind : undefined}
className={cn ('overflow-hidden break-words border px-2 py-1 dark:border-slate-700',
numbered && offset === 0 && 'text-right tabular-nums')}>{value}</td>
})}
{columnHeaders.map (column => <td key={columnKey (column)}
data-counts-copy={String (count (row, column))}
data-counts-row-total={row.kind !== 'data' ? row.kind : undefined}
data-counts-total={(row.kind === 'grand_total' || column.kind === 'grand_total')
? 'grand_total' : ((row.kind === 'subtotal' || column.kind === 'subtotal')
? 'subtotal' : undefined)}
data-counts-aggregate={(row.kind !== 'data' || column.kind !== 'data')
? '' : undefined}
className="border px-2 py-1 dark:border-slate-700">
<PrefetchLink to={drill (row, column)} aria-label={
totalLabels.get (JSON.stringify ([row.values, column.values])) == null ? undefined
: `${message (totalLabels.get (
JSON.stringify ([row.values, column.values]))!).text}: ${count (row, column)}`}>
{count (row, column)}</PrefetchLink>
</td>)}
</tr>)}</tbody>
</CountsSelectableTable>
</div>
}
const CountsSummaryResults = (props: Parameters<typeof CountsSummaryTable>[0]) => {
const locale = useLocale ()
const { message } = useUITranslation ('counts')
const headers = props.data.columnHeaders ?? []
const columns = props.data.cols ?? []
if (columns.length === 0) return <CountsSummaryTable {...props}/>
const groups = [...new Set (headers.filter (header => header.values.length > 0)
.map (header => JSON.stringify (header.values.slice (0, 1))))]
const ids = groups.map ((_, index) => `group${index}`)
const dragIds = Object.fromEntries (groups.map ((group, index) => [group, ids[index]]))
return <SortableColumns ids={ids} name={id => ({
text: countsSummaryValue (JSON.parse (groups[ids.indexOf (id)])[0], columns[0],
props.data.rows?.length ?? 0, props.data, locale), language: message ('count').language })}
onChange={order => props.update ('column_order', encodeCountState ('column_order',
order.map (id => ({ value: JSON.parse (groups[ids.indexOf (id)])[0] }))))}>
{order => {
const position = (header: CountsPivotHeader) => header.values.length === 0 ? order.length
: order.indexOf (dragIds[JSON.stringify (header.values.slice (0, 1))])
const sorted = [...headers].sort ((a, b) => position (a) - position (b))
return <CountsSummaryTable {...props} data={{ ...props.data, columnHeaders: sorted }}
dragIds={dragIds}/>
}}
</SortableColumns>
}
export default CountsSummaryResults