このコミットが含まれているのは:
2026-10-26 21:53:09 +09:00
コミット 1154babe45
22個のファイルの変更、537行の追加、145行の削除
+20 -7
ファイルの表示
@@ -1,4 +1,4 @@
import { useEffect, useId, useState } from 'react'
import { useEffect, useId, useRef, useState } from 'react'
import CountsAdditionalConditions from '@/components/counts/CountsAdditionalConditions'
import CountsAxes from '@/components/counts/CountsAxes'
@@ -9,10 +9,10 @@ import FormField from '@/components/common/FormField'
import HelpPopover from '@/components/common/HelpPopover'
import TagInput from '@/components/common/TagInput'
import { Button } from '@/components/ui/button'
import { countsUseLatest, fieldsForCounts, normaliseCountsParams,
import { countsUseLatest, fieldsForCounts, normaliseCountsParams, readCountSort,
selectedCountChildren, selectedCountColumns } from '@/lib/counts'
import { countsNestedIsScalar } from '@/lib/countsDisplay'
import { useCountsDefinition } from '@/lib/countsDefinitions'
import { readCountsJSON, useCountsDefinition } from '@/lib/countsDefinitions'
import { useClientMotion } from '@/lib/useClientMotion'
import { inputClass } from '@/lib/utils'
import { useUITranslation } from '@/lib/useUITranslation'
@@ -42,6 +42,7 @@ const CountsControls = (
const nestedJSON = applied.get ('nested_json')
const rowNumbers = applied.get ('row_numbers')
const thumbnail = selectedCountColumns (resource, applied, definition).includes ('thumbnail')
const previousThumbnail = useRef (thumbnail)
const mode = draft.get ('mode') ?? 'summary'
const columns = selectedCountColumns (resource, draft, definition)
const details = mode === 'details'
@@ -50,8 +51,10 @@ const CountsControls = (
&& !countsNestedIsScalar (field.children, field.key, draft)
&& field.children.some (child => selectedCountChildren (draft)
.has (`${field.key}.${child.key}`)))
// Immediate presentation changes merge into the pending query, never replace it.
// Preserve absent columns until a real presentation change or an explicit edit.
useEffect (() => {
if (previousThumbnail.current === thumbnail) return
previousThumbnail.current = thumbnail
setDraft (previous => {
const next = new URLSearchParams (previous)
const columns = selectedCountColumns (resource, previous, definition)
@@ -75,16 +78,26 @@ const CountsControls = (
const history = draft.get ('definition') === 'history'
const update = (key: string, value: string) => setDraft (previous => {
const next = new URLSearchParams (previous)
if (['mode', 'rows', 'cols'].includes (key)) next.delete ('sort')
if (['rows', 'cols'].includes (key) && next.get ('sort') !== '') next.delete ('sort')
next.set (key, value)
return next
if (key !== 'mode') return next
const resolved = normaliseCountsParams (resource, next, definition)
const axes = [...readCountsJSON<unknown[]> (resolved, 'rows', []),
...readCountsJSON<unknown[]> (resolved, 'cols', [])]
const allowed = value === 'summary'
? ['count', ...axes.map ((_, index) => `a${index}`)]
: fieldsForCounts (resource, definition).map (field => field.key)
if (readCountSort (resolved.get ('sort')).some (sort => !allowed.includes (sort.key)
&& !(value === 'summary' && /^measure_[0-9a-f]+$/.test (sort.key))))
next.delete ('sort')
return normaliseCountsParams (resource, next, definition)
})
return <form className="shrink-0" onSubmit={event => {
event.preventDefault ()
const next = new URLSearchParams (draft)
next.delete ('page')
next.delete ('execution_at')
execute (normaliseCountsParams (resource, next))
execute (normaliseCountsParams (resource, next, definition))
}}>
{!history && <div className="flex flex-wrap items-center gap-2 border-b pb-1
dark:border-slate-700">
+4 -4
ファイルの表示
@@ -2,14 +2,14 @@ import CountsResizeHandle from '@/components/counts/CountsResizeHandle'
import { useUITranslation } from '@/lib/useUITranslation'
const CountsRowNumber = ({ value }: { value?: number }) => {
const CountsRowNumber = ({ value, rowSpan }: { value?: number; rowSpan?: number }) => {
const { label, message } = useUITranslation ('counts')
if (value != null) return <td data-counts-copy={String (value)}
className="border-b px-2 py-1 text-right align-top tabular-nums dark:border-slate-700">
{value}</td>
return <th data-counts-heading={message ('rowNumbers').text} className="relative px-2 py-1">
{label ('rowNumbers')}
<CountsResizeHandle field="row_number" label={message ('rowNumbers')}/>
return <th rowSpan={rowSpan} data-counts-heading={message ('rowNumberHeading').text} className="relative px-2 py-1">
{label ('rowNumberHeading')}
<CountsResizeHandle field="row_number" label={message ('rowNumberHeading')}/>
</th>
}
+1
ファイルの表示
@@ -110,6 +110,7 @@ const CountsSelectableTable = (
const table = copyTable ()
return countsClipboard ({
columns: table.columns.slice (bounds.left, bounds.right + 1),
headerRows: table.headerRows?.map (row => row.slice (bounds.left, bounds.right + 1)),
rows: table.rows.slice (bounds.top, bounds.bottom + 1)
.map (row => row.slice (bounds.left, bounds.right + 1)) }, headers)
}
+5 -4
ファイルの表示
@@ -8,12 +8,13 @@ import type { CountSort } from '@/lib/counts'
const CountsSortHeader = (
{ field, label, sorts, change, structured = false }: {
{ field, label, sorts, change, structured = false, rowSpan, colSpan, resizable = true }: {
field: string
label: { text: string; language: string }
sorts: CountSort[]
change: (key: string, additive: boolean) => void
structured?: boolean },
structured?: boolean
rowSpan?: number; colSpan?: number; resizable?: boolean },
) => {
const { message } = useUITranslation ('counts')
const press = useRef<{ x: number; y: number
@@ -36,7 +37,7 @@ const CountsSortHeader = (
direction: message (`sort.${state}`).text })
return (
<th data-counts-heading={label.text}
<th data-counts-heading={label.text} rowSpan={rowSpan} colSpan={colSpan}
className="relative overflow-hidden whitespace-nowrap px-1 py-1"
aria-sort={(sorts.length > 1 && priority >= 0) ? 'other' : state}>
<Button type="button" variant="ghost" size="sm"
@@ -89,7 +90,7 @@ const CountsSortHeader = (
{priority >= 0 && <span aria-hidden>
{direction === 'asc' ? '▲' : '▼'}{sorts.length > 1 && priority + 1}</span>}
</Button>
<CountsResizeHandle field={field} label={label}/>
{resizable && <CountsResizeHandle field={field} label={label}/>}
</th>)
}
+135 -64
ファイルの表示
@@ -5,10 +5,11 @@ import CountsSortHeader from '@/components/counts/CountsSortHeader'
import { changeCountSort, countsRequestParams, readCountSort, writeCountSort } from '@/lib/counts'
import { countsAxisLabel, countsFieldLabel, readCountsJSON, useCountsDefinition }
from '@/lib/countsDefinitions'
import { cn } from '@/lib/utils'
import { useLocale } from '@/lib/useLocale'
import { useUITranslation } from '@/lib/useUITranslation'
import type { CountsResource, CountsResult } from '@/lib/counts'
import type { CountsPivotHeader, CountsResource, CountsResult } from '@/lib/counts'
import type { CountsAxis } from '@/lib/countsDefinitions'
@@ -19,25 +20,26 @@ const CountsSummaryResults = (
) => {
const definition = useCountsDefinition ()
const locale = useLocale ()
const { label, message } = useUITranslation ('counts')
const rowNumbers = params.get ('row_numbers') === '1'
const { message } = useUITranslation ('counts')
const numbered = params.get ('row_numbers') === '1'
const rows = data.rows ?? []
const cols = data.cols ?? []
const axes = [...rows, ...cols]
const cells = data.cells ?? []
const rowKeys = [...new Set (cells.map (cell => JSON.stringify (
cell.values.slice (0, rows.length))))]
const columnKeys = data.columnValues?.map (values => JSON.stringify (values))
?? [...new Set (cells.map (cell => JSON.stringify (cell.values.slice (rows.length))))]
if (cols.length === 0 && columnKeys.length === 0) columnKeys.push ('[]')
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): string => {
if (value == null) return axis.field === 'user' ? message ('bot').text : ''
const logical = data.axisLabels?.[`a${index}`]?.[String (value)]
if (axis.field === 'user') return message ('actorWithId', {
name: logical || message ('notRegistered').text, id: value }).text
name: logical || message ('notRegistered').text, id: value }).text
if (logical != null) return logical
if (['tags', 'user', 'language_code', 'source_language_code'].includes (axis.field))
if (['tags', 'language_code', 'source_language_code'].includes (axis.field))
return message ('notRegistered').text
if (axis.field === 'changed_field') return message (countsFieldLabel (String (value))).text
if (axis.field === 'event_type') return message (`events.${value}`).text
@@ -45,71 +47,140 @@ const CountsSummaryResults = (
return message (`common:categories.${value}`).text
return String (value)
}
const drill = (values: (string | number | null)[]) => {
const aggregateLabel = (header: CountsPivotHeader) => message (
header.kind === 'grand_total' ? 'grandTotal' : 'subtotal').text
const leading = Math.max (rows.length, cols.length > 0 ? 1 : 0)
const levels = Math.max (1, cols.length)
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 measureKey = (header: CountsPivotHeader) => header.values.length === 0 ? 'count'
: `measure_${[...new TextEncoder ().encode (JSON.stringify (header.values))]
.map (byte => byte.toString (16).padStart (2, '0')).join ('')}`
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 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 = readCountsJSON<unknown[]> (params, 'drill', [])
next.set ('drill', JSON.stringify ([...existing,
...axes.map ((axis, index) => ({ ...axis, value: values[index] }))]))
...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}`
}
const keys = [...(rowNumbers ? ['row_number'] : []), ...rows.map ((_, index) => `a${index}`),
...columnKeys.map ((_, index) => `cell${index}`)]
let ordinal = (data.page - 1) * data.limit
const rowLabels = rowHeaders.map (header => {
const labels = names (header, rows, 0)
const number = header.kind === 'data' ? String (++ordinal) : ''
if (header.kind !== 'data') labels.push (aggregateLabel (header))
return [...(numbered ? [number] : []),
...Array.from ({ length: leading }, (_, index) => labels[index] ?? '')]
})
// This unmerged matrix is also the single source for TSV and HTML clipboard headings.
const headingRows = 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 header.kind === 'data'
? message ('count').text : aggregateLabel (header)
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 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 = aggregate ? aggregateLabel (header)
: (cols.length === 0 ? message ('count').text
: 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} rowSpan={aggregate ? levels - level : 1} resizable={leaf}
label={{ text, language: message ('count').language }}
sorts={sorts.map (sort => sort.key === sortKey ? { ...sort, key: field } : sort)}
change={(_, 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}
identity={`${countsRequestParams (params)}:${keys}`} className="text-left text-sm">
<thead className="sticky top-0 z-20"><tr>
{rowNumbers && <CountsRowNumber/>}
{rows.map ((axis, index) => <CountsSortHeader key={axis.field} field={`a${index}`}
label={countsAxisLabel (axis, definition, locale)} sorts={sorts}
change={(key, additive) => update ('sort', writeCountSort (
changeCountSort (sorts, key, additive)))}/>)}
{columnKeys.map ((key, index) => {
const values = JSON.parse (key) as (string | number | null)[]
const name = values.map ((value, position) =>
`${countsAxisLabel (cols[position], definition, locale).text}: ${
display (value, cols[position], rows.length + position)}`).join (' / ')
const sortKey = cols.length === 0 ? 'count' : `measure_${[
...new TextEncoder ().encode (key)].map (byte =>
byte.toString (16).padStart (2, '0')).join ('')}`
return <CountsSortHeader key={key} field={`cell${index}`}
label={{ text: name || message ('count').text,
language: message ('count').language }}
sorts={sorts.map (sort => sort.key === sortKey
? { ...sort, key: `cell${index}` } : sort)}
change={(_, additive) => update ('sort', writeCountSort (
changeCountSort (sorts, sortKey, additive)))}/>
})}
</tr></thead>
<tbody>{rowKeys.map ((rowKey, rowIndex) => {
const rowValues = JSON.parse (rowKey) as (string | number | null)[]
return <tr key={rowKey}>
{rowNumbers && <CountsRowNumber value={(data.page - 1) * data.limit + rowIndex + 1}/>}
{rowValues.map ((value, index) => <td key={index}
className="overflow-hidden break-words border px-2 py-1 dark:border-slate-700"
data-counts-copy={display (value, rows[index], index)}
lang={(value == null && rows[index].field === 'user')
? message ('bot').language : (rows[index].field === 'event_type'
? message (`events.${value}`).language : undefined)}>
{display (value, rows[index], index)}</td>)}
{columnKeys.map (columnKey => {
const columnValues = JSON.parse (columnKey) as (string | number | null)[]
const values = [...rowValues, ...columnValues]
const cell = cells.find (cell => JSON.stringify (
cell.values) === JSON.stringify (values))
return <td key={columnKey} data-counts-copy={String (cell?.count ?? 0)}
className="border px-2 py-1 dark:border-slate-700">
<PrefetchLink to={drill (values)}>{cell?.count ?? 0}</PrefetchLink>
</td>
})}
</tr>
})}</tbody>
copyTable={() => ({ columns: keys.map ((_, index) => ({ label: headingRows[0][index] })),
headerRows: headingRows, rows: matrix })}
identity={`${countsRequestParams (params)}:${JSON.stringify (keys)}`}
className="text-left text-sm">
<thead className="sticky top-0 z-20">
{headingRows.map ((_, level) => <tr key={level}>
{level === 0 && <>
{numbered && <CountsRowNumber rowSpan={levels}/>}
{Array.from ({ length: leading }, (_, index) => rows[index] == null
? <th key={index} rowSpan={levels}/>
: <CountsSortHeader key={rows[index].field} field={`a${index}`}
rowSpan={levels} label={countsAxisLabel (rows[index], definition, locale)}
sorts={sorts} change={change}/>)}
</>}
{headerCells (level)}
</tr>)}
</thead>
<tbody>{rowHeaders.map ((row, index) => <tr key={columnKey (row)}>
{rowLabels[index].map ((value, offset) => <td key={offset} data-counts-copy={value}
data-counts-aggregate={row.kind !== 'data' ? '' : undefined}
className={cn ('overflow-hidden break-words border px-2 py-1 dark:border-slate-700',
row.kind !== 'data' && 'bg-slate-200 text-slate-950 font-semibold',
row.kind !== 'data' && 'dark:bg-slate-700 dark:text-slate-50',
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-aggregate={(row.kind !== 'data' || column.kind !== 'data')
? '' : undefined}
className={cn ('border px-2 py-1 dark:border-slate-700',
(row.kind !== 'data' || column.kind !== 'data')
&& 'bg-slate-200 text-slate-950 font-semibold',
(row.kind !== 'data' || column.kind !== 'data')
&& 'dark:bg-slate-700 dark:text-slate-50')}>
<PrefetchLink to={drill (row, column)}>{count (row, column)}</PrefetchLink>
</td>)}
</tr>)}</tbody>
</CountsSelectableTable>
{cells.length === 0 && <p>{label ('empty')}</p>}
</div>
}
export default CountsSummaryResults
+11
ファイルの表示
@@ -18,6 +18,17 @@
@apply bg-slate-900 text-slate-100;
}
.counts-grid > tbody > tr > td[data-counts-aggregate]:not([data-counts-selected]) {
background-color: #e2e8f0;
color: #0f172a;
font-weight: 600;
}
.dark .counts-grid > tbody > tr > td[data-counts-aggregate]:not([data-counts-selected]) {
background-color: #334155;
color: #f8fafc;
}
.counts-grid > thead {
@apply bg-slate-200 text-slate-950;
}