@dou-ui/pivot 0.0.0-stage → 0.1.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +10 -2
  3. package/dist/assets/default-PXde2wzm.js.map +1 -0
  4. package/dist/core/format.js +116 -0
  5. package/dist/core/format.js.map +1 -0
  6. package/dist/core/labels.js +28 -0
  7. package/dist/core/labels.js.map +1 -0
  8. package/dist/core/layout.js +190 -0
  9. package/dist/core/layout.js.map +1 -0
  10. package/dist/core/source.js +46 -0
  11. package/dist/core/source.js.map +1 -0
  12. package/dist/core/table.js +154 -0
  13. package/dist/core/table.js.map +1 -0
  14. package/dist/engine/aggregate.js +51 -0
  15. package/dist/engine/aggregate.js.map +1 -0
  16. package/dist/engine/compute.js +735 -0
  17. package/dist/engine/compute.js.map +1 -0
  18. package/dist/engine/engine.js +46 -0
  19. package/dist/engine/engine.js.map +1 -0
  20. package/dist/engine/formula.js +225 -0
  21. package/dist/engine/formula.js.map +1 -0
  22. package/dist/engine/group.js +107 -0
  23. package/dist/engine/group.js.map +1 -0
  24. package/dist/engine/hash.js +108 -0
  25. package/dist/engine/hash.js.map +1 -0
  26. package/dist/engine/index.js +8 -0
  27. package/dist/engine/show.js +132 -0
  28. package/dist/engine/show.js.map +1 -0
  29. package/dist/engine/source.js +339 -0
  30. package/dist/engine/source.js.map +1 -0
  31. package/dist/engine/tree.js +226 -0
  32. package/dist/engine/tree.js.map +1 -0
  33. package/dist/fields/fields.js +6 -0
  34. package/dist/fields/fields.js.map +1 -0
  35. package/dist/fields/fields.vue_vue_type_script_setup_true_lang.js +881 -0
  36. package/dist/fields/fields.vue_vue_type_script_setup_true_lang.js.map +1 -0
  37. package/dist/fields/filter-list.js +6 -0
  38. package/dist/fields/filter-list.js.map +1 -0
  39. package/dist/fields/filter-list.vue_vue_type_script_setup_true_lang.js +184 -0
  40. package/dist/fields/filter-list.vue_vue_type_script_setup_true_lang.js.map +1 -0
  41. package/dist/fields/formula-dialog.js +6 -0
  42. package/dist/fields/formula-dialog.js.map +1 -0
  43. package/dist/fields/formula-dialog.vue_vue_type_script_setup_true_lang.js +165 -0
  44. package/dist/fields/formula-dialog.vue_vue_type_script_setup_true_lang.js.map +1 -0
  45. package/dist/fields/model.js +200 -0
  46. package/dist/fields/model.js.map +1 -0
  47. package/dist/fields/top-dialog.js +6 -0
  48. package/dist/fields/top-dialog.js.map +1 -0
  49. package/dist/fields/top-dialog.vue_vue_type_script_setup_true_lang.js +174 -0
  50. package/dist/fields/top-dialog.vue_vue_type_script_setup_true_lang.js.map +1 -0
  51. package/dist/index.js +16 -0
  52. package/dist/index.js.map +1 -0
  53. package/dist/locale.js +508 -0
  54. package/dist/locale.js.map +1 -0
  55. package/dist/pivot.js +6 -0
  56. package/dist/pivot.js.map +1 -0
  57. package/dist/pivot.vue_vue_type_script_setup_true_lang.js +1711 -0
  58. package/dist/pivot.vue_vue_type_script_setup_true_lang.js.map +1 -0
  59. package/dist/style.css +1 -0
  60. package/dist/style.d.ts +1 -0
  61. package/dist/style.js +13 -0
  62. package/dist/types/core/format.d.ts +26 -0
  63. package/dist/types/core/labels.d.ts +14 -0
  64. package/dist/types/core/layout.d.ts +72 -0
  65. package/dist/types/core/source.d.ts +7 -0
  66. package/dist/types/core/table.d.ts +73 -0
  67. package/dist/types/engine/aggregate.d.ts +35 -0
  68. package/dist/types/engine/compute.d.ts +173 -0
  69. package/dist/types/engine/engine.d.ts +29 -0
  70. package/dist/types/engine/formula.d.ts +44 -0
  71. package/dist/types/engine/group.d.ts +34 -0
  72. package/dist/types/engine/hash.d.ts +36 -0
  73. package/dist/types/engine/index.d.ts +15 -0
  74. package/dist/types/engine/show.d.ts +27 -0
  75. package/dist/types/engine/source.d.ts +108 -0
  76. package/dist/types/engine/tree.d.ts +87 -0
  77. package/dist/types/engine/types.d.ts +148 -0
  78. package/dist/types/fields/fields.vue.d.ts +26 -0
  79. package/dist/types/fields/filter-list.vue.d.ts +20 -0
  80. package/dist/types/fields/formula-dialog.vue.d.ts +30 -0
  81. package/dist/types/fields/model.d.ts +52 -0
  82. package/dist/types/fields/top-dialog.vue.d.ts +25 -0
  83. package/dist/types/index.d.ts +458 -0
  84. package/dist/types/locale.d.ts +184 -0
  85. package/dist/types/pivot.vue.d.ts +29 -0
  86. package/dist/types/types.d.ts +199 -0
  87. package/dist/types/worker/client.d.ts +19 -0
  88. package/dist/types/worker/default.d.ts +1 -0
  89. package/dist/types/worker/index.d.ts +5 -0
  90. package/dist/types/worker/protocol.d.ts +60 -0
  91. package/dist/types/worker/serve.d.ts +22 -0
  92. package/dist/worker/client.js +191 -0
  93. package/dist/worker/client.js.map +1 -0
  94. package/dist/worker/default.js +20 -0
  95. package/dist/worker/default.js.map +1 -0
  96. package/dist/worker/index.js +3 -0
  97. package/dist/worker/serve.js +101 -0
  98. package/dist/worker/serve.js.map +1 -0
  99. package/package.json +64 -4
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pivot.js","names":["_sfc_main"],"sources":["../src/pivot.vue"],"sourcesContent":["<script setup lang=\"ts\" generic=\"Row = Record<string, unknown>\">\nimport type { HTMLAttributes } from 'vue'\nimport type { ForwardedAttributes } from 'dou-ui'\nimport { useElementSize, useLocale } from 'dou-ui'\nimport { ArrowDownIcon, ArrowUpIcon, ChevronRightIcon } from '@dou-ui/icons'\nimport {\n computed,\n markRaw,\n nextTick,\n onBeforeUnmount,\n ref,\n shallowRef,\n toRaw,\n useId,\n watch,\n} from 'vue'\nimport {\n computePivot,\n PivotSource,\n type PivotAggregator,\n type PivotResult,\n type PivotTree,\n} from './engine'\nimport { BUILTIN_AGGREGATORS, FAST_AGGREGATES } from './engine/aggregate'\nimport { markOf, PERCENT_SHOWS, showOf } from './engine/compute'\nimport type { PivotEngine } from './engine/engine'\nimport type {\n PivotCollapsed,\n PivotConfig,\n PivotDimension,\n PivotField,\n PivotPath,\n} from './engine/types'\nimport {\n collapsedNodes,\n columnLines,\n groupPaths,\n headerSpans,\n rowLines,\n samePath,\n spansIn,\n totalLines,\n type PivotAxis,\n type PivotHeaderSpan,\n type PivotLine,\n} from './core/layout'\nimport { labelFormatter, valueFormatter, valueNumberOptions } from './core/format'\nimport { valueLabel } from './core/labels'\nimport { usePivotSource } from './core/source'\nimport {\n gridMerges,\n gridToCSV,\n gridToHTML,\n gridToSheetCells,\n gridToTSV,\n sheetFormat,\n SHEET_STYLES,\n type PivotCSVOptions,\n type PivotGrid,\n type PivotGridCell,\n} from './core/table'\nimport { format, usePivotMessages } from './locale'\nimport type {\n PivotCellEvent,\n PivotChartRows,\n PivotChartRowsOptions,\n PivotCopyOptions,\n PivotEmits,\n PivotExpose,\n PivotPrintOptions,\n PivotProps,\n PivotSheetWorkbook,\n} from './types'\n\ndefineOptions({ name: 'DouPivot' })\n\nconst props = withDefaults(\n defineProps<\n PivotProps<Row> &\n /* @vue-ignore */ ForwardedAttributes<\n HTMLAttributes,\n PivotProps<Row>,\n keyof PivotEmits & string\n >\n >(),\n {\n layout: 'compact',\n resizable: true,\n worker: 'auto',\n maxHeight: 480,\n size: 'md',\n rowHeaderWidth: 200,\n columnWidth: 120,\n },\n)\nconst config = defineModel<PivotConfig>('config', { default: () => ({}) })\nconst collapsed = defineModel<PivotCollapsed>('collapsed', { default: () => ({}) })\nconst emit = defineEmits<{ cellClick: [cell: PivotCellEvent<Row>] }>()\n\nconst { locale } = useLocale()\nconst messages = usePivotMessages(() => props.messages)\nconst uid = useId()\n\nconst ROW_HEIGHT: Record<string, number> = { sm: 28, md: 32, lg: 40 }\nconst VALUE_HEADER_WIDTH = 120\n\n// ---- the records, read once per array ----\n\nconst source = usePivotSource(\n () => props.data,\n () => props.fields,\n)\n\n/** Stays the same object while the aggregators do, even when the array is written inline. */\nlet lastAggregators: Record<string, PivotAggregator> = {}\nconst aggregatorMap = computed(() => {\n const list = props.aggregators ?? []\n const last = Object.values(lastAggregators)\n if (list.length === last.length && list.every((a) => lastAggregators[a.name] === toRaw(a)))\n return lastAggregators\n const map: Record<string, PivotAggregator> = {}\n for (const aggregator of list) map[aggregator.name] = toRaw(aggregator)\n return (lastAggregators = map)\n})\n\nconst keyReplacer = (_key: string, value: unknown) =>\n typeof value === 'function' ? undefined : value\n\nconst dimensionKey = (d: PivotDimension) =>\n typeof d === 'string'\n ? { field: d }\n : { field: d.field, sort: d.sort, subtotal: d.subtotal, by: d.by }\n\n/**\n * The part of the config that changes what is summed up. Sorting, totals,\n * labels and formats leave it as it is, so they never sum up again.\n */\nlet engineCache: { key: string; config: PivotConfig } | null = null\nconst engineConfig = computed<PivotConfig>(() => {\n const c = config.value\n const custom = aggregatorMap.value\n const values = (c.values ?? []).filter((v) => {\n const name = typeof v === 'string' ? undefined : v.aggregate\n if (!name || FAST_AGGREGATES.has(name) || BUILTIN_AGGREGATORS[name] || custom[name]) return true\n if (import.meta.env?.DEV)\n console.warn(\n `[@dou-ui/pivot] there is no aggregate named \"${name}\", so this value is left out; register it in the aggregators prop`,\n )\n return false\n })\n const next: PivotConfig = {\n rows: (c.rows ?? []).map(dimensionKey),\n columns: (c.columns ?? []).map(dimensionKey),\n // how values are shown, marked, headed and written changes nothing to sum up\n values: values.map((v) =>\n typeof v === 'string'\n ? { field: v }\n : v.formula !== undefined\n ? { formula: v.formula }\n : { field: v.field, aggregate: v.aggregate },\n ),\n filters: c.filters,\n }\n const key = JSON.stringify(next, keyReplacer)\n if (engineCache?.key === key) return engineCache.config\n engineCache = { key, config: next }\n return next\n})\n\n// ---- summing up: here, or in a worker ----\n\n/** Records from which `'auto'` sums up in a worker. */\nconst WORKER_FROM = 100_000\n/** How long a worker may take before the table says it is busy, in ms. */\nconst BUSY_AFTER = 120\n\n/** Set when the table's own worker cannot start (under a strict CSP, say); it then sums up here. */\nconst ownFailed = ref(false)\nconst away = computed(\n () =>\n !!props.engine ||\n (!ownFailed.value &&\n (props.worker === true ||\n (props.worker === 'auto' &&\n source.value.length > WORKER_FROM &&\n typeof Worker !== 'undefined'))),\n)\n\n/** The last result summed up here, shown while a worker sums up the first time. */\nlet lastHere: PivotResult | null = null\nconst here = computed(() => {\n if (away.value) return null\n const next = computePivot(source.value, engineConfig.value, { aggregators: aggregatorMap.value })\n return (lastHere = markRaw(next))\n})\n\n/** What a worker last sent, and whether a newer one is on its way. */\nconst fromAway = shallowRef<PivotResult | null>(null)\nconst busy = ref(false)\n/** Groups with no records yet: the shape of the table while the first result is on its way. */\nconst shapeOnly = computed(() =>\n markRaw(computePivot(new PivotSource([], { fields: [...source.value.fields.values()] }), {})),\n)\nconst result = computed<PivotResult>(() => here.value ?? fromAway.value ?? shapeOnly.value)\n\nlet ownEngine: PivotEngine | null = null\nasync function startOwnEngine() {\n const [{ createPivotWorker }, { default: PivotWorker }] = await Promise.all([\n import('./worker/client'),\n import('./worker/default?worker&inline'),\n ])\n return createPivotWorker(() => new PivotWorker())\n}\nlet ownEngineReady: Promise<PivotEngine> | null = null\nconst engineFor = () =>\n props.engine\n ? Promise.resolve(props.engine)\n : (ownEngineReady ??= startOwnEngine().then((engine) => (ownEngine = engine)))\n\nlet job = 0\nlet stop: AbortController | null = null\nwatch(\n [away, source, engineConfig, () => props.engine],\n async ([isAway, src, cfg]) => {\n stop?.abort()\n stop = null\n const id = ++job\n if (!isAway) {\n busy.value = false\n fromAway.value = null\n return\n }\n // keep what is on screen until the worker's first result comes\n if (!fromAway.value && lastHere) fromAway.value = lastHere\n lastHere = null\n const controller = (stop = new AbortController())\n const slow = setTimeout(() => id === job && (busy.value = true), BUSY_AFTER)\n try {\n const engine = await engineFor()\n const next = await engine.compute(src, cfg, {\n aggregators: aggregatorMap.value,\n signal: controller.signal,\n })\n if (id !== job) return\n fromAway.value = markRaw(next)\n } catch (error) {\n if (id !== job || (error as Error).name === 'AbortError') return\n if (props.engine)\n console.error(\n '[@dou-ui/pivot] the engine passed to DouPivot failed in compute(); the table keeps the last result',\n error,\n )\n else {\n // the own worker failed: sum up on the page from now on\n console.warn(\n '[@dou-ui/pivot] the worker is not available, so the table sums up on the page from now on',\n error,\n )\n ownEngine?.dispose?.()\n ownEngine = null\n ownEngineReady = null\n ownFailed.value = true\n }\n } finally {\n clearTimeout(slow)\n if (id === job) busy.value = false\n }\n },\n { immediate: true },\n)\n\nonBeforeUnmount(() => {\n stop?.abort()\n job++\n ownEngine?.dispose?.()\n})\n\n// ---- what is shown ----\n\nconst valueCount = computed(() => result.value.values.length)\nconst valuesOnRows = computed(() => config.value.valuesOn === 'rows' && valueCount.value > 1)\nconst showRowTotal = computed(() => config.value.totals?.rows !== false)\nconst showColumnTotal = computed(() => config.value.totals?.columns !== false)\n\nconst rowCollapsed = computed(() => collapsedNodes(result.value.rows, collapsed.value.rows))\nconst columnCollapsed = computed(() =>\n collapsedNodes(result.value.columns, collapsed.value.columns),\n)\nconst rowOrder = computed(() => result.value.rowOrder(config.value.sort))\n/** Each row field in a column of its own, open groups' totals on a line after their children. */\nconst tabular = computed(() => props.layout === 'tabular' && result.value.rows.levels.length > 0)\n\nconst bodyLines = computed(() =>\n rowLines(result.value.rows, rowOrder.value, rowCollapsed.value, {\n values: valueCount.value,\n valuesHere: valuesOnRows.value,\n total: showRowTotal.value,\n totalsAfter: tabular.value,\n }),\n)\nconst footLines = computed(() =>\n totalLines(result.value.rows, {\n values: valueCount.value,\n valuesHere: valuesOnRows.value,\n total: showRowTotal.value,\n }),\n)\nconst colLines = computed(() =>\n columnLines(result.value.columns, columnCollapsed.value, {\n values: valueCount.value,\n valuesHere: !valuesOnRows.value,\n total: showColumnTotal.value,\n }),\n)\n/**\n * The row naming each column's value: left out when the values run along the\n * rows, and when one value sits under column groups (each would repeat it).\n */\nconst valueRow = computed(\n () => result.value.columns.levels.length === 0 || (!valuesOnRows.value && valueCount.value > 1),\n)\nconst spans = computed(() => headerSpans(result.value.columns, colLines.value, valueRow.value))\n\n/** How each value is headed, shown, marked and written: the config's, on what was summed up. */\nconst valueDisplay = computed(() => {\n const m = messages.value\n return result.value.values.map((value, i) => {\n const given = config.value.values?.[i]\n const spec = typeof given === 'object' ? given : undefined\n const show = showOf(spec?.show)\n // a share or a rank is not in the field's units\n const own = show && (PERCENT_SHOWS.has(show.as) || show.as === 'rank')\n const shownValue = {\n ...value,\n show,\n format: spec?.format ?? (own ? undefined : value.format),\n }\n const options = valueNumberOptions(shownValue)\n return {\n label: valueLabel(shownValue, spec?.label, m),\n write: valueFormatter(shownValue, locale.value.name),\n /** The same format for a sheet, when there is one. */\n sheetFormat: options ? sheetFormat(options, locale.value.name) : undefined,\n show: show ?? null,\n mark: markOf(spec?.mark),\n }\n })\n})\n\nconst labelWriters = (tree: PivotTree) => {\n const m = messages.value\n const texts = {\n empty: m.empty,\n yes: m.yes,\n no: m.no,\n year: m.yearLabel,\n quarter: m.quarterLabel,\n week: m.weekLabel,\n range: m.rangeLabel,\n }\n return tree.levels.map((level) => labelFormatter(level.field, locale.value.name, texts, level.by))\n}\nconst rowWriters = computed(() => labelWriters(result.value.rows))\nconst colWriters = computed(() => labelWriters(result.value.columns))\n\nconst nodeText = (tree: PivotTree, writers: ((v: unknown) => string)[], node: number) =>\n node <= 0 ? messages.value.total : writers[tree.depth[node]! - 1]!(tree.label(node))\n\n// ---- sizes and the window in view ----\n\nconst rowHeight = computed(() => ROW_HEIGHT[props.size] ?? 32)\n/** A row per column field, and one more for the row fields (and the values, when shown). */\nconst headRows = computed(() => result.value.columns.levels.length + 1)\nconst headHeight = computed(() => headRows.value * rowHeight.value)\n/** Set by dragging the edge of the corner; `0` for the width worked out from the props. */\nconst rowHeadDragged = ref(0)\nconst rowLevelCount = computed(() => (tabular.value ? result.value.rows.levels.length : 1))\n/**\n * The row headers' width. A tabular layout gives each row field a column of\n * 60% of `rowHeaderWidth`. On a narrow screen the row headers give up some\n * width so the numbers still show.\n */\nconst rowHeadWidth = computed(() => {\n if (rowHeadDragged.value) return rowHeadDragged.value\n const want = tabular.value\n ? rowLevelCount.value * Math.round(props.rowHeaderWidth * 0.6)\n : props.rowHeaderWidth\n return Math.min(want, Math.max(120, Math.floor(viewWidth.value * (tabular.value ? 0.5 : 0.4))))\n})\n/** The width of each row field's column in the tabular layout. */\nconst levelWidth = computed(() => Math.floor(rowHeadWidth.value / rowLevelCount.value))\nconst headWidth = computed(() => rowHeadWidth.value + (valuesOnRows.value ? VALUE_HEADER_WIDTH : 0))\n/** Columns widen to fill a wide table, up to twice their width; past that the bands run on alone. */\nconst colWidth = computed(() => {\n const count = colLines.value.length\n const fill = count ? Math.floor((viewWidth.value - headWidth.value) / count) : 0\n return Math.max(props.columnWidth, Math.min(fill, props.columnWidth * 2))\n})\n/** Widths set by dragging a column's edge, by the column's groups, kind and value. */\nconst dragged = shallowRef(new Map<string, number>())\nconst colKey = (line: PivotLine) =>\n `${line.kind}|${line.value}|${line.kind === 'total' ? '' : JSON.stringify(result.value.columns.path(line.node))}`\n/** Where each column starts; the last entry is where the last one ends. */\nconst colStarts = computed(() => {\n const lines = colLines.value\n const starts = new Float64Array(lines.length + 1)\n const base = colWidth.value\n const map = dragged.value\n for (let i = 0; i < lines.length; i++)\n starts[i + 1] = starts[i]! + (map.size ? (map.get(colKey(lines[i]!)) ?? base) : base)\n return starts\n})\n/** The column at `x` px from the left of the columns. */\nfunction columnAt(x: number) {\n const starts = colStarts.value\n let low = 0\n let high = starts.length - 2\n while (low < high) {\n const mid = (low + high + 1) >> 1\n if (starts[mid]! <= x) low = mid\n else high = mid - 1\n }\n return Math.max(0, low)\n}\nconst columnsWidth = computed(() => colStarts.value[colLines.value.length]!)\nconst totalWidth = computed(() => headWidth.value + columnsWidth.value)\nconst bodyHeight = computed(() => bodyLines.value.length * rowHeight.value)\nconst footHeight = computed(() => footLines.value.length * rowHeight.value)\n\nconst root = ref<HTMLElement>()\nconst viewport = ref<HTMLElement>()\nconst { width: viewWidth, height: viewHeight } = useElementSize(viewport, {\n initial: { width: 960, height: 480 },\n})\nconst scroll = shallowRef({ top: 0, left: 0 })\n/** The height of the horizontal scrollbar, when there is one that takes room. */\nconst gutter = ref(0)\nwatch(\n [viewWidth, viewHeight, () => totalWidth.value],\n () =>\n nextTick(() => {\n const el = viewport.value\n if (el) gutter.value = el.offsetHeight - el.clientHeight\n }),\n { flush: 'post' },\n)\n\nfunction onScroll() {\n const el = viewport.value\n if (!el) return\n if (el.scrollTop !== scroll.value.top || el.scrollLeft !== scroll.value.left)\n scroll.value = { top: el.scrollTop, left: el.scrollLeft }\n}\n\nconst OVERSCAN_ROWS = 6\nconst OVERSCAN_COLS = 2\n\nconst rowWindow = computed(() => {\n const h = rowHeight.value\n const space = Math.max(h, viewHeight.value - headHeight.value - footHeight.value)\n const from = Math.max(0, Math.floor(scroll.value.top / h) - OVERSCAN_ROWS)\n const to = Math.min(\n bodyLines.value.length,\n Math.ceil((scroll.value.top + space) / h) + OVERSCAN_ROWS,\n )\n return { from, to }\n})\n\nconst colWindow = computed(() => {\n const space = Math.max(colWidth.value, viewWidth.value - headWidth.value)\n const count = colLines.value.length\n const from = Math.max(0, columnAt(scroll.value.left) - OVERSCAN_COLS)\n const to = count ? Math.min(count, columnAt(scroll.value.left + space) + 1 + OVERSCAN_COLS) : 0\n // a stable object while the window stays put, so rows can skip re-rendering\n const last = lastColWindow\n if (last && last.from === from && last.to === to) return last\n return (lastColWindow = { from, to })\n})\nlet lastColWindow: { from: number; to: number } | null = null\n\nconst visibleRows = computed(() => {\n const { from, to } = rowWindow.value\n const out: { line: PivotLine; index: number }[] = []\n for (let i = from; i < to; i++) out.push({ line: bodyLines.value[i]!, index: i })\n return out\n})\nconst visibleFoot = computed(() =>\n footLines.value.map((line, i) => ({ line, index: bodyLines.value.length + i })),\n)\nconst visibleCols = computed(() => {\n const { from, to } = colWindow.value\n return colLines.value.slice(from, to).map((line, i) => ({ line, index: from + i }))\n})\nconst visibleSpans = computed(() => {\n const { from, to } = colWindow.value\n return spans.value.map((list) => spansIn(list, from, to))\n})\n\n// ---- rows ----\n\nconst rowTree = computed(() => result.value.rows)\n\n/** Whether a group's own row is blank because its field has no subtotals and it is open. */\nconst rowBlank = (line: PivotLine) => {\n if (line.kind !== 'group') return false\n const tree = rowTree.value\n if (!tree.isGroup(line.node) || rowCollapsed.value.has(line.node)) return false\n return !tree.levels[tree.depth[line.node]! - 1]!.subtotal\n}\n\nconst isFirstOfNode = (line: PivotLine) => line.value <= 0\n\n/** A group whose label shows on a line: in the tabular layout, one per row field. */\ninterface RowHeadPart {\n text: string\n /** The group a click on the arrow opens or closes, or -1. */\n toggle: number\n open: boolean\n}\n\ninterface RowHead {\n text: string\n depth: number\n group: boolean\n open: boolean\n valueText: string\n firstOfNode: boolean\n /** The tabular layout's labels, by row field; empty in the compact layout. */\n parts: (RowHeadPart | null)[]\n}\n\n/** Whether a line opens or closes a group from its row header: a group's line, or its total's. */\n/** Whether a line opens and closes its group; a subtotal after the children (tabular) does not. */\nconst lineToggles = (line: PivotLine) =>\n (line.kind === 'group' || (line.kind === 'subtotal' && !tabular.value)) &&\n rowTree.value.isGroup(line.node)\n\nfunction rowHead(line: PivotLine, index: number): RowHead {\n const tree = rowTree.value\n const group = lineToggles(line)\n const head: RowHead = {\n text:\n line.kind === 'total'\n ? messages.value.total\n : line.kind === 'subtotal'\n ? format(messages.value.subtotalRow, {\n label: nodeText(tree, rowWriters.value, line.node),\n })\n : nodeText(tree, rowWriters.value, line.node),\n depth: line.kind === 'total' ? 0 : tree.depth[line.node]! - 1,\n group,\n open: group && !rowCollapsed.value.has(line.node),\n valueText: line.value >= 0 ? valueDisplay.value[line.value]!.label : '',\n firstOfNode: isFirstOfNode(line),\n parts: [],\n }\n if (!tabular.value || !head.firstOfNode) return head\n const parts: (RowHeadPart | null)[] = Array.from({ length: tree.levels.length }, () => null)\n if (line.kind === 'total' || line.kind === 'subtotal') {\n parts[head.depth] = { text: head.text, toggle: group ? line.node : -1, open: head.open }\n } else {\n // a group's label shows on the first line inside it\n const before = index > 0 ? bodyLines.value[index - 1] : undefined\n for (let at = line.node; at > 0; at = tree.parent[at]!) {\n const level = tree.depth[at]! - 1\n if (at !== line.node && before && before.kind !== 'total' && tree.within(before.node, at))\n break\n const isGroup = tree.isGroup(at)\n parts[level] = {\n text: nodeText(tree, rowWriters.value, at),\n toggle: isGroup ? at : -1,\n open: isGroup && !rowCollapsed.value.has(at),\n }\n }\n }\n head.parts = parts\n return head\n}\n\ninterface Cell {\n key: number\n /** In the selected range. */\n selected: boolean\n text: string\n cls: string\n col: number\n style: string\n}\n\n/** Where a mark puts a value: a bar's start and size, or a tint's depth, from 0 to 1. */\nfunction markStyle(value: number, valueIndex: number) {\n const { mark, show } = valueDisplay.value[valueIndex]!\n if (!mark) return ''\n const { min, max } = result.value.range(valueIndex, show)\n if (min !== min) return ''\n const tone = `--dou-pivot-tone:var(--dou-color-${mark.tone})`\n if (mark.type === 'scale') {\n const depth = max === min ? 1 : (value - min) / (max - min)\n return `;${tone};--dou-pivot-mark:${depth.toFixed(3)}`\n }\n // bars grow from zero, to the left for values below it\n const lo = Math.min(0, min)\n const span = Math.max(0, max) - lo || 1\n const start = (Math.min(value, 0) - lo) / span\n return `;${tone};--dou-pivot-bar-start:${start.toFixed(3)};--dou-pivot-bar-size:${(Math.abs(value) / span).toFixed(3)}`\n}\n\nfunction rowCells(line: PivotLine, row: number): Cell[] {\n const res = result.value\n const sel = selection.value\n const picked = sel && row >= sel.top && row <= sel.bottom ? sel : null\n const blank = rowBlank(line)\n const display = valueDisplay.value\n const starts = colStarts.value\n // marks go on the innermost cells, never on subtotals and totals\n // (without fields on an axis, its grand total is its innermost group)\n const innerRow = res.rows.depth[line.node] === res.rows.levels.length && !blank\n return visibleCols.value.map(({ line: col, index }) => {\n const v = line.value >= 0 ? line.value : Math.max(0, col.value)\n const n = blank ? Number.NaN : res.shown(line.node, col.node, v, display[v]!.show)\n let cls = 'dou-pivot__cell'\n if (col.kind !== 'group') cls += ' is-total'\n if (col.kind === 'group' && col.node > 0 && res.columns.isGroup(col.node)) cls += ' is-closed'\n const selected = !!picked && index >= picked.left && index <= picked.right\n if (selected) cls += ' is-selected'\n if (col.node === highlightCol.value) cls += ' is-highlighted'\n let style = `width:${starts[index + 1]! - starts[index]!}px`\n const mark = display[v]!.mark\n if (mark && innerRow && n === n && res.columns.depth[col.node] === res.columns.levels.length) {\n cls += mark.type === 'bar' ? ' has-bar' : ' has-scale'\n style += markStyle(n, v)\n }\n return { key: index, col: index, text: display[v]!.write(n), cls, style, selected }\n })\n}\n\n// ---- header ----\n\ninterface HeadCell {\n key: string\n span: PivotHeaderSpan\n text: string\n style: string\n toggle: boolean\n open: boolean\n sortable: boolean\n sorted: 'asc' | 'desc' | null\n cls: string\n /** The column whose width its edge sets, or -1. */\n resize: number\n}\n\n/** What a header cell says: a group, a subtotal, the grand total or a value. */\nfunction spanText(span: PivotHeaderSpan) {\n const tree = result.value.columns\n if (span.kind === 'value')\n return span.value >= 0 ? valueDisplay.value[span.value]!.label : messages.value.total\n if (span.kind === 'total') return messages.value.total\n if (span.kind === 'subtotal')\n return format(messages.value.subtotal, { label: nodeText(tree, colWriters.value, span.node) })\n return nodeText(tree, colWriters.value, span.node)\n}\n\nconst columnPath = (line: { kind: string; node: number }) =>\n line.kind === 'total' ? [] : result.value.columns.path(line.node)\n\nconst headCells = computed<HeadCell[]>(() => {\n const tree = result.value.columns\n const starts = colStarts.value\n const h = rowHeight.value\n const sort = config.value.sort\n const out: HeadCell[] = []\n const rows = headRows.value\n visibleSpans.value.forEach((list) => {\n for (const span of list) {\n const reachesBottom = span.level + span.rows === rows\n const single = span.to - span.from === 1\n const line = colLines.value[span.from]!\n const sortable = span.kind === 'value' || (reachesBottom && single && !valueRow.value)\n const value = span.kind === 'value' ? Math.max(0, span.value) : Math.max(0, line.value)\n const sorted =\n sortable &&\n sort &&\n sort.valueIndex === value &&\n samePath(sort.column ?? [], columnPath(line))\n ? sort.order\n : null\n const canToggle = span.kind === 'group' && tree.isGroup(span.node)\n const text = spanText(span)\n let cls = `dou-pivot__col-head is-${span.kind}`\n // over the numbers, names sit right like them; a group with a toggle reads from the left\n if (span.kind !== 'group' || (!canToggle && single)) cls += ' is-end'\n if (span.kind === 'value' && line.kind !== 'group') cls += ' is-total'\n if (sorted) cls += ' is-sorted'\n out.push({\n key: `${span.level}:${span.from}:${span.kind}`,\n span,\n text,\n style: `left:${starts[span.from]}px;top:${span.level * h}px;width:${starts[span.to]! - starts[span.from]!}px;height:${span.rows * h}px`,\n resize: reachesBottom && single && props.resizable ? span.from : -1,\n toggle: canToggle,\n open: canToggle && !columnCollapsed.value.has(span.node),\n sortable,\n sorted,\n cls,\n })\n }\n })\n return out\n})\n\nconst rowFieldNames = computed(() => rowFields.value.map((f) => f.label).join(' / '))\nconst rowFields = computed(() =>\n result.value.rows.levels.map((level, i) => ({\n index: i,\n label: level.field.label,\n sort: level.sort,\n })),\n)\nconst columnFields = computed(() => result.value.columns.levels.map((level) => level.field.label))\n\n// ---- changing the config and the collapsed groups ----\n\nfunction sortByColumn(span: PivotHeaderSpan) {\n const line = colLines.value[span.from]!\n const value = span.kind === 'value' ? Math.max(0, span.value) : Math.max(0, line.value)\n const column = columnPath(line)\n const current = config.value.sort\n const same = current && current.valueIndex === value && samePath(current.column ?? [], column)\n // descending first (largest on top), then ascending, then back to the labels\n const order = !same ? 'desc' : current!.order === 'desc' ? 'asc' : null\n config.value = { ...config.value, sort: order ? { valueIndex: value, column, order } : null }\n}\n\nfunction sortByField(index: number) {\n const rows = (config.value.rows ?? []).map((d, i) => {\n const spec = typeof d === 'string' ? { field: d } : { ...d }\n if (i !== index) return d\n const wasValueSorted = !!config.value.sort\n spec.sort = wasValueSorted ? (spec.sort ?? 'asc') : spec.sort === 'desc' ? 'asc' : 'desc'\n return spec\n })\n config.value = { ...config.value, rows, sort: null }\n}\n\nfunction setCollapsed(axis: PivotAxis, paths: PivotPath[]) {\n collapsed.value = { ...collapsed.value, [axis]: paths }\n}\n\nfunction toggle(axis: PivotAxis, path: PivotPath, open?: boolean) {\n const list = collapsed.value[axis] ?? []\n const at = list.findIndex((p) => samePath(p, path))\n const isOpen = at < 0\n const next = open ?? !isOpen\n if (next === isOpen) return\n setCollapsed(axis, next ? list.filter((_, i) => i !== at) : [...list, path])\n}\n\nfunction toggleNode(axis: PivotAxis, node: number, all = false) {\n const tree = axis === 'rows' ? result.value.rows : result.value.columns\n const nodes = axis === 'rows' ? rowCollapsed.value : columnCollapsed.value\n const open = nodes.has(node)\n const label = nodeText(tree, axis === 'rows' ? rowWriters.value : colWriters.value, node)\n announce(format(open ? messages.value.expanded : messages.value.collapsed, { label }))\n if (!all) return toggle(axis, tree.path(node), open)\n // Alt: every group at the same depth goes the same way\n const depth = tree.depth[node]!\n const same: PivotPath[] = []\n for (let at = 1; at < tree.size; at++)\n if (tree.depth[at] === depth && tree.isGroup(at)) same.push(tree.path(at))\n const rest = (collapsed.value[axis] ?? []).filter((p) => !same.some((q) => samePath(p, q)))\n setCollapsed(axis, open ? rest : [...rest, ...same])\n}\n\nfunction expandAll({ axis }: { axis?: PivotAxis } = {}) {\n const next = { ...collapsed.value }\n for (const a of axis ? [axis] : (['rows', 'columns'] as const)) next[a] = []\n collapsed.value = next\n}\n\nfunction collapseAll({ axis, depth = 1 }: { axis?: PivotAxis; depth?: number } = {}) {\n const next = { ...collapsed.value }\n for (const a of axis ? [axis] : (['rows', 'columns'] as const))\n next[a] = groupPaths(a === 'rows' ? result.value.rows : result.value.columns, depth)\n collapsed.value = next\n}\n\n// ---- column widths ----\n\nlet justResized = false\nconst MIN_COLUMN = 56\nconst MAX_COLUMN = 640\n\n/** A column's width, or the row headers' for `-1`. */\nconst widthOf = (col: number) =>\n col < 0 ? rowHeadWidth.value : colStarts.value[col + 1]! - colStarts.value[col]!\nconst minWidthOf = (col: number) => (col < 0 ? 80 * rowLevelCount.value : MIN_COLUMN)\nconst maxWidthOf = (col: number) => (col < 0 ? 480 * rowLevelCount.value : MAX_COLUMN)\n\nfunction setWidth(col: number, width: number) {\n const w = Math.round(Math.min(Math.max(width, minWidthOf(col)), maxWidthOf(col)))\n if (col < 0) {\n rowHeadDragged.value = w\n return\n }\n const next = new Map(dragged.value)\n next.set(colKey(colLines.value[col]!), w)\n dragged.value = next\n}\n\n/** Dragging the edge of a column header (or of the corner, for the row headers). */\nfunction onResizeDown(event: PointerEvent) {\n const handle = (event.target as HTMLElement).closest<HTMLElement>('[data-resize]')\n if (!handle || event.button !== 0) return\n event.preventDefault()\n event.stopPropagation()\n const col = Number(handle.dataset.resize)\n const startX = event.clientX\n const from = widthOf(col)\n let moved = false\n handle.setPointerCapture?.(event.pointerId)\n const onMove = (e: PointerEvent) => {\n const dx = e.clientX - startX\n if (!moved && Math.abs(dx) < 2) return\n moved = true\n setWidth(col, from + dx)\n }\n const onUp = () => {\n window.removeEventListener('pointermove', onMove)\n window.removeEventListener('pointerup', onUp)\n window.removeEventListener('pointercancel', onUp)\n if (moved) {\n // the click that follows the drag is not a sort\n justResized = true\n setTimeout(() => (justResized = false))\n }\n }\n window.addEventListener('pointermove', onMove)\n window.addEventListener('pointerup', onUp)\n window.addEventListener('pointercancel', onUp)\n}\n\nconst measurer = ref<HTMLElement>()\n/** The widest of some texts in the table's font, measured once by the browser. */\nfunction widest(texts: string[]) {\n const el = measurer.value\n if (!el) return 0\n // the longest few by length; measuring every text would cost a layout each\n // oxlint-disable-next-line unicorn/no-array-sort -- sorts a fresh array\n const top = [...new Set(texts)].sort((a, b) => b.length - a.length).slice(0, 12)\n let max = 0\n for (const text of top) {\n el.textContent = text\n max = Math.max(max, el.getBoundingClientRect().width)\n }\n el.textContent = ''\n return Math.ceil(max)\n}\n\n/** Double-clicking a column's edge fits it to its numbers and its header; the corner's goes back to its width. */\nfunction onResizeFit(event: MouseEvent) {\n const handle = (event.target as HTMLElement).closest<HTMLElement>('[data-resize]')\n if (!handle) return\n event.stopPropagation()\n fitWidth(Number(handle.dataset.resize))\n}\n\nfunction fitWidth(col: number) {\n if (col < 0) {\n rowHeadDragged.value = 0\n return\n }\n const column = colLines.value[col]!\n const display = valueDisplay.value\n const res = result.value\n const lines = allLines.value\n const texts: string[] = []\n const limit = Math.min(lines.length, 5000)\n for (let i = 0; i < limit; i++) {\n const line = lines[i]!\n const v = line.value >= 0 ? line.value : Math.max(0, column.value)\n texts.push(display[v]!.write(rowBlank(line) ? Number.NaN : res.get(line.node, column.node, v)))\n }\n const head = headCells.value.find((c) => c.resize === col)\n const textWidth = widest(texts)\n const headWidthNeeded = head ? widest([head.text]) + (head.sorted ? 16 : 0) : 0\n const next = new Map(dragged.value)\n next.set(\n colKey(column),\n Math.min(MAX_COLUMN, Math.max(MIN_COLUMN, Math.max(textWidth, headWidthNeeded) + 26)),\n )\n dragged.value = next\n}\n\n/** The keys on a focused edge: the arrows resize it (Shift for bigger steps), Enter fits it. */\nfunction onResizeKeydown(event: KeyboardEvent) {\n const handle = (event.target as HTMLElement).closest<HTMLElement>('[data-resize]')\n if (!handle || event.key === 'Tab') return\n // the table's own keys stay off while an edge has the focus\n event.stopPropagation()\n const col = Number(handle.dataset.resize)\n const step = event.shiftKey ? 32 : 8\n if (event.key === 'ArrowLeft') setWidth(col, widthOf(col) - step)\n else if (event.key === 'ArrowRight') setWidth(col, widthOf(col) + step)\n else if (event.key === 'Enter') fitWidth(col)\n else return\n event.preventDefault()\n}\n\n/** One edge takes Tab: that of the active cell's column, or the row headers'. */\nconst resizeStop = computed(() => {\n const col = active.value?.col ?? -1\n return col >= 0 && col < colLines.value.length ? col : -1\n})\n\n// ---- pointer and keys ----\n\nconst active = shallowRef<{ row: number; col: number } | null>(null)\nconst focused = ref(false)\n/** Where a range of cells starts; the active cell is its other corner. */\nconst anchor = shallowRef<{ row: number; col: number } | null>(null)\n/** The selected range, rows and columns from first to last (column -1 is the row headers); null for one cell. */\nconst selection = computed(() => {\n const a = anchor.value\n const b = active.value\n if (!a || !b || (a.row === b.row && a.col === b.col)) return null\n return {\n top: Math.min(a.row, b.row),\n bottom: Math.max(a.row, b.row),\n left: Math.min(a.col, b.col),\n right: Math.max(a.col, b.col),\n }\n})\n/** What a screen reader reads out: a range picked, cells copied, a group opened. */\nconst announcement = ref('')\nlet announceTimer: ReturnType<typeof setTimeout> | undefined\nfunction announce(text: string, delay = 0) {\n clearTimeout(announceTimer)\n // cleared first, so the same words twice are read twice\n announcement.value = ''\n announceTimer = setTimeout(() => (announcement.value = text), delay || 50)\n}\n// read out once the range stops growing, not on every step\nwatch(selection, (sel) => {\n if (!sel) return\n announce(\n format(messages.value.selected, {\n rows: sel.bottom - sel.top + 1,\n columns: sel.right - sel.left + 1,\n }),\n 400,\n )\n})\nonBeforeUnmount(() => clearTimeout(announceTimer))\n\n/** The selected columns of a row, as `left:right`; empty when the row is not in the range. */\nconst selectedIn = (row: number) => {\n const sel = selection.value\n return sel && row >= sel.top && row <= sel.bottom ? `${sel.left}:${sel.right}` : ''\n}\nconst allLines = computed(() => [...bodyLines.value, ...footLines.value])\n\n/** The highlighted groups' nodes, -1 when there is none (or its path matches no group). */\nconst highlightRow = computed(() => {\n const path = props.highlight?.row\n return path?.length ? result.value.rows.find(path) || -1 : -1\n})\nconst highlightCol = computed(() => {\n const path = props.highlight?.column\n return path?.length ? result.value.columns.find(path) || -1 : -1\n})\n\nconst cellId = (row: number, col: number) => `${uid}-${row}-${col}`\nconst activeId = computed(() => {\n const a = active.value\n if (!a || !focused.value) return undefined\n return cellId(a.row, a.col)\n})\n\nfunction cellEvent(row: PivotLine, col: PivotLine): PivotCellEvent<Row> {\n const res = result.value\n const src = source.value\n const valueIndex = row.value >= 0 ? row.value : Math.max(0, col.value)\n const indices = () => res.indices(row.node, col.node)\n return {\n row: row.kind === 'total' ? [] : res.rows.path(row.node),\n column: col.kind === 'total' ? [] : res.columns.path(col.node),\n labels: {\n row: row.kind === 'total' ? [] : pathTexts(res.rows, rowWriters.value, row.node),\n column: col.kind === 'total' ? [] : pathTexts(res.columns, colWriters.value, col.node),\n },\n valueIndex,\n value: rowBlank(row)\n ? Number.NaN\n : res.shown(row.node, col.node, valueIndex, valueDisplay.value[valueIndex]?.show ?? null),\n records: () => indices().map((i) => src.record(i) as Row),\n indices,\n }\n}\n\n/** Set while a drag (or a Shift+click) picks a range, so the click that ends it opens nothing. */\nlet swept = false\n\n/** Pressing on a cell and dragging picks a range; Shift+click grows it. Touch scrolls instead. */\nfunction onPointerDown(event: PointerEvent) {\n if (event.button !== 0 || event.pointerType === 'touch') return\n const target = event.target as HTMLElement\n if (target.closest('[data-resize], [data-head], [data-toggle-node], [data-sort-field]')) return\n const el = target.closest<HTMLElement>('[data-row]')\n const view = viewport.value\n if (!el || !view) return\n // no text selection while dragging; the table takes the focus itself\n event.preventDefault()\n view.focus({ preventScroll: true })\n const start = { row: Number(el.dataset.row), col: Number(el.dataset.col) }\n if (event.shiftKey) {\n anchor.value ??= active.value ?? start\n active.value = start\n swept = true\n setTimeout(() => (swept = false))\n return\n }\n anchor.value = null\n const onMove = (e: PointerEvent) => {\n // near an edge the table scrolls on\n const box = view.getBoundingClientRect()\n if (e.clientY > box.bottom - 24) view.scrollTop += 16\n else if (e.clientY < box.top + headHeight.value + 8) view.scrollTop -= 16\n if (e.clientX > box.right - 24) view.scrollLeft += 16\n else if (e.clientX < box.left + headWidth.value + 8 && start.col >= 0) view.scrollLeft -= 16\n const hit = document.elementFromPoint(e.clientX, e.clientY)?.closest<HTMLElement>('[data-row]')\n if (!hit || !view.contains(hit)) return\n const at = { row: Number(hit.dataset.row), col: Number(hit.dataset.col) }\n if (!swept && at.row === start.row && at.col === start.col) return\n swept = true\n anchor.value = start\n if (active.value?.row !== at.row || active.value?.col !== at.col) active.value = at\n }\n const onUp = () => {\n window.removeEventListener('pointermove', onMove)\n window.removeEventListener('pointerup', onUp)\n window.removeEventListener('pointercancel', onUp)\n setTimeout(() => (swept = false))\n }\n window.addEventListener('pointermove', onMove)\n window.addEventListener('pointerup', onUp)\n window.addEventListener('pointercancel', onUp)\n}\n\nfunction onClick(event: MouseEvent) {\n const target = event.target as HTMLElement\n if (target.closest('[data-resize]') || justResized || swept) return\n const sortField = target.closest<HTMLElement>('[data-sort-field]')\n if (sortField) return sortByField(Number(sortField.dataset.sortField))\n const head = target.closest<HTMLElement>('[data-head]')\n if (head) {\n const cell = headCells.value.find((c) => c.key === head.dataset.head)\n if (!cell) return\n if (cell.toggle && (target.closest('[data-toggle]') || !cell.sortable))\n return toggleNode('columns', cell.span.node, event.altKey)\n if (cell.sortable) sortByColumn(cell.span)\n return\n }\n const el = target.closest<HTMLElement>('[data-row]')\n if (!el) return\n const row = Number(el.dataset.row)\n const col = Number(el.dataset.col)\n active.value = { row, col }\n anchor.value = null\n const line = allLines.value[row]\n if (!line) return\n if (col < 0) {\n const part = target.closest<HTMLElement>('[data-toggle-node]')\n if (part) toggleNode('rows', Number(part.dataset.toggleNode), event.altKey)\n else if (lineToggles(line)) toggleNode('rows', line.node, event.altKey)\n return\n }\n const column = colLines.value[col]\n if (column) emit('cellClick', cellEvent(line, column))\n}\n\nfunction ensureVisible(row: number, col: number) {\n const el = viewport.value\n if (!el) return\n const h = rowHeight.value\n if (row < bodyLines.value.length) {\n const space = viewHeight.value - headHeight.value - footHeight.value\n if (row * h < el.scrollTop) el.scrollTop = row * h\n else if ((row + 1) * h > el.scrollTop + space) el.scrollTop = (row + 1) * h - space\n }\n if (col >= 0) {\n const starts = colStarts.value\n const space = viewWidth.value - headWidth.value\n if (starts[col]! < el.scrollLeft) el.scrollLeft = starts[col]!\n else if (starts[col + 1]! > el.scrollLeft + space) el.scrollLeft = starts[col + 1]! - space\n }\n onScroll()\n}\n\n/** Moves the active cell; `extend` (Shift) grows the range from where it started instead. */\nfunction moveTo(row: number, col: number, extend = false) {\n const rows = allLines.value.length\n const cols = colLines.value.length\n if (rows === 0) return\n const next = {\n row: Math.max(0, Math.min(rows - 1, row)),\n col: Math.max(-1, Math.min(cols - 1, col)),\n }\n if (extend) anchor.value ??= active.value ?? next\n else anchor.value = null\n active.value = next\n ensureVisible(next.row, next.col)\n}\n\n/** The index of the first line of `node`'s parent above `row`. */\nfunction parentRow(row: number) {\n const lines = allLines.value\n const tree = rowTree.value\n const parent = tree.parent[lines[row]!.node]!\n if (parent <= 0) return row\n for (let i = row - 1; i >= 0; i--) {\n if (lines[i]!.node === parent) {\n let first = i\n while (first > 0 && lines[first - 1]!.node === parent) first--\n return first\n }\n }\n // in the tabular layout a group's own line is its total, after its children\n for (let i = row + 1; i < lines.length; i++) if (lines[i]!.node === parent) return i\n return row\n}\n\nfunction onKeydown(event: KeyboardEvent) {\n if (event.isComposing) return\n const a = active.value ?? { row: 0, col: -1 }\n const line = allLines.value[a.row]\n const isGroup = !!line && lineToggles(line)\n const open = isGroup && !rowCollapsed.value.has(line!.node)\n const page = Math.max(1, Math.floor((viewHeight.value - headHeight.value) / rowHeight.value) - 1)\n const mod = event.ctrlKey || event.metaKey\n const shift = event.shiftKey\n if (mod && !shift && !event.altKey && event.key.toLowerCase() === 'a') {\n selectAll()\n event.preventDefault()\n return\n }\n switch (event.key) {\n case 'ArrowDown':\n moveTo(a.row + 1, a.col, shift)\n break\n case 'ArrowUp':\n moveTo(a.row - 1, a.col, shift)\n break\n case 'ArrowRight':\n if (a.col < 0 && isGroup && !open && !shift) toggleNode('rows', line!.node)\n else moveTo(a.row, a.col + 1, shift)\n break\n case 'ArrowLeft':\n if (shift) moveTo(a.row, a.col - 1, true)\n else if (a.col < 0 && open) toggleNode('rows', line!.node)\n else if (a.col < 0 && line) moveTo(parentRow(a.row), -1)\n else moveTo(a.row, a.col - 1)\n break\n case 'PageDown':\n moveTo(a.row + page, a.col, shift)\n break\n case 'PageUp':\n moveTo(a.row - page, a.col, shift)\n break\n case 'Home':\n moveTo(mod ? 0 : a.row, -1, shift)\n break\n case 'End':\n moveTo(mod ? allLines.value.length - 1 : a.row, colLines.value.length - 1, shift)\n break\n case 'Escape':\n if (!anchor.value) return\n anchor.value = null\n break\n case 'Enter':\n case ' ':\n if (!line) return\n if (a.col < 0) {\n if (isGroup) toggleNode('rows', line.node, event.altKey)\n } else if (event.key === 'Enter') {\n const column = colLines.value[a.col]\n if (column) emit('cellClick', cellEvent(line, column))\n }\n break\n default:\n return\n }\n event.preventDefault()\n}\n\nfunction onFocus() {\n focused.value = true\n if (!active.value && allLines.value.length) active.value = { row: 0, col: -1 }\n}\n\n// The active cell's row and column by their groups, so that it stays on the\n// same groups when the data changes and lines come and go.\nconst rowKey = (line: PivotLine | undefined) =>\n line\n ? `${line.kind}|${line.value}|${line.kind === 'total' ? '' : JSON.stringify(result.value.rows.path(line.node))}`\n : ''\nlet activeKeys: { row: string; col: string } | null = null\nwatch(\n active,\n (a) => {\n activeKeys = a\n ? { row: rowKey(allLines.value[a.row]), col: a.col < 0 ? '' : colKey(colLines.value[a.col]!) }\n : null\n },\n { flush: 'sync' },\n)\nwatch([allLines, colLines], ([rows, cols]) => {\n const a = active.value\n const keys = activeKeys\n if (!a || !keys) return\n // most changes leave it where it was; look further only when they don't\n let row =\n rowKey(rows[a.row]) === keys.row ? a.row : rows.findIndex((line) => rowKey(line) === keys.row)\n if (row < 0) row = Math.min(a.row, rows.length - 1)\n let col = !keys.col\n ? -1\n : a.col >= 0 && cols[a.col] && colKey(cols[a.col]!) === keys.col\n ? a.col\n : cols.findIndex((line) => colKey(line) === keys.col)\n if (col < 0 && keys.col) col = Math.min(a.col, cols.length - 1)\n if (row < 0) active.value = null\n else if (row !== a.row || col !== a.col) {\n active.value = { row, col }\n anchor.value = null\n // it moved with its groups: bring it into view where the lines now put it\n nextTick(() => ensureVisible(row, col))\n }\n})\n\n// back to the top left when the layout changes\nwatch(engineConfig, () => {\n nextTick(() => {\n const el = viewport.value\n if (!el) return\n el.scrollTop = 0\n el.scrollLeft = 0\n onScroll()\n })\n})\n\n// ---- the table as cells: copying, CSV, a sheet, a chart and printing ----\n\n/** The column headers for export, which always name the values when they run along the columns. */\nconst exportSpans = computed(() =>\n headerSpans(\n result.value.columns,\n colLines.value,\n result.value.columns.levels.length === 0 || !valuesOnRows.value,\n ),\n)\n\ninterface GridRange {\n top: number\n bottom: number\n left: number\n right: number\n}\n\n/** A row's labels, one per row field: the group on its own line, as the table shows it. */\nfunction rowLabels(line: PivotLine, index: number, count: number) {\n const texts: string[] = Array.from({ length: count }, () => '')\n if (line.kind === 'total') texts[0] = messages.value.total\n else if (isFirstOfNode(line)) {\n const head = rowHead(line, index)\n if (tabular.value) head.parts.forEach((part, level) => part && (texts[level] = part.text))\n else texts[head.depth] = head.text\n }\n return texts\n}\n\n/**\n * Cells of the table: rows `top` to `bottom`, columns `left` to `right`\n * (`-1` takes the row headers), with the column headers when `heads`.\n */\nfunction gridOf(range: GridRange, heads: boolean): PivotGrid {\n const res = result.value\n const display = valueDisplay.value\n const levels = Math.max(1, res.rows.levels.length)\n const withLeft = range.left < 0\n const headCols = withLeft ? levels + (valuesOnRows.value ? 1 : 0) : 0\n const from = Math.max(0, range.left)\n const to = range.right\n const width = headCols + Math.max(0, to - from + 1)\n const cells: (PivotGridCell | null)[][] = []\n const headRowCount = heads ? res.columns.levels.length + 1 : 0\n for (let r = 0; r < headRowCount; r++)\n cells.push(Array.from({ length: width }, (): PivotGridCell => ({ text: '', kind: 'head' })))\n if (heads && withLeft) {\n columnFields.value.forEach((label, level) => (cells[level]![0] = { text: label, kind: 'head' }))\n const last = cells[headRowCount - 1]!\n for (const field of rowFields.value) last[field.index] = { text: field.label, kind: 'head' }\n if (valuesOnRows.value) last[levels] = { text: messages.value.values, kind: 'head' }\n }\n if (heads) {\n for (const list of exportSpans.value) {\n for (const span of list) {\n const a = Math.max(span.from, from)\n const b = Math.min(span.to - 1, to)\n if (a > b) continue\n const col = headCols + a - from\n const cell: PivotGridCell = { text: spanText(span), kind: 'head' }\n const line = colLines.value[span.from]!\n if (span.kind === 'total' || span.kind === 'subtotal' || line.kind !== 'group')\n cell.total = true\n if (b > a) cell.colSpan = b - a + 1\n if (span.rows > 1) cell.rowSpan = span.rows\n for (let r = span.level; r < span.level + span.rows; r++)\n for (let c = col; c <= col + b - a; c++) cells[r]![c] = null\n cells[span.level]![col] = cell\n }\n }\n }\n const lines = allLines.value\n for (let i = range.top; i <= range.bottom; i++) {\n const line = lines[i]\n if (!line) continue\n const out: (PivotGridCell | null)[] = []\n // a group's own line holds its total\n const lineTotal = line.kind !== 'group' || (line.node > 0 && res.rows.isGroup(line.node))\n const mark = lineTotal ? { total: true } : {}\n if (withLeft) {\n for (const text of rowLabels(line, i, levels)) out.push({ text, kind: 'label', ...mark })\n if (valuesOnRows.value)\n out.push({\n text: line.value >= 0 ? display[line.value]!.label : '',\n kind: 'label',\n ...mark,\n })\n }\n const blank = rowBlank(line)\n for (let c = from; c <= to; c++) {\n const col = colLines.value[c]!\n const v = line.value >= 0 ? line.value : Math.max(0, col.value)\n const n = blank ? Number.NaN : res.shown(line.node, col.node, v, display[v]!.show)\n const total =\n lineTotal || col.kind !== 'group' || (col.node > 0 && res.columns.isGroup(col.node))\n out.push({\n text: display[v]!.write(n),\n kind: 'value',\n value: n,\n valueIndex: v,\n ...(total ? { total: true } : {}),\n })\n }\n cells.push(out)\n }\n const widths: number[] = []\n if (withLeft) {\n const own = tabular.value\n ? levelWidth.value\n : levels === 1\n ? rowHeadWidth.value\n : Math.max(80, Math.round(props.rowHeaderWidth * 0.6))\n for (let l = 0; l < levels; l++) widths.push(own)\n if (valuesOnRows.value) widths.push(VALUE_HEADER_WIDTH)\n }\n const starts = colStarts.value\n for (let c = from; c <= to; c++) widths.push(starts[c + 1]! - starts[c]!)\n return {\n cells,\n headRows: headRowCount,\n headCols,\n formats: display.map((d) => d.sheetFormat),\n widths,\n }\n}\n\nconst wholeRange = (): GridRange => ({\n top: 0,\n bottom: allLines.value.length - 1,\n left: -1,\n right: colLines.value.length - 1,\n})\n\n/** The whole table, headers and all, as cells. */\nconst toGrid = () => gridOf(wholeRange(), true)\n\n/**\n * What copying takes: the selected range, or the active cell. A range over\n * the whole table brings the column headers along.\n */\nfunction copyGrid(all = false): PivotGrid | null {\n if (all) return toGrid()\n const range: GridRange | null =\n selection.value ??\n (active.value\n ? {\n top: active.value.row,\n bottom: active.value.row,\n left: active.value.col,\n right: active.value.col,\n }\n : null)\n if (!range) return null\n const whole = wholeRange()\n const isWhole =\n range.top === 0 &&\n range.bottom === whole.bottom &&\n range.left === -1 &&\n range.right === whole.right\n return gridOf(range, isWhole)\n}\n\n/** Selects every cell, the row headers too (Ctrl / ⌘ + A). */\nfunction selectAll() {\n const whole = wholeRange()\n if (whole.bottom < 0) return\n anchor.value = { row: 0, col: -1 }\n active.value = { row: whole.bottom, col: whole.right }\n}\n\n/** Copying puts tab-separated text and a table on the clipboard; pasted into DouSheet, numbers keep their formats. */\nfunction onCopy(event: ClipboardEvent) {\n const grid = copyGrid()\n if (!grid || !event.clipboardData) return\n event.preventDefault()\n event.clipboardData.setData('text/plain', gridToTSV(grid))\n event.clipboardData.setData('text/html', gridToHTML(grid, { sheet: true }))\n announceCopied(grid)\n}\n\nfunction announceCopied(grid: PivotGrid) {\n let cells = 0\n for (const row of grid.cells) for (const cell of row) if (cell) cells++\n announce(format(messages.value.copied, { cells }))\n}\n\nasync function copy(options: PivotCopyOptions = {}) {\n const grid = copyGrid(options.all)\n if (!grid) return\n const text = gridToTSV(grid)\n const html = gridToHTML(grid, { sheet: true })\n const clipboard = navigator.clipboard\n if (typeof ClipboardItem !== 'undefined' && clipboard?.write) {\n await clipboard.write([\n new ClipboardItem({\n 'text/plain': new Blob([text], { type: 'text/plain' }),\n 'text/html': new Blob([html], { type: 'text/html' }),\n }),\n ])\n } else await clipboard.writeText(text)\n announceCopied(grid)\n}\n\nconst toCSV = (options?: PivotCSVOptions) => gridToCSV(toGrid(), options)\n\nfunction toSheet(options: { name?: string } = {}): PivotSheetWorkbook {\n const grid = toGrid()\n const colSizes: Record<number, number> = {}\n grid.widths.forEach((w, i) => (colSizes[i] = w))\n return {\n version: 1,\n sheets: [\n {\n name: options.name ?? messages.value.pivot,\n cells: gridToSheetCells(grid),\n merges: gridMerges(grid),\n freeze: { rows: grid.headRows, cols: grid.headCols },\n colSizes,\n },\n ],\n styles: structuredClone(SHEET_STYLES),\n }\n}\n\n/**\n * Rows for a chart: a row per group on show that is not open (its children\n * would be the rows instead), a series per column the same way; the\n * subtotals and totals are left out.\n */\nfunction toChartRows(options: PivotChartRowsOptions = {}): PivotChartRows {\n const res = result.value\n const display = valueDisplay.value\n const { valueIndex } = options\n const many = display.length > 1\n const series = colLines.value\n .map((line, col) => ({ line, col }))\n .filter(({ line }) =>\n res.columns.levels.length === 0 ? line.kind === 'total' : line.kind === 'group',\n )\n .filter(({ line }) => valueIndex === undefined || line.value < 0 || line.value === valueIndex)\n .map(({ line }, i) => {\n const v = line.value >= 0 ? line.value : (valueIndex ?? 0)\n const group = line.kind === 'total' ? '' : pathText(res.columns, colWriters.value, line.node)\n const value = display[v]!.label\n const name = group ? (many && !valuesOnRows.value ? join([group, value]) : group) : value\n return { key: `s${i}`, name, valueIndex: v, node: line.node, format: display[v]!.write }\n })\n const lines = res.rows.levels.length === 0 ? footLines.value : bodyLines.value\n const rows: Record<string, string | number | null>[] = []\n for (const line of lines) {\n if (res.rows.levels.length > 0) {\n if (line.kind !== 'group') continue\n if (res.rows.isGroup(line.node) && !rowCollapsed.value.has(line.node)) continue\n }\n if (valuesOnRows.value && line.value !== (valueIndex ?? 0)) continue\n const row: Record<string, string | number | null> = {\n label:\n line.kind === 'total'\n ? messages.value.total\n : pathText(res.rows, rowWriters.value, line.node),\n }\n for (const s of series) {\n const v = valuesOnRows.value ? line.value : s.valueIndex\n const n = res.shown(line.node, s.node, v, display[v]!.show)\n row[s.key] = n === n ? n : null\n }\n rows.push(row)\n }\n return {\n x: 'label',\n rows,\n series: series.map(({ key, name, valueIndex: v, format: write }) => {\n const at = valuesOnRows.value ? (valueIndex ?? 0) : v\n return { key, name, valueIndex: at, format: valuesOnRows.value ? display[at]!.write : write }\n }),\n }\n}\n\nconst join = (texts: string[]) => texts.join(' · ')\n/** A group's labels from the outermost in, as one text. */\nfunction pathTexts(tree: PivotTree, writers: ((v: unknown) => string)[], node: number) {\n const texts: string[] = []\n for (let at = node; at > 0; at = tree.parent[at]!) texts.unshift(nodeText(tree, writers, at))\n return texts\n}\nconst pathText = (tree: PivotTree, writers: ((v: unknown) => string)[], node: number) =>\n join(pathTexts(tree, writers, node))\nconst escape = (text: string) =>\n text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')\n\n/** A4 less 10mm margins, in px at 96 per inch. */\nconst PAGE_WIDTH = { portrait: 718, landscape: 1047 }\n\nfunction print(options: PivotPrintOptions = {}) {\n const grid = toGrid()\n const orientation =\n options.orientation ??\n (grid.widths.reduce((a, b) => a + b, 0) > PAGE_WIDTH.portrait ? 'landscape' : 'portrait')\n const title = options.title ?? ariaLabel.value\n const iframe = document.createElement('iframe')\n iframe.setAttribute('aria-hidden', 'true')\n iframe.style.cssText = 'position:fixed;width:0;height:0;border:0;visibility:hidden'\n document.body.append(iframe)\n const doc = iframe.contentDocument!\n const font = root.value ? getComputedStyle(root.value).fontFamily : 'sans-serif'\n doc.open()\n doc.write(\n `<!doctype html><html><head><meta charset=\"utf-8\"><title>${escape(title || document.title)}</title><style>` +\n `@page{size:A4 ${orientation};margin:10mm}` +\n `html,body{margin:0;color:#1f1f1f;font:10px/1.4 ${font};font-variant-numeric:tabular-nums}` +\n `h1{margin:0 0 8px;font-size:13px;font-weight:600}` +\n `table{border-collapse:collapse;width:100%}` +\n `thead{display:table-header-group}tr{break-inside:avoid}` +\n `th,td{border:1px solid #d6d6d6;padding:3px 6px;white-space:nowrap}` +\n `th{background:#f5f5f5;font-weight:500;text-align:left}` +\n `tbody th{background:none}.is-total{font-weight:600}` +\n `thead th.is-end{text-align:right}` +\n `*{print-color-adjust:exact;-webkit-print-color-adjust:exact}` +\n `</style></head><body>${title ? `<h1>${escape(title)}</h1>` : ''}${gridToHTML(grid)}</body></html>`,\n )\n doc.close()\n const win = iframe.contentWindow!\n const done = () => iframe.remove()\n win.addEventListener('afterprint', done)\n // browsers that print without a dialog, or never send afterprint\n setTimeout(done, 60_000)\n win.focus()\n win.print()\n}\n\nconst ariaLabel = computed(() => props.ariaLabel ?? messages.value.pivot)\nconst rootStyle = computed(() => {\n const height = typeof props.maxHeight === 'number' ? `${props.maxHeight}px` : props.maxHeight\n // a short table ends with its last row instead of leaving blank space under it\n const content = headHeight.value + bodyHeight.value + footHeight.value + gutter.value + 2\n return `height:min(${height}, ${content}px);--dou-pivot-row-height:${rowHeight.value}px;--dou-pivot-head-width:${headWidth.value}px`\n})\n\ndefineExpose<PivotExpose>({\n get result() {\n return result.value\n },\n expandAll,\n collapseAll,\n toggle,\n toGrid,\n toCSV,\n toSheet,\n toChartRows,\n copy,\n print,\n selectAll,\n})\n</script>\n\n<template>\n <div\n ref=\"root\"\n class=\"dou-pivot\"\n :class=\"[\n `dou-pivot--${size}`,\n {\n 'is-focused': focused,\n 'is-scrolled-x': scroll.left > 0,\n 'is-scrolled-y': scroll.top > 0,\n 'has-values-column': valuesOnRows,\n 'is-tabular': tabular,\n 'is-busy': busy,\n },\n ]\"\n :style=\"rootStyle\"\n >\n <div\n ref=\"viewport\"\n class=\"dou-pivot__viewport\"\n role=\"treegrid\"\n tabindex=\"0\"\n :aria-label=\"ariaLabel\"\n :aria-rowcount=\"headRows + allLines.length\"\n :aria-colcount=\"colLines.length + (valuesOnRows ? 2 : 1)\"\n aria-multiselectable=\"true\"\n :aria-activedescendant=\"activeId\"\n :aria-busy=\"busy || undefined\"\n @scroll=\"onScroll\"\n @pointerdown=\"onPointerDown\"\n @click=\"onClick\"\n @copy=\"onCopy\"\n @keydown=\"onKeydown\"\n @focus=\"onFocus\"\n @blur=\"focused = false\"\n >\n <div class=\"dou-pivot__sheet\" :style=\"`width:${totalWidth}px`\">\n <div class=\"dou-pivot__head\" role=\"rowgroup\" :style=\"`height:${headHeight}px`\">\n <div class=\"dou-pivot__corner\" :style=\"`width:${headWidth}px;height:${headHeight}px`\">\n <div\n v-for=\"(label, level) in columnFields\"\n :key=\"`c${level}`\"\n class=\"dou-pivot__corner-line is-column\"\n :style=\"`top:${level * rowHeight}px`\"\n >\n <span class=\"dou-pivot__corner-label\">{{ label }}</span>\n </div>\n <div\n class=\"dou-pivot__corner-line is-rows\"\n :style=\"`top:${(headRows - 1) * rowHeight}px`\"\n >\n <template v-for=\"field in rowFields\" :key=\"field.index\">\n <span\n v-if=\"field.index > 0 && !tabular\"\n class=\"dou-pivot__corner-sep\"\n aria-hidden=\"true\"\n >/</span\n >\n <span\n :class=\"\n tabular\n ? [\n 'dou-pivot__corner-field',\n { 'is-last': field.index === rowFields.length - 1 },\n ]\n : 'dou-pivot__corner-inline'\n \"\n :style=\"tabular ? `width:${levelWidth}px` : undefined\"\n >\n <button\n type=\"button\"\n class=\"dou-pivot__field\"\n :class=\"{ 'is-sorted': !config.sort && field.sort === 'desc' }\"\n tabindex=\"-1\"\n :data-sort-field=\"field.index\"\n :aria-label=\"format(messages.sortField, { field: field.label })\"\n >\n {{ field.label }}\n <ArrowDownIcon\n v-if=\"!config.sort && field.sort === 'desc'\"\n aria-hidden=\"true\"\n />\n </button>\n </span>\n </template>\n <span v-if=\"valuesOnRows\" class=\"dou-pivot__corner-values\">{{\n messages.values\n }}</span>\n <span v-else-if=\"!valueRow\" class=\"dou-pivot__corner-values\">{{\n valueDisplay[0]!.label\n }}</span>\n </div>\n <span\n v-if=\"resizable\"\n class=\"dou-pivot__resize\"\n data-resize=\"-1\"\n role=\"separator\"\n aria-orientation=\"vertical\"\n :aria-label=\"format(messages.columnWidth, { column: rowFieldNames })\"\n :aria-valuenow=\"widthOf(-1)\"\n :aria-valuemin=\"minWidthOf(-1)\"\n :aria-valuemax=\"maxWidthOf(-1)\"\n :tabindex=\"resizeStop === -1 ? 0 : -1\"\n :title=\"messages.resizeColumn\"\n @pointerdown=\"onResizeDown\"\n @dblclick=\"onResizeFit\"\n @keydown=\"onResizeKeydown\"\n />\n </div>\n <div\n class=\"dou-pivot__col-heads\"\n :style=\"`width:${columnsWidth}px;height:${headHeight}px`\"\n >\n <div\n v-for=\"cell in headCells\"\n :key=\"cell.key\"\n :class=\"cell.cls\"\n role=\"columnheader\"\n :aria-sort=\"\n cell.sorted ? (cell.sorted === 'asc' ? 'ascending' : 'descending') : undefined\n \"\n :aria-expanded=\"cell.toggle ? cell.open : undefined\"\n :data-head=\"cell.key\"\n :title=\"cell.sortable ? messages.sortValue : undefined\"\n :style=\"cell.style\"\n >\n <span class=\"dou-pivot__col-label\">\n <span\n v-if=\"cell.toggle\"\n class=\"dou-pivot__toggle\"\n :class=\"{ 'is-open': cell.open }\"\n data-toggle\n :aria-label=\"cell.open ? messages.collapse : messages.expand\"\n >\n <ChevronRightIcon aria-hidden=\"true\" />\n </span>\n <span class=\"dou-pivot__text\">{{ cell.text }}</span>\n <ArrowUpIcon\n v-if=\"cell.sorted === 'asc'\"\n class=\"dou-pivot__sort\"\n aria-hidden=\"true\"\n />\n <ArrowDownIcon\n v-else-if=\"cell.sorted === 'desc'\"\n class=\"dou-pivot__sort\"\n aria-hidden=\"true\"\n />\n </span>\n <span\n v-if=\"cell.resize >= 0\"\n class=\"dou-pivot__resize\"\n :data-resize=\"cell.resize\"\n role=\"separator\"\n aria-orientation=\"vertical\"\n :aria-label=\"format(messages.columnWidth, { column: cell.text })\"\n :aria-valuenow=\"widthOf(cell.resize)\"\n :aria-valuemin=\"MIN_COLUMN\"\n :aria-valuemax=\"MAX_COLUMN\"\n :tabindex=\"resizeStop === cell.resize ? 0 : -1\"\n :title=\"messages.resizeColumn\"\n @pointerdown=\"onResizeDown\"\n @dblclick=\"onResizeFit\"\n @keydown=\"onResizeKeydown\"\n />\n </div>\n </div>\n </div>\n\n <div class=\"dou-pivot__body\" role=\"rowgroup\" :style=\"`height:${bodyHeight}px`\">\n <div\n v-for=\"{ line, index } in visibleRows\"\n :key=\"index\"\n v-memo=\"[\n line,\n colWindow,\n colLines,\n colStarts,\n rowHeadWidth,\n tabular,\n valueDisplay,\n rowWriters,\n active?.row === index ? active.col : -9,\n selectedIn(index),\n highlightCol,\n line.node === highlightRow,\n ]\"\n class=\"dou-pivot__row\"\n :class=\"{\n 'is-group': lineToggles(line),\n 'is-subtotal': line.kind === 'subtotal',\n 'is-node-start': line.value <= 0,\n 'is-highlighted': line.node === highlightRow,\n }\"\n role=\"row\"\n :aria-rowindex=\"headRows + index + 1\"\n :aria-level=\"rowTree.depth[line.node]\"\n :aria-expanded=\"lineToggles(line) ? !rowCollapsed.has(line.node) : undefined\"\n :style=\"`top:${index * rowHeight}px;width:${totalWidth}px`\"\n >\n <template v-for=\"head in [rowHead(line, index)]\" :key=\"0\">\n <div\n :id=\"cellId(index, -1)\"\n class=\"dou-pivot__row-head\"\n :class=\"{\n 'is-active': active?.row === index && active.col === -1,\n 'is-clickable': head.group,\n 'is-selected': selectedIn(index).startsWith('-1:'),\n }\"\n role=\"rowheader\"\n :aria-selected=\"selectedIn(index).startsWith('-1:') || undefined\"\n :data-row=\"index\"\n data-col=\"-1\"\n :style=\"\n tabular\n ? `width:${rowHeadWidth}px;padding-left:0`\n : `width:${rowHeadWidth}px;padding-left:${12 + head.depth * 16}px`\n \"\n >\n <template v-if=\"tabular\">\n <span\n v-for=\"(part, level) in head.parts\"\n :key=\"level\"\n class=\"dou-pivot__level\"\n :style=\"`width:${levelWidth}px`\"\n >\n <template v-if=\"part\">\n <span\n v-if=\"part.toggle > 0\"\n class=\"dou-pivot__toggle\"\n :class=\"{ 'is-open': part.open }\"\n :data-toggle-node=\"part.toggle\"\n aria-hidden=\"true\"\n >\n <ChevronRightIcon />\n </span>\n <span\n v-else-if=\"level < head.parts.length - 1\"\n class=\"dou-pivot__toggle-space\"\n />\n <span class=\"dou-pivot__text\">{{ part.text }}</span>\n </template>\n </span>\n </template>\n <template v-else-if=\"head.firstOfNode\">\n <span\n v-if=\"head.group\"\n class=\"dou-pivot__toggle\"\n :class=\"{ 'is-open': head.open }\"\n aria-hidden=\"true\"\n >\n <ChevronRightIcon />\n </span>\n <span v-else-if=\"rowTree.levels.length > 1\" class=\"dou-pivot__toggle-space\" />\n <span class=\"dou-pivot__text\">{{ head.text }}</span>\n </template>\n </div>\n <div\n v-if=\"valuesOnRows\"\n class=\"dou-pivot__value-head\"\n role=\"rowheader\"\n :style=\"`width:${VALUE_HEADER_WIDTH}px;left:${rowHeadWidth}px`\"\n >\n <span class=\"dou-pivot__text\">{{ head.valueText }}</span>\n </div>\n </template>\n <div class=\"dou-pivot__spacer\" :style=\"`width:${colStarts[colWindow.from]}px`\" />\n <div\n v-for=\"cell in rowCells(line, index)\"\n :id=\"cellId(index, cell.col)\"\n :key=\"cell.key\"\n :class=\"[cell.cls, { 'is-active': active?.row === index && active.col === cell.col }]\"\n role=\"gridcell\"\n :aria-selected=\"cell.selected || undefined\"\n :aria-colindex=\"cell.col + (valuesOnRows ? 3 : 2)\"\n :data-row=\"index\"\n :data-col=\"cell.col\"\n :style=\"cell.style\"\n >\n <span class=\"dou-pivot__text\">{{ cell.text }}</span>\n </div>\n </div>\n </div>\n\n <div\n v-if=\"footLines.length\"\n class=\"dou-pivot__foot\"\n role=\"rowgroup\"\n :style=\"`height:${footHeight}px`\"\n >\n <div\n v-for=\"({ line, index }, i) in visibleFoot\"\n :key=\"index\"\n class=\"dou-pivot__row is-total\"\n role=\"row\"\n :aria-rowindex=\"headRows + index + 1\"\n :style=\"`top:${i * rowHeight}px;width:${totalWidth}px`\"\n >\n <div\n :id=\"cellId(index, -1)\"\n class=\"dou-pivot__row-head\"\n :class=\"{\n 'is-active': active?.row === index && active.col === -1,\n 'is-selected': selectedIn(index).startsWith('-1:'),\n }\"\n role=\"rowheader\"\n :aria-selected=\"selectedIn(index).startsWith('-1:') || undefined\"\n :data-row=\"index\"\n data-col=\"-1\"\n :style=\"`width:${rowHeadWidth}px`\"\n >\n <span v-if=\"line.value <= 0\" class=\"dou-pivot__text\">{{ messages.total }}</span>\n </div>\n <div\n v-if=\"valuesOnRows\"\n class=\"dou-pivot__value-head\"\n role=\"rowheader\"\n :style=\"`width:${VALUE_HEADER_WIDTH}px;left:${rowHeadWidth}px`\"\n >\n <span class=\"dou-pivot__text\">{{ valueDisplay[line.value]?.label }}</span>\n </div>\n <div class=\"dou-pivot__spacer\" :style=\"`width:${colStarts[colWindow.from]}px`\" />\n <div\n v-for=\"cell in rowCells(line, index)\"\n :id=\"cellId(index, cell.col)\"\n :key=\"cell.key\"\n :class=\"[cell.cls, { 'is-active': active?.row === index && active.col === cell.col }]\"\n role=\"gridcell\"\n :aria-selected=\"cell.selected || undefined\"\n :aria-colindex=\"cell.col + (valuesOnRows ? 3 : 2)\"\n :data-row=\"index\"\n :data-col=\"cell.col\"\n :style=\"cell.style\"\n >\n <span class=\"dou-pivot__text\">{{ cell.text }}</span>\n </div>\n </div>\n </div>\n </div>\n <span ref=\"measurer\" class=\"dou-pivot__measure\" aria-hidden=\"true\" />\n <div\n v-if=\"result.matched === 0 && !busy && result !== shapeOnly\"\n class=\"dou-pivot__empty\"\n role=\"status\"\n >\n {{ messages.noData }}\n </div>\n </div>\n <span class=\"dou-pivot__live\" role=\"status\" aria-live=\"polite\">{{ announcement }}</span>\n <!-- the last result stays under a veil while the next one is summed up -->\n <div v-if=\"busy\" class=\"dou-pivot__veil\" role=\"status\">\n <span class=\"dou-pivot__busy\">{{ messages.busy }}</span>\n </div>\n </div>\n</template>\n"],"mappings":""}