@adminforth/dashboard 1.0.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 (107) hide show
  1. package/.woodpecker/buildRelease.sh +13 -0
  2. package/.woodpecker/buildSlackNotify.sh +46 -0
  3. package/.woodpecker/release.yml +57 -0
  4. package/README.md +59 -0
  5. package/custom/api/dashboardApi.ts +213 -0
  6. package/custom/composables/useElementSize.ts +41 -0
  7. package/custom/model/dashboard.types.ts +73 -0
  8. package/custom/package.json +9 -0
  9. package/custom/pnpm-lock.yaml +24 -0
  10. package/custom/queries/useDashboardConfig.ts +51 -0
  11. package/custom/queries/useWidgetData.ts +51 -0
  12. package/custom/runtime/DashboardGroup.vue +185 -0
  13. package/custom/runtime/DashboardPage.vue +122 -0
  14. package/custom/runtime/DashboardRuntime.vue +435 -0
  15. package/custom/runtime/WidgetRenderer.vue +60 -0
  16. package/custom/runtime/WidgetShell.vue +152 -0
  17. package/custom/skills/adminforth-dashboard/SKILL.md +125 -0
  18. package/custom/widgets/chart/ChartWidget.vue +188 -0
  19. package/custom/widgets/chart/bar/BarChart.vue +167 -0
  20. package/custom/widgets/chart/chart.types.ts +34 -0
  21. package/custom/widgets/chart/chart.utils.ts +54 -0
  22. package/custom/widgets/chart/funnel/FunnelChart.vue +197 -0
  23. package/custom/widgets/chart/histogram/HistogramChart.vue +21 -0
  24. package/custom/widgets/chart/line/LineChart.vue +175 -0
  25. package/custom/widgets/chart/pie/PieChart.vue +161 -0
  26. package/custom/widgets/chart/stacked-bar/StackedBarChart.vue +256 -0
  27. package/custom/widgets/gauge-card/GaugeCardWidget.vue +107 -0
  28. package/custom/widgets/kpi-card/KpiCardWidget.vue +73 -0
  29. package/custom/widgets/pivot-table/PivotTableWidget.vue +122 -0
  30. package/custom/widgets/registry.ts +51 -0
  31. package/custom/widgets/table/TableWidget.vue +110 -0
  32. package/dist/custom/api/dashboardApi.d.ts +32 -0
  33. package/dist/custom/api/dashboardApi.js +179 -0
  34. package/dist/custom/api/dashboardApi.ts +213 -0
  35. package/dist/custom/composables/useElementSize.d.ts +8 -0
  36. package/dist/custom/composables/useElementSize.js +30 -0
  37. package/dist/custom/composables/useElementSize.ts +41 -0
  38. package/dist/custom/model/dashboard.types.d.ts +45 -0
  39. package/dist/custom/model/dashboard.types.js +14 -0
  40. package/dist/custom/model/dashboard.types.ts +73 -0
  41. package/dist/custom/package.json +9 -0
  42. package/dist/custom/pnpm-lock.yaml +24 -0
  43. package/dist/custom/queries/useDashboardConfig.d.ts +112 -0
  44. package/dist/custom/queries/useDashboardConfig.js +57 -0
  45. package/dist/custom/queries/useDashboardConfig.ts +51 -0
  46. package/dist/custom/queries/useWidgetData.d.ts +90 -0
  47. package/dist/custom/queries/useWidgetData.js +57 -0
  48. package/dist/custom/queries/useWidgetData.ts +51 -0
  49. package/dist/custom/runtime/DashboardGroup.vue +185 -0
  50. package/dist/custom/runtime/DashboardPage.vue +122 -0
  51. package/dist/custom/runtime/DashboardRuntime.vue +435 -0
  52. package/dist/custom/runtime/WidgetRenderer.vue +60 -0
  53. package/dist/custom/runtime/WidgetShell.vue +152 -0
  54. package/dist/custom/skills/adminforth-dashboard/SKILL.md +125 -0
  55. package/dist/custom/widgets/chart/ChartWidget.vue +188 -0
  56. package/dist/custom/widgets/chart/bar/BarChart.vue +167 -0
  57. package/dist/custom/widgets/chart/chart.types.d.ts +25 -0
  58. package/dist/custom/widgets/chart/chart.types.js +2 -0
  59. package/dist/custom/widgets/chart/chart.types.ts +34 -0
  60. package/dist/custom/widgets/chart/chart.utils.d.ts +5 -0
  61. package/dist/custom/widgets/chart/chart.utils.js +52 -0
  62. package/dist/custom/widgets/chart/chart.utils.ts +54 -0
  63. package/dist/custom/widgets/chart/funnel/FunnelChart.vue +197 -0
  64. package/dist/custom/widgets/chart/histogram/HistogramChart.vue +21 -0
  65. package/dist/custom/widgets/chart/line/LineChart.vue +175 -0
  66. package/dist/custom/widgets/chart/pie/PieChart.vue +161 -0
  67. package/dist/custom/widgets/chart/stacked-bar/StackedBarChart.vue +256 -0
  68. package/dist/custom/widgets/gauge-card/GaugeCardWidget.vue +107 -0
  69. package/dist/custom/widgets/kpi-card/KpiCardWidget.vue +73 -0
  70. package/dist/custom/widgets/pivot-table/PivotTableWidget.vue +122 -0
  71. package/dist/custom/widgets/registry.d.ts +11 -0
  72. package/dist/custom/widgets/registry.js +47 -0
  73. package/dist/custom/widgets/registry.ts +51 -0
  74. package/dist/custom/widgets/table/TableWidget.vue +110 -0
  75. package/dist/endpoint/dashboard.d.ts +7 -0
  76. package/dist/endpoint/dashboard.js +29 -0
  77. package/dist/endpoint/groups.d.ts +30 -0
  78. package/dist/endpoint/groups.js +131 -0
  79. package/dist/endpoint/widgets.d.ts +15 -0
  80. package/dist/endpoint/widgets.js +182 -0
  81. package/dist/index.d.ts +13 -0
  82. package/dist/index.js +124 -0
  83. package/dist/schema/api.d.ts +1205 -0
  84. package/dist/schema/api.js +84 -0
  85. package/dist/schema/widget.d.ts +514 -0
  86. package/dist/schema/widget.js +133 -0
  87. package/dist/services/dashboardConfigService.d.ts +35 -0
  88. package/dist/services/dashboardConfigService.js +79 -0
  89. package/dist/services/widgetConfigValidator.d.ts +8 -0
  90. package/dist/services/widgetConfigValidator.js +65 -0
  91. package/dist/services/widgetDataService.d.ts +20 -0
  92. package/dist/services/widgetDataService.js +32 -0
  93. package/dist/types.d.ts +8 -0
  94. package/dist/types.js +1 -0
  95. package/endpoint/dashboard.ts +32 -0
  96. package/endpoint/groups.ts +213 -0
  97. package/endpoint/widgets.ts +255 -0
  98. package/index.ts +141 -0
  99. package/package.json +64 -0
  100. package/schema/api.ts +99 -0
  101. package/schema/widget.ts +159 -0
  102. package/services/dashboardConfigService.ts +136 -0
  103. package/services/widgetConfigValidator.ts +93 -0
  104. package/services/widgetDataService.ts +57 -0
  105. package/shims-vue.d.ts +5 -0
  106. package/tsconfig.json +18 -0
  107. package/types.ts +8 -0
@@ -0,0 +1,197 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import { useElementSize } from '../../../composables/useElementSize.js'
4
+ import {
5
+ CHART_COLORS,
6
+ formatChartAxisLabel,
7
+ formatChartLabel,
8
+ formatChartValue,
9
+ toFiniteNumber,
10
+ } from '../chart.utils.js'
11
+
12
+ const props = withDefaults(defineProps<{
13
+ rows: Record<string, unknown>[]
14
+ labelField: string
15
+ valueField: string
16
+ colors?: string[]
17
+ height?: number
18
+ }>(), {
19
+ height: 260,
20
+ })
21
+
22
+ const { el: rootEl, width: rootWidth } = useElementSize<HTMLDivElement>()
23
+ const { el: svgEl, width: svgWidth, height: svgHeight } = useElementSize<HTMLDivElement>()
24
+
25
+ const chartWidth = computed(() => Math.max(svgWidth.value, 1))
26
+ const chartHeight = computed(() => {
27
+ if (svgHeight.value > 0) {
28
+ return Math.max(svgHeight.value, 1)
29
+ }
30
+
31
+ return Math.max(props.height, 96)
32
+ })
33
+ const isCompact = computed(() => rootWidth.value > 0 && rootWidth.value < 420)
34
+
35
+ const shouldCountRows = computed(() => /(^|_)id$/i.test(props.valueField))
36
+
37
+ const funnelRows = computed(() => {
38
+ const groupedRows = new Map<string, { label: string, value: number }>()
39
+
40
+ for (const row of props.rows) {
41
+ const label = formatChartLabel(row[props.labelField])
42
+ const item = groupedRows.get(label) ?? { label, value: 0 }
43
+
44
+ item.value += shouldCountRows.value
45
+ ? 1
46
+ : toFiniteNumber(row[props.valueField])
47
+
48
+ groupedRows.set(label, item)
49
+ }
50
+
51
+ return Array.from(groupedRows.values())
52
+ .filter((row) => row.value > 0)
53
+ .sort((left, right) => right.value - left.value)
54
+ })
55
+
56
+ const maxValue = computed(() => {
57
+ return Math.max(...funnelRows.value.map((row) => row.value), 1)
58
+ })
59
+
60
+ const totalValue = computed(() => {
61
+ return funnelRows.value.reduce((sum, row) => sum + row.value, 0)
62
+ })
63
+
64
+ const segmentGap = computed(() => {
65
+ return Math.max(Math.min(Math.floor(chartHeight.value * 0.03), 10), 4)
66
+ })
67
+
68
+ const segmentHeight = computed(() => {
69
+ const count = Math.max(funnelRows.value.length, 1)
70
+
71
+ return Math.max((chartHeight.value - (count - 1) * segmentGap.value) / count, 1)
72
+ })
73
+
74
+ const minSegmentWidth = computed(() => {
75
+ return Math.max(Math.floor(chartWidth.value * 0.14), 36)
76
+ })
77
+
78
+ const segments = computed(() => funnelRows.value.map((row, index) => {
79
+ const nextRow = funnelRows.value[index + 1]
80
+
81
+ const topWidth = Math.max(
82
+ (row.value / maxValue.value) * chartWidth.value,
83
+ minSegmentWidth.value,
84
+ )
85
+
86
+ const bottomWidth = Math.max(
87
+ nextRow ? (nextRow.value / maxValue.value) * chartWidth.value : topWidth * 0.5,
88
+ minSegmentWidth.value,
89
+ )
90
+
91
+ const yTop = index * (segmentHeight.value + segmentGap.value)
92
+ const yBottom = yTop + segmentHeight.value
93
+
94
+ const xTop = (chartWidth.value - topWidth) / 2
95
+ const xBottom = (chartWidth.value - bottomWidth) / 2
96
+
97
+ const percent = totalValue.value > 0
98
+ ? (row.value / totalValue.value) * 100
99
+ : 0
100
+
101
+ return {
102
+ id: `${row.label}-${index}`,
103
+ label: row.label,
104
+ shortLabel: formatChartAxisLabel(row.label, 22),
105
+ value: row.value,
106
+ percentLabel: `${new Intl.NumberFormat(undefined, {
107
+ maximumFractionDigits: 1,
108
+ }).format(percent)}%`,
109
+ color: props.colors?.[index] || CHART_COLORS[index % CHART_COLORS.length],
110
+ path: [
111
+ `M ${xTop} ${yTop}`,
112
+ `L ${xTop + topWidth} ${yTop}`,
113
+ `L ${xBottom + bottomWidth} ${yBottom}`,
114
+ `L ${xBottom} ${yBottom}`,
115
+ 'Z',
116
+ ].join(' '),
117
+ centerY: yTop + segmentHeight.value / 2,
118
+ labelVisible: segmentHeight.value >= 24 && Math.min(topWidth, bottomWidth) >= Math.max(chartWidth.value * 0.22, 96),
119
+ }
120
+ }))
121
+ </script>
122
+
123
+ <template>
124
+ <div
125
+ ref="rootEl"
126
+ class="grid h-full min-h-0 w-full gap-4 overflow-hidden"
127
+ :class="isCompact ? 'grid-rows-[minmax(0,1fr)_auto]' : 'grid-cols-[minmax(0,1fr)_200px]'"
128
+ >
129
+ <div
130
+ ref="svgEl"
131
+ class="min-h-0 w-full overflow-hidden"
132
+ >
133
+ <svg
134
+ v-if="chartWidth > 0 && chartHeight > 0"
135
+ class="block h-full w-full"
136
+ :viewBox="`0 0 ${chartWidth} ${chartHeight}`"
137
+ role="img"
138
+ :aria-label="valueField"
139
+ >
140
+ <path
141
+ v-for="segment in segments"
142
+ :key="segment.id"
143
+ :d="segment.path"
144
+ :fill="segment.color"
145
+ fill-opacity="0.9"
146
+ >
147
+ <title>
148
+ {{ segment.label }}: {{ formatChartValue(segment.value) }} ({{ segment.percentLabel }})
149
+ </title>
150
+ </path>
151
+
152
+ <text
153
+ v-for="segment in segments"
154
+ v-show="segment.labelVisible"
155
+ :key="`value-${segment.id}`"
156
+ :x="chartWidth / 2"
157
+ :y="segment.centerY + 4"
158
+ fill="#ffffff"
159
+ font-size="12"
160
+ font-weight="600"
161
+ text-anchor="middle"
162
+ >
163
+ {{ formatChartValue(segment.value) }}
164
+ </text>
165
+ </svg>
166
+ </div>
167
+
168
+ <div class="grid min-w-0 gap-2 text-sm">
169
+ <div
170
+ v-for="segment in segments"
171
+ :key="`legend-${segment.id}`"
172
+ class="grid min-h-[34px] min-w-0 grid-cols-[1fr_auto] items-center gap-3"
173
+ >
174
+ <div class="flex min-w-0 items-center gap-2">
175
+ <span
176
+ class="h-2.5 w-2.5 shrink-0 rounded-full"
177
+ :style="{ backgroundColor: segment.color }"
178
+ />
179
+
180
+ <span class="truncate text-lightNavbarText dark:text-darkNavbarText">
181
+ {{ segment.shortLabel }}
182
+ </span>
183
+ </div>
184
+
185
+ <div class="text-right">
186
+ <div class="font-semibold text-lightNavbarText dark:text-darkNavbarText">
187
+ {{ formatChartValue(segment.value) }}
188
+ </div>
189
+
190
+ <div class="text-xs text-lightListTableText dark:text-darkListTableText">
191
+ {{ segment.percentLabel }}
192
+ </div>
193
+ </div>
194
+ </div>
195
+ </div>
196
+ </div>
197
+ </template>
@@ -0,0 +1,21 @@
1
+ <script setup lang="ts">
2
+ import BarChart from '../bar/BarChart.vue'
3
+
4
+ defineProps<{
5
+ rows: Record<string, unknown>[]
6
+ labelField: string
7
+ valueField: string
8
+ color?: string
9
+ height?: number
10
+ }>()
11
+ </script>
12
+
13
+ <template>
14
+ <BarChart
15
+ :rows="rows"
16
+ :label-field="labelField"
17
+ :value-field="valueField"
18
+ :color="color"
19
+ :height="height"
20
+ />
21
+ </template>
@@ -0,0 +1,175 @@
1
+ <template>
2
+ <div
3
+ ref="rootEl"
4
+ class="h-full min-h-0 w-full overflow-hidden"
5
+ >
6
+ <svg
7
+ v-if="chartWidth > 0 && chartHeight > 0"
8
+ class="block h-full w-full"
9
+ :viewBox="`0 0 ${chartWidth} ${chartHeight}`"
10
+ role="img"
11
+ :aria-label="seriesName || yField"
12
+ >
13
+ <g class="text-lightListTableText dark:text-darkListTableText">
14
+ <line
15
+ v-for="tick in yTicks"
16
+ :key="tick.y"
17
+ :x1="padding.left"
18
+ :x2="chartWidth - padding.right"
19
+ :y1="tick.y"
20
+ :y2="tick.y"
21
+ stroke="currentColor"
22
+ stroke-opacity="0.14"
23
+ />
24
+ <text
25
+ v-for="tick in yTicks"
26
+ :key="`label-${tick.y}`"
27
+ :x="padding.left - 8"
28
+ :y="tick.y + 4"
29
+ fill="currentColor"
30
+ font-size="11"
31
+ text-anchor="end"
32
+ >
33
+ {{ formatChartValue(tick.value) }}
34
+ </text>
35
+ </g>
36
+
37
+ <path
38
+ v-if="fillPath"
39
+ :d="fillPath"
40
+ :fill="chartColor"
41
+ fill-opacity="0.12"
42
+ />
43
+ <path
44
+ v-if="linePath"
45
+ :d="linePath"
46
+ fill="none"
47
+ :stroke="chartColor"
48
+ stroke-linecap="round"
49
+ stroke-linejoin="round"
50
+ stroke-width="5"
51
+ />
52
+
53
+ <g>
54
+ <circle
55
+ v-for="point in points"
56
+ :key="`${point.label}-${point.x}`"
57
+ :cx="point.x"
58
+ :cy="point.y"
59
+ :fill="chartColor"
60
+ r="4"
61
+ >
62
+ <title>{{ point.label }}: {{ formatChartValue(point.value) }}</title>
63
+ </circle>
64
+ </g>
65
+
66
+ <g class="text-lightListTableText dark:text-darkListTableText">
67
+ <text
68
+ v-for="point in xLabels"
69
+ :key="`x-${point.x}`"
70
+ :x="point.x"
71
+ :y="chartHeight - 10"
72
+ fill="currentColor"
73
+ font-size="11"
74
+ text-anchor="middle"
75
+ >
76
+ {{ point.axisLabel }}
77
+ </text>
78
+ </g>
79
+ </svg>
80
+ </div>
81
+ </template>
82
+
83
+
84
+
85
+ <script setup lang="ts">
86
+ import { computed } from 'vue'
87
+ import { useElementSize } from '../../../composables/useElementSize.js'
88
+ import { CHART_COLORS, formatChartAxisLabel, formatChartLabel, formatChartValue, toFiniteNumber } from '../chart.utils.js'
89
+
90
+ const props = withDefaults(defineProps<{
91
+ rows: Record<string, unknown>[]
92
+ xField: string
93
+ yField: string
94
+ seriesName?: string
95
+ color?: string
96
+ height?: number
97
+ }>(), {
98
+ height: 240,
99
+ })
100
+
101
+ const { el: rootEl, width: rootWidth, height: rootHeight } = useElementSize<HTMLDivElement>()
102
+
103
+ const padding = {
104
+ top: 12,
105
+ right: 6,
106
+ bottom: 24,
107
+ left: 38,
108
+ }
109
+ const chartWidth = computed(() => Math.max(rootWidth.value, 1))
110
+ const chartHeight = computed(() => {
111
+ if (rootHeight.value > 0) {
112
+ return Math.max(rootHeight.value, 1)
113
+ }
114
+
115
+ return Math.max(props.height, 1)
116
+ })
117
+
118
+ const chartColor = computed(() => props.color || CHART_COLORS[0])
119
+ const values = computed(() => props.rows.map((row) => toFiniteNumber(row[props.yField])))
120
+ const maxValue = computed(() => Math.max(...values.value, 1))
121
+ const innerWidth = computed(() => Math.max(chartWidth.value - padding.left - padding.right, 1))
122
+ const innerHeight = computed(() => Math.max(chartHeight.value - padding.top - padding.bottom, 1))
123
+
124
+ const points = computed(() => {
125
+ if (props.rows.length === 1) {
126
+ return [{
127
+ x: padding.left + innerWidth.value / 2,
128
+ y: padding.top + innerHeight.value - (values.value[0] / maxValue.value) * innerHeight.value,
129
+ label: formatChartLabel(props.rows[0][props.xField]),
130
+ axisLabel: formatChartAxisLabel(props.rows[0][props.xField]),
131
+ value: values.value[0],
132
+ }]
133
+ }
134
+
135
+ return props.rows.map((row, index) => ({
136
+ x: padding.left + (index / (props.rows.length - 1)) * innerWidth.value,
137
+ y: padding.top + innerHeight.value - (values.value[index] / maxValue.value) * innerHeight.value,
138
+ label: formatChartLabel(row[props.xField]),
139
+ axisLabel: formatChartAxisLabel(row[props.xField]),
140
+ value: values.value[index],
141
+ }))
142
+ })
143
+
144
+ const linePath = computed(() => points.value
145
+ .map((point, index) => `${index === 0 ? 'M' : 'L'} ${point.x} ${point.y}`)
146
+ .join(' '))
147
+
148
+ const fillPath = computed(() => {
149
+ if (!points.value.length) {
150
+ return ''
151
+ }
152
+
153
+ const first = points.value[0]
154
+ const last = points.value[points.value.length - 1]
155
+ return `${linePath.value} L ${last.x} ${padding.top + innerHeight.value} L ${first.x} ${padding.top + innerHeight.value} Z`
156
+ })
157
+
158
+ const yTicks = computed(() => [0, 0.5, 1].map((ratio) => ({
159
+ value: maxValue.value * (1 - ratio),
160
+ y: padding.top + innerHeight.value * ratio,
161
+ })))
162
+
163
+ const xLabels = computed(() => {
164
+ if (points.value.length <= 4) {
165
+ return points.value
166
+ }
167
+
168
+ return [
169
+ points.value[0],
170
+ points.value[Math.floor(points.value.length / 3)],
171
+ points.value[Math.floor((points.value.length * 2) / 3)],
172
+ points.value[points.value.length - 1],
173
+ ]
174
+ })
175
+ </script>
@@ -0,0 +1,161 @@
1
+ <template>
2
+ <div
3
+ ref="rootEl"
4
+ class="grid h-full min-h-0 w-full gap-5 overflow-hidden"
5
+ :class="isCompact ? 'grid-rows-[minmax(0,1fr)_auto]' : 'grid-cols-[minmax(160px,260px)_minmax(0,1fr)] items-center'"
6
+ >
7
+ <div
8
+ ref="chartEl"
9
+ class="relative mx-auto grid min-h-0 w-full place-items-center overflow-hidden"
10
+ >
11
+ <svg
12
+ v-if="size > 0"
13
+ class="shrink-0 -rotate-90 drop-shadow-sm"
14
+ :width="size"
15
+ :height="size"
16
+ :viewBox="`0 0 ${size} ${size}`"
17
+ role="img"
18
+ :aria-label="valueField"
19
+ >
20
+ <circle
21
+ :cx="center"
22
+ :cy="center"
23
+ :r="radius"
24
+ class="text-lightListBorder dark:text-darkListBorder"
25
+ fill="none"
26
+ stroke="currentColor"
27
+ :stroke-width="strokeWidth"
28
+ />
29
+ <circle
30
+ v-for="slice in slices"
31
+ :key="slice.id"
32
+ :cx="center"
33
+ :cy="center"
34
+ :r="radius"
35
+ :stroke="slice.color"
36
+ :stroke-dasharray="slice.dashArray"
37
+ :stroke-dashoffset="slice.dashOffset"
38
+ fill="none"
39
+ pathLength="100"
40
+ stroke-linecap="butt"
41
+ :stroke-width="strokeWidth"
42
+ >
43
+ <title>{{ slice.label }}: {{ formatChartValue(slice.value) }} ({{ slice.percentLabel }})</title>
44
+ </circle>
45
+ </svg>
46
+
47
+ <div class="absolute inset-0 grid place-items-center text-center">
48
+ <div>
49
+ <div class="text-2xl font-bold text-lightNavbarText dark:text-darkNavbarText">
50
+ {{ formatChartValue(total) }}
51
+ </div>
52
+ <div class="text-xs uppercase tracking-wide text-lightListTableText dark:text-darkListTableText">
53
+ Total
54
+ </div>
55
+ </div>
56
+ </div>
57
+ </div>
58
+
59
+ <div class="grid min-w-0 gap-3">
60
+ <div
61
+ v-for="slice in slices"
62
+ :key="`legend-${slice.id}`"
63
+ class="grid min-w-0 grid-cols-[1fr_auto] items-center gap-3 text-sm"
64
+ >
65
+ <div class="flex min-w-0 items-center gap-2">
66
+ <span
67
+ class="h-3 w-3 shrink-0 rounded-full"
68
+ :style="{ backgroundColor: slice.color }"
69
+ />
70
+ <span class="truncate font-medium text-lightNavbarText dark:text-darkNavbarText">
71
+ {{ slice.label }}
72
+ </span>
73
+ </div>
74
+
75
+ <div class="text-right">
76
+ <div class="font-semibold text-lightNavbarText dark:text-darkNavbarText">
77
+ {{ slice.percentLabel }}
78
+ </div>
79
+ <div class="text-xs text-lightListTableText dark:text-darkListTableText">
80
+ {{ formatChartValue(slice.value) }}
81
+ </div>
82
+ </div>
83
+ </div>
84
+ </div>
85
+ </div>
86
+ </template>
87
+
88
+
89
+
90
+ <script setup lang="ts">
91
+ import { computed } from 'vue'
92
+ import { useElementSize } from '../../../composables/useElementSize.js'
93
+ import { CHART_COLORS, formatChartLabel, formatChartValue, toFiniteNumber } from '../chart.utils.js'
94
+
95
+ const props = withDefaults(defineProps<{
96
+ rows: Record<string, unknown>[]
97
+ labelField: string
98
+ valueField: string
99
+ colors?: string[]
100
+ height?: number
101
+ }>(), {
102
+ height: 240,
103
+ })
104
+
105
+ const { el: rootEl, width: rootWidth } = useElementSize<HTMLDivElement>()
106
+ const { el: chartEl, width: chartWidth, height: chartHeight } = useElementSize<HTMLDivElement>()
107
+
108
+ const isCompact = computed(() => rootWidth.value > 0 && rootWidth.value < 420)
109
+
110
+ const size = computed(() => {
111
+ const measured = Math.min(chartWidth.value, chartHeight.value)
112
+
113
+ if (measured > 0) {
114
+ return Math.min(Math.max(measured, 1), 320)
115
+ }
116
+
117
+ return Math.min(Math.max(props.height, 96), 320)
118
+ })
119
+ const center = computed(() => size.value / 2)
120
+ const strokeWidth = computed(() => Math.max(Math.round(size.value * 0.12), 18))
121
+ const radius = computed(() => Math.max(size.value / 2 - strokeWidth.value / 2 - 4, 1))
122
+ const shouldCountRows = computed(() => /(^|_)id$/i.test(props.valueField))
123
+
124
+ const pieRows = computed(() => {
125
+ const groupedRows = new Map<string, { label: string, value: number }>()
126
+
127
+ for (const row of props.rows) {
128
+ const label = formatChartLabel(row[props.labelField])
129
+ const item = groupedRows.get(label) ?? { label, value: 0 }
130
+ item.value += shouldCountRows.value ? 1 : toFiniteNumber(row[props.valueField])
131
+ groupedRows.set(label, item)
132
+ }
133
+
134
+ return Array.from(groupedRows.values())
135
+ })
136
+
137
+ const total = computed(() => pieRows.value.reduce((sum, row) => sum + row.value, 0))
138
+
139
+ const slices = computed(() => {
140
+ let offset = 0
141
+
142
+ return pieRows.value.map((row, index) => {
143
+ const value = row.value
144
+ const share = total.value > 0 ? value / total.value : 0
145
+ const percent = share * 100
146
+ const slice = {
147
+ id: `${row.label}-${index}`,
148
+ label: row.label,
149
+ value,
150
+ share,
151
+ percentLabel: `${new Intl.NumberFormat(undefined, { maximumFractionDigits: 1 }).format(percent)}%`,
152
+ dashArray: `${Math.max(percent - 0.6, 0)} ${100 - Math.max(percent - 0.6, 0)}`,
153
+ dashOffset: -offset,
154
+ color: props.colors?.[index] || CHART_COLORS[index % CHART_COLORS.length],
155
+ }
156
+
157
+ offset += percent
158
+ return slice
159
+ })
160
+ })
161
+ </script>