@adminforth/dashboard 1.11.2 → 1.12.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 (40) hide show
  1. package/README.md +43 -2
  2. package/custom/api/dashboardApi.ts +72 -10
  3. package/custom/model/dashboard.types.ts +6 -0
  4. package/custom/runtime/DashboardGroup.vue +15 -68
  5. package/custom/runtime/DashboardToolbarButton.vue +32 -0
  6. package/custom/runtime/DashboardToolbarIcon.vue +52 -0
  7. package/custom/runtime/WidgetShell.vue +15 -68
  8. package/custom/skills/adminforth-dashboard/SKILL.md +63 -13
  9. package/custom/widgets/chart/StackedBarChart.vue +64 -7
  10. package/dist/custom/api/dashboardApi.js +59 -10
  11. package/dist/custom/api/dashboardApi.ts +72 -10
  12. package/dist/custom/model/dashboard.types.d.ts +9 -0
  13. package/dist/custom/model/dashboard.types.ts +6 -0
  14. package/dist/custom/queries/useDashboardConfig.d.ts +80 -0
  15. package/dist/custom/queries/useWidgetData.d.ts +80 -0
  16. package/dist/custom/runtime/DashboardGroup.vue +15 -68
  17. package/dist/custom/runtime/DashboardToolbarButton.vue +32 -0
  18. package/dist/custom/runtime/DashboardToolbarIcon.vue +52 -0
  19. package/dist/custom/runtime/WidgetShell.vue +15 -68
  20. package/dist/custom/skills/adminforth-dashboard/SKILL.md +63 -13
  21. package/dist/custom/widgets/chart/StackedBarChart.vue +64 -7
  22. package/dist/schema/api.d.ts +742 -802
  23. package/dist/schema/api.js +2 -2
  24. package/dist/schema/widget.d.ts +75 -81
  25. package/dist/schema/widget.js +1 -1
  26. package/dist/schema/widgets/charts.d.ts +84 -160
  27. package/dist/schema/widgets/charts.js +2 -2
  28. package/dist/schema/widgets/common.d.ts +115 -0
  29. package/dist/schema/widgets/common.js +17 -1
  30. package/dist/schema/widgets/gauge-card.d.ts +8 -0
  31. package/dist/schema/widgets/kpi-card.d.ts +8 -0
  32. package/dist/schema/widgets/pivot-table.d.ts +8 -0
  33. package/dist/schema/widgets/table.d.ts +8 -0
  34. package/dist/services/widgetDataService.js +42 -0
  35. package/package.json +2 -2
  36. package/schema/api.ts +2 -1
  37. package/schema/widget.ts +2 -0
  38. package/schema/widgets/charts.ts +2 -1
  39. package/schema/widgets/common.ts +19 -1
  40. package/services/widgetDataService.ts +68 -0
@@ -14,91 +14,36 @@
14
14
  v-if="isAdmin"
15
15
  class="absolute right-3 top-3 flex gap-1 opacity-0 transition-opacity group-hover/dashboard:opacity-100"
16
16
  >
17
- <button
18
- type="button"
19
- class="flex h-8 w-8 items-center justify-center rounded-lg border border-lightListViewButtonBorder bg-lightListViewButtonBackground text-lightListViewButtonText shadow-sm hover:bg-lightListViewButtonBackgroundHover hover:text-lightListViewButtonTextHover dark:border-darkListViewButtonBorder dark:bg-darkListViewButtonBackground dark:text-darkListViewButtonText dark:hover:bg-darkListViewButtonBackgroundHover dark:hover:text-darkListViewButtonTextHover"
17
+ <DashboardToolbarButton
20
18
  title="Edit JSON"
21
19
  @click="emit('edit-group', group)"
22
20
  >
23
- <svg
24
- class="h-4 w-4"
25
- viewBox="0 0 24 24"
26
- fill="none"
27
- stroke="currentColor"
28
- stroke-width="1.8"
29
- stroke-linecap="round"
30
- stroke-linejoin="round"
31
- aria-hidden="true"
32
- >
33
- <path d="M15.5 7.5a3 3 0 1 1 1 2.2l-6.8 6.8H7.5v2.2H5.3v2.2H2.8v-2.5l7.5-7.5a5.5 5.5 0 1 1 5.2 1.6" />
34
- </svg>
35
- </button>
21
+ <DashboardToolbarIcon name="edit" />
22
+ </DashboardToolbarButton>
36
23
 
37
- <button
38
- type="button"
39
- class="flex h-8 w-8 items-center justify-center rounded-lg border border-lightListViewButtonBorder bg-lightListViewButtonBackground text-lightListViewButtonText shadow-sm hover:bg-lightListViewButtonBackgroundHover hover:text-lightListViewButtonTextHover disabled:opacity-45 dark:border-darkListViewButtonBorder dark:bg-darkListViewButtonBackground dark:text-darkListViewButtonText dark:hover:bg-darkListViewButtonBackgroundHover dark:hover:text-darkListViewButtonTextHover"
24
+ <DashboardToolbarButton
40
25
  title="Move up"
41
26
  :disabled="!canMoveUp"
42
27
  @click="emit('move-up')"
43
28
  >
44
- <svg
45
- class="h-4 w-4"
46
- viewBox="0 0 24 24"
47
- fill="none"
48
- stroke="currentColor"
49
- stroke-width="2"
50
- stroke-linecap="round"
51
- stroke-linejoin="round"
52
- aria-hidden="true"
53
- >
54
- <path d="m18 15-6-6-6 6" />
55
- </svg>
56
- </button>
29
+ <DashboardToolbarIcon name="move-up" />
30
+ </DashboardToolbarButton>
57
31
 
58
- <button
59
- type="button"
60
- class="flex h-8 w-8 items-center justify-center rounded-lg border border-lightListViewButtonBorder bg-lightListViewButtonBackground text-lightListViewButtonText shadow-sm hover:bg-lightListViewButtonBackgroundHover hover:text-lightListViewButtonTextHover disabled:opacity-45 dark:border-darkListViewButtonBorder dark:bg-darkListViewButtonBackground dark:text-darkListViewButtonText dark:hover:bg-darkListViewButtonBackgroundHover dark:hover:text-darkListViewButtonTextHover"
32
+ <DashboardToolbarButton
61
33
  title="Move down"
62
34
  :disabled="!canMoveDown"
63
35
  @click="emit('move-down')"
64
36
  >
65
- <svg
66
- class="h-4 w-4"
67
- viewBox="0 0 24 24"
68
- fill="none"
69
- stroke="currentColor"
70
- stroke-width="2"
71
- stroke-linecap="round"
72
- stroke-linejoin="round"
73
- aria-hidden="true"
74
- >
75
- <path d="m6 9 6 6 6-6" />
76
- </svg>
77
- </button>
37
+ <DashboardToolbarIcon name="move-down" />
38
+ </DashboardToolbarButton>
78
39
 
79
- <button
80
- type="button"
81
- class="flex h-8 w-8 items-center justify-center rounded-lg border border-lightInputErrorColor/30 bg-lightSecondary text-lightInputErrorColor shadow-sm hover:bg-lightListViewButtonBackgroundHover dark:bg-darkSecondary dark:hover:bg-darkListViewButtonBackgroundHover"
40
+ <DashboardToolbarButton
82
41
  title="Remove"
42
+ variant="danger"
83
43
  @click="emit('remove-group')"
84
44
  >
85
- <svg
86
- class="h-4 w-4"
87
- viewBox="0 0 24 24"
88
- fill="none"
89
- stroke="currentColor"
90
- stroke-width="2"
91
- stroke-linecap="round"
92
- stroke-linejoin="round"
93
- aria-hidden="true"
94
- >
95
- <path d="M3 6h18" />
96
- <path d="M8 6V4h8v2" />
97
- <path d="M19 6l-1 14H6L5 6" />
98
- <path d="M10 11v5" />
99
- <path d="M14 11v5" />
100
- </svg>
101
- </button>
45
+ <DashboardToolbarIcon name="remove" />
46
+ </DashboardToolbarButton>
102
47
  </div>
103
48
  </header>
104
49
 
@@ -158,6 +103,8 @@
158
103
 
159
104
  <script setup lang="ts">
160
105
  import { Button } from '@/afcl'
106
+ import DashboardToolbarButton from './DashboardToolbarButton.vue'
107
+ import DashboardToolbarIcon from './DashboardToolbarIcon.vue'
161
108
  import WidgetRenderer from './WidgetRenderer.vue'
162
109
  import WidgetShell from './WidgetShell.vue'
163
110
  import type { DashboardGroupConfig, DashboardWidgetConfig } from '../model/dashboard.types.js'
@@ -0,0 +1,32 @@
1
+ <template>
2
+ <button
3
+ type="button"
4
+ class="flex h-8 w-8 items-center justify-center rounded-lg border shadow-sm"
5
+ :class="buttonClass"
6
+ :title="title"
7
+ :disabled="disabled"
8
+ >
9
+ <slot />
10
+ </button>
11
+ </template>
12
+
13
+ <script setup lang="ts">
14
+ import { computed } from 'vue'
15
+
16
+ const props = withDefaults(defineProps<{
17
+ title: string
18
+ disabled?: boolean
19
+ variant?: 'default' | 'danger'
20
+ }>(), {
21
+ disabled: false,
22
+ variant: 'default',
23
+ })
24
+
25
+ const buttonClass = computed(() => {
26
+ if (props.variant === 'danger') {
27
+ return 'border-lightInputErrorColor/30 bg-lightSecondary text-lightInputErrorColor hover:bg-lightListViewButtonBackgroundHover dark:bg-darkSecondary dark:hover:bg-darkListViewButtonBackgroundHover'
28
+ }
29
+
30
+ return 'border-lightListViewButtonBorder bg-lightListViewButtonBackground text-lightListViewButtonText hover:bg-lightListViewButtonBackgroundHover hover:text-lightListViewButtonTextHover disabled:opacity-45 dark:border-darkListViewButtonBorder dark:bg-darkListViewButtonBackground dark:text-darkListViewButtonText dark:hover:bg-darkListViewButtonBackgroundHover dark:hover:text-darkListViewButtonTextHover'
31
+ })
32
+ </script>
@@ -0,0 +1,52 @@
1
+ <template>
2
+ <svg
3
+ v-if="name === 'edit'"
4
+ xmlns="http://www.w3.org/2000/svg"
5
+ width="16"
6
+ height="16"
7
+ viewBox="0 0 90 90"
8
+ fill="none"
9
+ aria-hidden="true"
10
+ >
11
+ <g transform="translate(90 0) scale(-1 1)">
12
+ <path
13
+ fill="currentColor"
14
+ d="M69.243 90c-5.389 0-10.67-2.107-14.643-6.081-5.433-5.432-7.391-13.514-5.115-20.831L26.912 40.515c-7.313 2.276-15.397.32-20.832-5.114C.147 29.468-1.625 20.617 1.566 12.852l.846-2.059 12.493 12.493c2.311 2.31 6.07 2.311 8.381 0 2.31-2.311 2.31-6.071 0-8.381L10.794 2.413l2.059-.846c7.766-3.191 16.616-1.418 22.549 4.514 5.433 5.433 7.39 13.516 5.114 20.831l22.572 22.573c7.314-2.278 15.398-.32 20.832 5.114 5.934 5.933 7.704 14.784 4.514 22.549l-.847 2.059-12.492-12.493c-1.113-1.113-2.601-1.726-4.191-1.726s-3.077.613-4.191 1.726c-2.31 2.311-2.31 6.071 0 8.381l12.493 12.492-2.06.847C74.582 89.487 71.899 89.999 69.243 90zM27.692 37.1l25.206 25.207-.322.887c-2.345 6.469-.728 13.779 4.119 18.626 4.538 4.538 11.069 6.235 17.152 4.603l-9.232-9.232c-3.466-3.467-3.466-9.109 0-12.576 3.466-3.468 9.109-3.468 12.576 0l9.232 9.232c1.633-6.083-.064-12.615-4.602-17.153-4.847-4.847-12.161-6.464-18.627-4.118l-.887.322L37.101 27.692l.322-.887c2.345-6.469.729-13.78-4.118-18.627-4.539-4.538-11.07-6.235-17.152-4.603l9.232 9.232c3.467 3.467 3.467 9.109 0 12.576s-9.109 3.468-12.576 0l-9.233-9.232c-1.634 6.083.064 12.614 4.602 17.153 4.848 4.848 12.16 6.464 18.628 4.118l.886-.322z"
15
+ />
16
+ </g>
17
+ </svg>
18
+
19
+ <svg
20
+ v-else
21
+ class="h-4 w-4"
22
+ viewBox="0 0 24 24"
23
+ fill="none"
24
+ stroke="currentColor"
25
+ stroke-width="2"
26
+ stroke-linecap="round"
27
+ stroke-linejoin="round"
28
+ aria-hidden="true"
29
+ >
30
+ <path
31
+ v-if="name === 'move-up'"
32
+ d="m18 15-6-6-6 6"
33
+ />
34
+ <path
35
+ v-else-if="name === 'move-down'"
36
+ d="m6 9 6 6 6-6"
37
+ />
38
+ <template v-else>
39
+ <path d="M3 6h18" />
40
+ <path d="M8 6V4h8v2" />
41
+ <path d="M19 6l-1 14H6L5 6" />
42
+ <path d="M10 11v5" />
43
+ <path d="M14 11v5" />
44
+ </template>
45
+ </svg>
46
+ </template>
47
+
48
+ <script setup lang="ts">
49
+ defineProps<{
50
+ name: 'edit' | 'move-up' | 'move-down' | 'remove'
51
+ }>()
52
+ </script>
@@ -10,91 +10,36 @@
10
10
  v-if="isAdmin"
11
11
  class="absolute right-2 top-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100"
12
12
  >
13
- <button
14
- type="button"
15
- class="flex h-8 w-8 items-center justify-center rounded-lg border border-lightListViewButtonBorder bg-lightListViewButtonBackground text-lightListViewButtonText shadow-sm hover:bg-lightListViewButtonBackgroundHover hover:text-lightListViewButtonTextHover dark:border-darkListViewButtonBorder dark:bg-darkListViewButtonBackground dark:text-darkListViewButtonText dark:hover:bg-darkListViewButtonBackgroundHover dark:hover:text-darkListViewButtonTextHover"
13
+ <DashboardToolbarButton
16
14
  title="Edit JSON"
17
15
  @click="emit('edit')"
18
16
  >
19
- <svg
20
- class="h-4 w-4"
21
- viewBox="0 0 24 24"
22
- fill="none"
23
- stroke="currentColor"
24
- stroke-width="1.8"
25
- stroke-linecap="round"
26
- stroke-linejoin="round"
27
- aria-hidden="true"
28
- >
29
- <path d="M15.5 7.5a3 3 0 1 1 1 2.2l-6.8 6.8H7.5v2.2H5.3v2.2H2.8v-2.5l7.5-7.5a5.5 5.5 0 1 1 5.2 1.6" />
30
- </svg>
31
- </button>
17
+ <DashboardToolbarIcon name="edit" />
18
+ </DashboardToolbarButton>
32
19
 
33
- <button
34
- type="button"
35
- class="flex h-8 w-8 items-center justify-center rounded-lg border border-lightListViewButtonBorder bg-lightListViewButtonBackground text-lightListViewButtonText shadow-sm hover:bg-lightListViewButtonBackgroundHover hover:text-lightListViewButtonTextHover disabled:opacity-45 dark:border-darkListViewButtonBorder dark:bg-darkListViewButtonBackground dark:text-darkListViewButtonText dark:hover:bg-darkListViewButtonBackgroundHover dark:hover:text-darkListViewButtonTextHover"
20
+ <DashboardToolbarButton
36
21
  title="Move up"
37
22
  :disabled="!canMoveUp"
38
23
  @click="emit('move-up')"
39
24
  >
40
- <svg
41
- class="h-4 w-4"
42
- viewBox="0 0 24 24"
43
- fill="none"
44
- stroke="currentColor"
45
- stroke-width="2"
46
- stroke-linecap="round"
47
- stroke-linejoin="round"
48
- aria-hidden="true"
49
- >
50
- <path d="m18 15-6-6-6 6" />
51
- </svg>
52
- </button>
25
+ <DashboardToolbarIcon name="move-up" />
26
+ </DashboardToolbarButton>
53
27
 
54
- <button
55
- type="button"
56
- class="flex h-8 w-8 items-center justify-center rounded-lg border border-lightListViewButtonBorder bg-lightListViewButtonBackground text-lightListViewButtonText shadow-sm hover:bg-lightListViewButtonBackgroundHover hover:text-lightListViewButtonTextHover disabled:opacity-45 dark:border-darkListViewButtonBorder dark:bg-darkListViewButtonBackground dark:text-darkListViewButtonText dark:hover:bg-darkListViewButtonBackgroundHover dark:hover:text-darkListViewButtonTextHover"
28
+ <DashboardToolbarButton
57
29
  title="Move down"
58
30
  :disabled="!canMoveDown"
59
31
  @click="emit('move-down')"
60
32
  >
61
- <svg
62
- class="h-4 w-4"
63
- viewBox="0 0 24 24"
64
- fill="none"
65
- stroke="currentColor"
66
- stroke-width="2"
67
- stroke-linecap="round"
68
- stroke-linejoin="round"
69
- aria-hidden="true"
70
- >
71
- <path d="m6 9 6 6 6-6" />
72
- </svg>
73
- </button>
33
+ <DashboardToolbarIcon name="move-down" />
34
+ </DashboardToolbarButton>
74
35
 
75
- <button
76
- type="button"
77
- class="flex h-8 w-8 items-center justify-center rounded-lg border border-lightInputErrorColor/30 bg-lightSecondary text-lightInputErrorColor shadow-sm hover:bg-lightListViewButtonBackgroundHover dark:bg-darkSecondary dark:hover:bg-darkListViewButtonBackgroundHover"
36
+ <DashboardToolbarButton
78
37
  title="Remove"
38
+ variant="danger"
79
39
  @click="emit('remove')"
80
40
  >
81
- <svg
82
- class="h-4 w-4"
83
- viewBox="0 0 24 24"
84
- fill="none"
85
- stroke="currentColor"
86
- stroke-width="2"
87
- stroke-linecap="round"
88
- stroke-linejoin="round"
89
- aria-hidden="true"
90
- >
91
- <path d="M3 6h18" />
92
- <path d="M8 6V4h8v2" />
93
- <path d="M19 6l-1 14H6L5 6" />
94
- <path d="M10 11v5" />
95
- <path d="M14 11v5" />
96
- </svg>
97
- </button>
41
+ <DashboardToolbarIcon name="remove" />
42
+ </DashboardToolbarButton>
98
43
  </div>
99
44
  </div>
100
45
  </template>
@@ -105,6 +50,8 @@
105
50
  import { computed } from 'vue'
106
51
  import type { CSSProperties } from 'vue'
107
52
  import type { WidgetLayout } from '../model/dashboard.types.js'
53
+ import DashboardToolbarButton from './DashboardToolbarButton.vue'
54
+ import DashboardToolbarIcon from './DashboardToolbarIcon.vue'
108
55
 
109
56
  const DEFAULT_WIDGET_HEIGHT = 500
110
57
 
@@ -253,6 +253,50 @@ query:
253
253
  direction: asc
254
254
  ```
255
255
 
256
+ Note: use `stacked_bar` with a normal single-resource grouped query when you need a dynamic series dimension such as `series.field: purpose`. For the same numeric buckets across multiple resources, use `query.source: steps` with `query.bucket`; set `chart.x.field: label`, `chart.y.field` to the aggregate alias, and `chart.series.field: name`.
257
+
258
+ Example bucketed multi-resource stacked bar:
259
+
260
+ ```yaml
261
+ target: chart
262
+ label: Cars by price range and database
263
+ size: wide
264
+ chart:
265
+ type: stacked_bar
266
+ x:
267
+ field: label
268
+ label: Price range
269
+ y:
270
+ field: count
271
+ label: Cars
272
+ series:
273
+ field: name
274
+ label: Database
275
+ query:
276
+ source: steps
277
+ bucket:
278
+ field: price
279
+ buckets:
280
+ - label: Budget
281
+ max: 3500
282
+ - label: Mid-range
283
+ min: 3500
284
+ max: 7000
285
+ - label: Premium
286
+ min: 7000
287
+ steps:
288
+ - name: SQLite
289
+ resource: cars_sl
290
+ select:
291
+ - agg: count
292
+ as: count
293
+ - name: MySQL
294
+ resource: cars_mysql
295
+ select:
296
+ - agg: count
297
+ as: count
298
+ ```
299
+
256
300
  Example `dashboard_configure_pie_chart_widget` config:
257
301
 
258
302
  ```yaml
@@ -291,26 +335,24 @@ chart:
291
335
  field: total_tokens
292
336
  label: Tokens
293
337
  y:
294
- field: requests
338
+ field: count
295
339
  label: Requests
340
+ buckets:
341
+ - label: Small
342
+ max: 1000
343
+ - label: Medium
344
+ min: 1000
345
+ max: 10000
346
+ - label: Large
347
+ min: 10000
296
348
  query:
297
349
  resource: llm_usage
298
350
  select:
299
351
  - field: total_tokens
300
- - agg: count
301
- as: requests
302
- bucket:
303
- field: total_tokens
304
- buckets:
305
- - label: Small
306
- max: 1000
307
- - label: Medium
308
- min: 1000
309
- max: 10000
310
- - label: Large
311
- min: 10000
312
352
  ```
313
353
 
354
+ Note: for the current dashboard runtime, histogram buckets are computed on the frontend from raw rows using `chart.buckets`. Do not rely on `query.bucket` for histogram widgets, do not use `query.source: steps`, and do not aggregate the source rows first. Histogram widgets should use a single-resource plain query with raw numeric rows, for example `select: - field: total_tokens`; the histogram component will derive the per-bucket `count` values itself.
355
+
314
356
  Example `dashboard_configure_funnel_chart_widget` config:
315
357
 
316
358
  ```yaml
@@ -476,6 +518,14 @@ query:
476
518
 
477
519
  Do not use bare query.steps without source: steps.
478
520
  Do not use metric. Use select even when a step has only one aggregate.
521
+ Each `steps[]` item supports only:
522
+ - name
523
+ - resource
524
+ - select with aggregate items only, for example `agg: count`, `agg: sum`, `agg: avg`
525
+ - optional filters
526
+ Do not put `field` selects, `calc` selects, `group_by`, `order_by`, `limit`, `offset`, or `bucket` inside a step.
527
+ Without `query.bucket`, `query.source: steps` produces one output row per step, with built-in `name` and `resource` fields plus the aggregate aliases from that step.
528
+ For per-bucket comparisons across multiple resources, put `bucket` at the query level, not inside a step. Use stacked_bar with `chart.x.field: label`, `chart.series.field: name`, and `chart.y.field` set to the aggregate alias such as `count`.
479
529
  All filters, including aggregate select item filters, must use filter expression shape.
480
530
  Use `filters: { field: model, eq: gpt-5.4 }`, not shorthand maps like `filters: { model: gpt-5.4 }`.
481
531
  When grouping by a derived date alias, repeat the source field object in `group_by`.
@@ -1,7 +1,8 @@
1
1
  <template>
2
2
  <div
3
3
  ref="rootEl"
4
- class="grid h-full min-h-0 w-full grid-rows-[auto_minmax(0,1fr)] gap-3 overflow-hidden"
4
+ class="relative grid h-full min-h-0 w-full grid-rows-[auto_minmax(0,1fr)] gap-3 overflow-hidden"
5
+ @mouseleave="hideTooltip"
5
6
  >
6
7
  <div
7
8
  v-if="showLegend"
@@ -70,9 +71,9 @@
70
71
  :height="segment.height"
71
72
  :fill="segment.color"
72
73
  rx="3"
73
- >
74
- <title>{{ getBarTooltip(bar) }}</title>
75
- </rect>
74
+ @mouseenter="showTooltip($event, bar)"
75
+ @mousemove="moveTooltip($event)"
76
+ />
76
77
 
77
78
  <text
78
79
  v-if="visibleLabelIndexes.has(barIndex)"
@@ -88,13 +89,29 @@
88
89
  </g>
89
90
  </svg>
90
91
  </div>
92
+
93
+ <Teleport to="body">
94
+ <div
95
+ v-if="tooltip"
96
+ class="pointer-events-none fixed z-[1000] min-w-44 rounded border border-lightTableBorder bg-lightTableBackground px-3 py-2 text-xs leading-5 text-lightListTableText shadow-lg dark:border-darkTableBorder dark:bg-darkTableBackground dark:text-darkListTableText"
97
+ :style="{ left: `${tooltip.x}px`, top: `${tooltip.y}px` }"
98
+ >
99
+ <div
100
+ v-for="line in tooltip.lines"
101
+ :key="line"
102
+ class="whitespace-nowrap"
103
+ >
104
+ {{ line }}
105
+ </div>
106
+ </div>
107
+ </Teleport>
91
108
  </div>
92
109
  </template>
93
110
 
94
111
 
95
112
 
96
113
  <script setup lang="ts">
97
- import { computed } from 'vue'
114
+ import { computed, ref } from 'vue'
98
115
  import { useElementSize } from '../../composables/useElementSize.js'
99
116
  import {
100
117
  CHART_COLORS,
@@ -120,6 +137,17 @@ const { el: rootEl, width: rootWidth } = useElementSize<HTMLDivElement>()
120
137
  const { el: svgEl, width: svgWidth, height: svgHeight } = useElementSize<HTMLDivElement>()
121
138
 
122
139
  const barGap = 10
140
+ type StackedBarTooltipBar = {
141
+ label: string
142
+ total: number
143
+ segments: Array<{ name: string, value: number }>
144
+ }
145
+
146
+ const tooltip = ref<{
147
+ x: number
148
+ y: number
149
+ lines: string[]
150
+ } | null>(null)
123
151
  const seriesNames = computed(() => Array.from(new Set(props.rows.map((row) => formatSeriesLabel(row[props.seriesField])))))
124
152
  const normalizedSeries = computed(() => seriesNames.value.map((name, index) => ({
125
153
  name,
@@ -223,7 +251,36 @@ const yTicks = computed(() => [0, 0.5, 1].map((ratio) => ({
223
251
  y: padding.value.top + innerHeight.value * ratio,
224
252
  })))
225
253
 
226
- function getBarTooltip(bar: { label: string, total: number, segments: Array<{ name: string, value: number }> }) {
254
+ function showTooltip(event: MouseEvent, bar: StackedBarTooltipBar) {
255
+ tooltip.value = {
256
+ ...getTooltipPosition(event),
257
+ lines: getBarTooltipLines(bar),
258
+ }
259
+ }
260
+
261
+ function moveTooltip(event: MouseEvent) {
262
+ if (!tooltip.value) {
263
+ return
264
+ }
265
+
266
+ tooltip.value = {
267
+ ...tooltip.value,
268
+ ...getTooltipPosition(event),
269
+ }
270
+ }
271
+
272
+ function hideTooltip() {
273
+ tooltip.value = null
274
+ }
275
+
276
+ function getTooltipPosition(event: MouseEvent) {
277
+ return {
278
+ x: event.clientX + 12,
279
+ y: event.clientY + 12,
280
+ }
281
+ }
282
+
283
+ function getBarTooltipLines(bar: StackedBarTooltipBar) {
227
284
  const percentFormatter = new Intl.NumberFormat(undefined, { maximumFractionDigits: 1 })
228
285
 
229
286
  const segmentLines = bar.segments.map((segment) => {
@@ -238,7 +295,7 @@ function getBarTooltip(bar: { label: string, total: number, segments: Array<{ na
238
295
  `${bar.label}`,
239
296
  `Total: ${formatChartValue(bar.total)}`,
240
297
  ...segmentLines,
241
- ].join('\n')
298
+ ]
242
299
  }
243
300
 
244
301
  function formatSeriesLabel(value: unknown) {