@bagelink/vue 1.15.63 → 1.15.65

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 (136) hide show
  1. package/dist/components/AccordionItem.vue.d.ts.map +1 -1
  2. package/dist/components/Avatar.vue.d.ts +6 -1
  3. package/dist/components/Avatar.vue.d.ts.map +1 -1
  4. package/dist/components/Badge.vue.d.ts.map +1 -1
  5. package/dist/components/Card.vue.d.ts +7 -0
  6. package/dist/components/Card.vue.d.ts.map +1 -1
  7. package/dist/components/Dropdown.vue.d.ts.map +1 -1
  8. package/dist/components/EmptyState.vue.d.ts +43 -0
  9. package/dist/components/EmptyState.vue.d.ts.map +1 -0
  10. package/dist/components/Icon/Icon.vue.d.ts +13 -0
  11. package/dist/components/Icon/Icon.vue.d.ts.map +1 -1
  12. package/dist/components/Image.vue.d.ts +26 -1
  13. package/dist/components/Image.vue.d.ts.map +1 -1
  14. package/dist/components/ListItem.vue.d.ts +9 -9
  15. package/dist/components/ListItem.vue.d.ts.map +1 -1
  16. package/dist/components/Menu.vue.d.ts.map +1 -1
  17. package/dist/components/Swiper.vue.d.ts +3 -3
  18. package/dist/components/calendar/CalendarPopover.vue.d.ts +10 -0
  19. package/dist/components/calendar/CalendarPopover.vue.d.ts.map +1 -1
  20. package/dist/components/charts/BarChart.vue.d.ts +34 -0
  21. package/dist/components/charts/BarChart.vue.d.ts.map +1 -0
  22. package/dist/components/charts/ChartTooltip.vue.d.ts +33 -0
  23. package/dist/components/charts/ChartTooltip.vue.d.ts.map +1 -0
  24. package/dist/components/charts/Donut.vue.d.ts +53 -0
  25. package/dist/components/charts/Donut.vue.d.ts.map +1 -0
  26. package/dist/components/charts/Funnel.vue.d.ts +53 -0
  27. package/dist/components/charts/Funnel.vue.d.ts.map +1 -0
  28. package/dist/components/charts/Gauge.vue.d.ts +28 -0
  29. package/dist/components/charts/Gauge.vue.d.ts.map +1 -0
  30. package/dist/components/charts/LineChart.vue.d.ts +37 -0
  31. package/dist/components/charts/LineChart.vue.d.ts.map +1 -0
  32. package/dist/components/charts/RadialBars.vue.d.ts +34 -0
  33. package/dist/components/charts/RadialBars.vue.d.ts.map +1 -0
  34. package/dist/components/charts/RankBars.vue.d.ts +27 -0
  35. package/dist/components/charts/RankBars.vue.d.ts.map +1 -0
  36. package/dist/components/charts/Sparkline.vue.d.ts +25 -0
  37. package/dist/components/charts/Sparkline.vue.d.ts.map +1 -0
  38. package/dist/components/charts/StatCard.vue.d.ts +28 -0
  39. package/dist/components/charts/StatCard.vue.d.ts.map +1 -0
  40. package/dist/components/charts/core/data.d.ts +46 -0
  41. package/dist/components/charts/core/data.d.ts.map +1 -0
  42. package/dist/components/charts/core/format.d.ts +13 -0
  43. package/dist/components/charts/core/format.d.ts.map +1 -0
  44. package/dist/components/charts/core/palette.d.ts +19 -0
  45. package/dist/components/charts/core/palette.d.ts.map +1 -0
  46. package/dist/components/charts/core/uid.d.ts +2 -0
  47. package/dist/components/charts/core/uid.d.ts.map +1 -0
  48. package/dist/components/charts/core/useChartAnim.d.ts +11 -0
  49. package/dist/components/charts/core/useChartAnim.d.ts.map +1 -0
  50. package/dist/components/charts/core/useChartFrame.d.ts +21 -0
  51. package/dist/components/charts/core/useChartFrame.d.ts.map +1 -0
  52. package/dist/components/charts/core/useScale.d.ts +16 -0
  53. package/dist/components/charts/core/useScale.d.ts.map +1 -0
  54. package/dist/components/charts/index.d.ts +12 -0
  55. package/dist/components/charts/index.d.ts.map +1 -0
  56. package/dist/components/form/inputs/RadioGroup.vue.d.ts +1 -0
  57. package/dist/components/form/inputs/RadioGroup.vue.d.ts.map +1 -1
  58. package/dist/components/form/inputs/RangeInput.vue.d.ts +13 -4
  59. package/dist/components/form/inputs/RangeInput.vue.d.ts.map +1 -1
  60. package/dist/components/form/inputs/SelectInput.vue.d.ts.map +1 -1
  61. package/dist/components/index.d.ts +3 -1
  62. package/dist/components/index.d.ts.map +1 -1
  63. package/dist/components/layout/Layout.vue.d.ts +1 -1
  64. package/dist/components/layout/Layout.vue.d.ts.map +1 -1
  65. package/dist/components/layout/Panel.vue.d.ts +1 -1
  66. package/dist/components/layout/Panel.vue.d.ts.map +1 -1
  67. package/dist/components/layout/Timeline.types.d.ts +9 -0
  68. package/dist/components/layout/Timeline.types.d.ts.map +1 -0
  69. package/dist/components/layout/Timeline.vue.d.ts +42 -0
  70. package/dist/components/layout/Timeline.vue.d.ts.map +1 -0
  71. package/dist/components/layout/TimelineItem.vue.d.ts +37 -0
  72. package/dist/components/layout/TimelineItem.vue.d.ts.map +1 -0
  73. package/dist/components/layout/index.d.ts +3 -0
  74. package/dist/components/layout/index.d.ts.map +1 -1
  75. package/dist/dialog/Dialog.vue.d.ts +4 -0
  76. package/dist/dialog/Dialog.vue.d.ts.map +1 -1
  77. package/dist/index.cjs +110 -116
  78. package/dist/index.mjs +38059 -37009
  79. package/dist/style.css +1 -1
  80. package/package.json +2 -1
  81. package/src/components/AccordionItem.vue +24 -22
  82. package/src/components/Avatar.vue +49 -11
  83. package/src/components/Badge.vue +4 -7
  84. package/src/components/Card.vue +32 -2
  85. package/src/components/Dropdown.vue +14 -3
  86. package/src/components/EmptyState.vue +91 -0
  87. package/src/components/Icon/Icon.vue +118 -25
  88. package/src/components/Image.vue +70 -3
  89. package/src/components/ListItem.vue +43 -22
  90. package/src/components/Menu.vue +10 -2
  91. package/src/components/charts/BarChart.vue +197 -0
  92. package/src/components/charts/ChartTooltip.vue +74 -0
  93. package/src/components/charts/Donut.vue +219 -0
  94. package/src/components/charts/Funnel.vue +377 -0
  95. package/src/components/charts/Gauge.vue +90 -0
  96. package/src/components/charts/LineChart.vue +255 -0
  97. package/src/components/charts/RadialBars.vue +99 -0
  98. package/src/components/charts/RankBars.vue +72 -0
  99. package/src/components/charts/Sparkline.vue +90 -0
  100. package/src/components/charts/StatCard.vue +84 -0
  101. package/src/components/charts/core/data.ts +95 -0
  102. package/src/components/charts/core/format.ts +64 -0
  103. package/src/components/charts/core/palette.ts +52 -0
  104. package/src/components/charts/core/uid.ts +6 -0
  105. package/src/components/charts/core/useChartAnim.ts +60 -0
  106. package/src/components/charts/core/useChartFrame.ts +49 -0
  107. package/src/components/charts/core/useScale.ts +39 -0
  108. package/src/components/charts/index.ts +12 -0
  109. package/src/components/form/inputs/RadioGroup.vue +2 -1
  110. package/src/components/form/inputs/RangeInput.vue +43 -15
  111. package/src/components/form/inputs/SelectInput.vue +1 -19
  112. package/src/components/index.ts +3 -1
  113. package/src/components/layout/Timeline.types.ts +9 -0
  114. package/src/components/layout/Timeline.vue +54 -0
  115. package/src/components/layout/TimelineItem.vue +93 -0
  116. package/src/components/layout/index.ts +3 -0
  117. package/src/dialog/Dialog.vue +29 -1
  118. package/src/styles/bagel.css +1 -0
  119. package/src/styles/gradients.css +181 -0
  120. package/src/styles/layout.css +9 -0
  121. package/src/styles/theme.css +1 -1
  122. package/dist/components/analytics/BarChart.vue.d.ts +0 -47
  123. package/dist/components/analytics/BarChart.vue.d.ts.map +0 -1
  124. package/dist/components/analytics/KpiCard.vue.d.ts +0 -24
  125. package/dist/components/analytics/KpiCard.vue.d.ts.map +0 -1
  126. package/dist/components/analytics/LineChart.vue.d.ts +0 -35
  127. package/dist/components/analytics/LineChart.vue.d.ts.map +0 -1
  128. package/dist/components/analytics/PieChart.vue.d.ts +0 -53
  129. package/dist/components/analytics/PieChart.vue.d.ts.map +0 -1
  130. package/dist/components/analytics/index.d.ts +0 -5
  131. package/dist/components/analytics/index.d.ts.map +0 -1
  132. package/src/components/analytics/BarChart.vue +0 -262
  133. package/src/components/analytics/KpiCard.vue +0 -84
  134. package/src/components/analytics/LineChart.vue +0 -357
  135. package/src/components/analytics/PieChart.vue +0 -544
  136. package/src/components/analytics/index.ts +0 -4
@@ -0,0 +1,219 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Donut — donut or pie chart with optional center label. Theme-driven slices.
4
+ *
5
+ * <Donut :data="[{label:'Direct',value:60},{label:'Referral',value:40}]" />
6
+ * <Donut :data="segments" :thickness="0.5" center-label="Total" :center-value="100" />
7
+ * <Donut :data="segments" pie :legend="false" />
8
+ * <Donut :data="segments" labels="both" /> // value + % on the slices
9
+ * <Donut :data="segments" pie labels="full" /> // name + value + % stacked
10
+ * <Donut :data="segments" :legend-value="false" /> // legend shows only %
11
+ */
12
+ import { computed, ref } from 'vue'
13
+ import { ramp, resolveColor } from './core/palette'
14
+ import { formatValue, type ValueFormat } from './core/format'
15
+ import { useChartAnim } from './core/useChartAnim'
16
+ import ChartTooltip from './ChartTooltip.vue'
17
+ import type { ThemeType } from '../../types'
18
+
19
+ defineOptions({ name: 'BglDonut' })
20
+
21
+ interface Slice { label: string; value: number; color?: string }
22
+
23
+ const props = withDefaults(defineProps<{
24
+ data: Slice[]
25
+ size?: number
26
+ /** Ring thickness as a fraction of radius (0–1). Ignored when `pie`. */
27
+ thickness?: number
28
+ pie?: boolean
29
+ legend?: boolean
30
+ /** Show the formatted amount column in the legend. Default true. */
31
+ legendValue?: boolean
32
+ /** Show the percentage column in the legend. Default true. */
33
+ legendPct?: boolean
34
+ /** Base tone for the slices. Drives the monochrome ramp in `shades` mode. */
35
+ color?: ThemeType | string
36
+ /** Coloring: `shades` = one tone, lightest→darkest (default, looks premium);
37
+ `multi` = a different tone per slice. Per-slice `color` always wins. */
38
+ mode?: 'shades' | 'multi'
39
+ centerLabel?: string
40
+ centerValue?: number | string
41
+ /** Auto-fill center with the total when no centerValue given. */
42
+ showTotal?: boolean
43
+ currency?: string
44
+ /** On-slice labels. `false` (default) keeps the chart clean.
45
+ `pct` = %, `value` = amount, `both` = value + %, `full` = name + value + %.
46
+ Each piece is stacked: name (small), value (large), pct (smaller).
47
+ Tiny slices are skipped. */
48
+ labels?: false | 'pct' | 'value' | 'both' | 'full'
49
+ /** Hide on-slice labels for slices below this share (0–1). Default 0.05. */
50
+ labelMinShare?: number
51
+ animated?: boolean
52
+ }>(), {
53
+ size: 180,
54
+ thickness: 0.38,
55
+ legend: true,
56
+ legendValue: true,
57
+ legendPct: true,
58
+ color: 'primary',
59
+ mode: 'shades',
60
+ showTotal: false,
61
+ labels: false,
62
+ labelMinShare: 0.05,
63
+ animated: true,
64
+ })
65
+
66
+ const el = ref<HTMLElement>()
67
+ const { progress } = useChartAnim({ el, enabled: props.animated, duration: 800 })
68
+
69
+ const fmt = computed<ValueFormat>(() => ({ currency: props.currency, compact: true }))
70
+ /** Formatted amount for a slice (used on-chart, in the legend + tooltip). */
71
+ function fmtValue(v: number): string {
72
+ return formatValue(v, { currency: props.currency, compact: true })
73
+ }
74
+ const total = computed(() => props.data.reduce((s, d) => s + Math.max(0, d.value), 0) || 1)
75
+ const radius = computed(() => props.size / 2)
76
+ const inner = computed(() => (props.pie ? 0 : radius.value * (1 - props.thickness)))
77
+
78
+ interface Arc {
79
+ d: string; color: string; label: string; value: number; pct: number
80
+ /** Centroid for on-slice label placement. */
81
+ lx: number; ly: number
82
+ /** Whether this slice shows an on-chart label at all. */
83
+ tagged: boolean
84
+ /** Stacked on-slice label pieces (empty string = that line is hidden). */
85
+ tagName: string; tagValue: string; tagPct: string
86
+ }
87
+ const arcs = computed<Arc[]>(() => {
88
+ const r = radius.value
89
+ const ri = inner.value
90
+ const cx = r
91
+ const cy = r
92
+ // Radius where the label centroid sits — pushed toward the outer edge
93
+ // (68% of the way from the inner edge to the rim) so labels sit near the rim.
94
+ const rMid = ri + (r - ri) * 0.68
95
+ let angle = -Math.PI / 2 // start at top
96
+ return props.data.map((d, i) => {
97
+ const frac = Math.max(0, d.value) / total.value
98
+ const sweep = frac * Math.PI * 2 * progress.value
99
+ const a0 = angle
100
+ const a1 = angle + sweep
101
+ angle = a1
102
+ const aMid = (a0 + a1) / 2
103
+ const large = sweep > Math.PI ? 1 : 0
104
+ const x0 = cx + r * Math.cos(a0)
105
+ const y0 = cy + r * Math.sin(a0)
106
+ const x1 = cx + r * Math.cos(a1)
107
+ const y1 = cy + r * Math.sin(a1)
108
+ let d2: string
109
+ if (ri > 0) {
110
+ const xi1 = cx + ri * Math.cos(a1)
111
+ const yi1 = cy + ri * Math.sin(a1)
112
+ const xi0 = cx + ri * Math.cos(a0)
113
+ const yi0 = cy + ri * Math.sin(a0)
114
+ d2 = `M ${x0} ${y0} A ${r} ${r} 0 ${large} 1 ${x1} ${y1} L ${xi1} ${yi1} A ${ri} ${ri} 0 ${large} 0 ${xi0} ${yi0} Z`
115
+ } else {
116
+ d2 = `M ${cx} ${cy} L ${x0} ${y0} A ${r} ${r} 0 ${large} 1 ${x1} ${y1} Z`
117
+ }
118
+ const sliceColor = d.color
119
+ ? resolveColor(d.color)
120
+ : props.mode === 'multi'
121
+ ? resolveColor(undefined, i)
122
+ : ramp(props.color, props.data.length, i)
123
+ const pct = frac * 100
124
+ const tagged = !!props.labels && frac >= props.labelMinShare
125
+ return {
126
+ d: d2, color: sliceColor, label: d.label, value: d.value, pct,
127
+ lx: cx + rMid * Math.cos(aMid),
128
+ ly: cy + rMid * Math.sin(aMid),
129
+ tagged,
130
+ tagName: tagged && props.labels === 'full' ? d.label : '',
131
+ tagValue: tagged && props.labels !== 'pct' ? fmtValue(d.value) : '',
132
+ tagPct: tagged && props.labels !== 'value' ? `${pct.toFixed(0)}%` : '',
133
+ }
134
+ })
135
+ })
136
+
137
+ const centerText = computed(() => {
138
+ if (props.centerValue !== undefined) return String(props.centerValue)
139
+ if (props.showTotal) return formatValue(total.value, fmt.value)
140
+ return ''
141
+ })
142
+
143
+ // ── Shared hover tooltip (slice-based, mouse-follow) ─────────────────────────
144
+ const hoverIdx = ref<number | null>(null)
145
+ const tipX = ref(0)
146
+ const tipY = ref(0)
147
+ function onSliceEnter(i: number) { hoverIdx.value = i }
148
+ function onLeave() { hoverIdx.value = null }
149
+ function onMove(e: MouseEvent) {
150
+ const host = (e.currentTarget as HTMLElement).getBoundingClientRect()
151
+ tipX.value = e.clientX - host.left
152
+ tipY.value = e.clientY - host.top
153
+ }
154
+ const hoverArc = computed(() => (hoverIdx.value == null ? null : arcs.value[hoverIdx.value]))
155
+ const hoverValue = computed(() => (hoverArc.value ? fmtValue(hoverArc.value.value) : ''))
156
+ const hoverStats = computed(() => (hoverArc.value ? [{ label: 'Share', value: `${hoverArc.value.pct.toFixed(0)}%` }] : []))
157
+ const tooltipStyle = computed(() => ({ left: `${tipX.value}px`, top: `${tipY.value}px` }))
158
+ </script>
159
+
160
+ <template>
161
+ <div
162
+ ref="el" class="bgl-donut w-100p inline-flex gap-1"
163
+ :class="legend ? 'row flex-wrap justify-content-center' : 'column'"
164
+ >
165
+ <div
166
+ class="bgl-donut__chart relative flex-shrink-0" :style="{ width: `${size}px`, height: `${size}px` }"
167
+ @mousemove="onMove" @mouseleave="onLeave"
168
+ >
169
+ <svg :width="size" :height="size" :viewBox="`0 0 ${size} ${size}`">
170
+ <path
171
+ v-for="(a, i) in arcs" :key="i"
172
+ :d="a.d" :fill="a.color" class="bgl-donut__slice cursor-pointer"
173
+ :class="{ 'opacity-5': hoverIdx != null && hoverIdx !== i }"
174
+ @mouseenter="onSliceEnter(i)"
175
+ />
176
+ <text
177
+ v-for="(a, i) in arcs" v-show="a.tagged" :key="`t${i}`"
178
+ :x="a.lx" :y="a.ly" class="bgl-donut__tag pointer-events-none"
179
+ text-anchor="middle" dominant-baseline="central"
180
+ >
181
+ <tspan v-if="a.tagName" :x="a.lx" dy="-0.7em" class="bgl-donut__tag-name opacity-9 txt-medium txt-0625rem">{{ a.tagName }}</tspan>
182
+ <tspan v-if="a.tagValue" :x="a.lx" :dy="a.tagName ? '1.15em' : '0'" class="bgl-donut__tag-val txt-bold txt-0875rem">{{ a.tagValue }}</tspan>
183
+ <tspan v-if="a.tagPct" :x="a.lx" :dy="(a.tagName || a.tagValue) ? '1.5em' : '0'" class="bgl-donut__tag-pct opacity-9 txt-05rem">{{ a.tagPct }}</tspan>
184
+ </text>
185
+ </svg>
186
+ <div v-if="!pie && (centerText || centerLabel)" class="absolute-fill flex-center column pointer-events-none gap-025">
187
+ <span v-if="centerText" class="semibold txt20 line-height-1">{{ centerText }}</span>
188
+ <span v-if="centerLabel" class="color-gray txt12">{{ centerLabel }}</span>
189
+ </div>
190
+ <ChartTooltip
191
+ v-if="hoverArc" above :style="tooltipStyle"
192
+ :label="hoverArc.label" :hero="hoverValue" :stats="hoverStats"
193
+ />
194
+ </div>
195
+
196
+ <ul v-if="legend" class="bgl-donut__legend grid gap-025">
197
+ <li v-for="(a, i) in arcs" :key="i" class="flex gap-05 txt13">
198
+ <span class="bgl-donut__dot flex-shrink-0" :style="{ background: a.color }" />
199
+ <span class="bgl-donut__legname ms-0 me-auto my-0 p-0">{{ a.label }}</span>
200
+ <span v-if="legendValue" class="semibold tabular-nums">{{ fmtValue(a.value) }}</span>
201
+ <span v-if="legendPct" class="bgl-donut__legpct color-gray tabular-nums txt-end">{{ a.pct.toFixed(0) }}%</span>
202
+ </li>
203
+ </ul>
204
+ </div>
205
+ </template>
206
+
207
+ <style scoped>
208
+ .bgl-donut__slice { transition: opacity 0.15s ease; outline: none; }
209
+ .bgl-donut__slice:focus,
210
+ .bgl-donut__slice:focus-visible { outline: none; }
211
+ .bgl-donut__tag {
212
+ fill: #fff; font-variant-numeric: tabular-nums;
213
+ paint-order: stroke; stroke: rgba(0, 0, 0, 0.28); stroke-width: 2.5px;
214
+ stroke-linejoin: round;
215
+ }
216
+ .bgl-donut__legend { list-style: none; min-width: 0; }
217
+ .bgl-donut__dot { width: 10px; height: 10px; border-radius: 3px; }
218
+ .bgl-donut__legpct { min-width: 2.5em; }
219
+ </style>
@@ -0,0 +1,377 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Funnel — conversion stages. Two looks:
4
+ * - `flow` (default): one continuous tapering silhouette, segments connected by
5
+ * smooth curved sides — feels natural for a conversion flow.
6
+ * - `:flow="false"`: discrete centered stage bars.
7
+ * Hover any segment for value + % from previous + % of top.
8
+ *
9
+ * <Funnel :data="[{label:'Visitors',value:8200},{label:'Signups',value:3100},…]" />
10
+ * <Funnel :data="stages" color="purple" :flow="false" />
11
+ * <Funnel :data="stages" orientation="horizontal" />
12
+ * <Funnel :stages="['Visitors',…]" :series="[{name:'Organic',data:[…]},…]" />
13
+ */
14
+ import { computed, ref } from 'vue'
15
+ import { ramp, resolveColor } from './core/palette'
16
+ import { formatValue, type ValueFormat } from './core/format'
17
+ import { chartUid } from './core/uid'
18
+ import ChartTooltip from './ChartTooltip.vue'
19
+ import { useChartAnim } from './core/useChartAnim'
20
+ import type { ThemeType } from '../../types'
21
+
22
+ defineOptions({ name: 'BglFunnel' })
23
+
24
+ interface Stage { label: string; value: number; color?: ThemeType | string }
25
+ interface SourceSeries { name: string; color?: ThemeType | string; data: number[] }
26
+
27
+ const props = withDefaults(defineProps<{
28
+ /** Simple funnel: stages with a single value each. */
29
+ data?: Stage[]
30
+ /** Segmented funnel: stage labels + one series per source. Each series'
31
+ `data[i]` is that source's count at stage i. Splits each band by source. */
32
+ stages?: (string | number)[]
33
+ series?: SourceSeries[]
34
+ /** Continuous flowing silhouette (default). `false` = discrete bars. */
35
+ flow?: boolean
36
+ /** `vertical` (default): stages stack top→bottom, tapering in width.
37
+ `horizontal`: stages run left→right, tapering in height. */
38
+ orientation?: 'vertical' | 'horizontal'
39
+ /** Base tone; stages shade from strong → light down the funnel. */
40
+ color?: ThemeType | string
41
+ /** One tone per stage instead of shades. Per-stage `color` wins. */
42
+ mode?: 'shades' | 'multi'
43
+ /** Soft gradient on each segment. Default on. */
44
+ gradient?: boolean
45
+ /** Height of each stage band (px). */
46
+ bandHeight?: number
47
+ fromPrev?: boolean
48
+ fromTop?: boolean
49
+ currency?: string
50
+ prefix?: string
51
+ suffix?: string
52
+ animated?: boolean
53
+ }>(), {
54
+ flow: true,
55
+ orientation: 'vertical',
56
+ color: 'primary',
57
+ mode: 'shades',
58
+ gradient: true,
59
+ bandHeight: 52,
60
+ fromPrev: true,
61
+ fromTop: true,
62
+ animated: true,
63
+ })
64
+
65
+ const el = ref<HTMLElement>()
66
+ const { progress } = useChartAnim({ el, enabled: props.animated, duration: 700 })
67
+
68
+ const fmt = computed<ValueFormat>(() => ({ currency: props.currency, prefix: props.prefix, suffix: props.suffix }))
69
+ const uid = chartUid('funnel')
70
+
71
+ const W = 100 // viewBox width units
72
+ const VPAD = 1 // min cross-axis padding so the narrowest band stays visible
73
+
74
+ const isSegmented = computed(() => !!props.series?.length)
75
+
76
+ // Source legend (segmented mode): name + resolved tone per source.
77
+ const sources = computed(() =>
78
+ (props.series ?? []).map((s, i) => ({ name: s.name, color: resolveColor(s.color, i) })),
79
+ )
80
+
81
+ // Normalized stage list ({ label, value }) from either `data` or `stages`+`series`.
82
+ const stageData = computed<Stage[]>(() => {
83
+ if (isSegmented.value) {
84
+ const labels = props.stages ?? []
85
+ return labels.map((label, i) => ({
86
+ label: String(label),
87
+ value: (props.series ?? []).reduce((sum, s) => sum + (s.data[i] ?? 0), 0),
88
+ }))
89
+ }
90
+ return props.data ?? []
91
+ })
92
+
93
+ const stages = computed(() => {
94
+ const top = stageData.value[0]?.value || 1
95
+ return stageData.value.map((d, i) => {
96
+ const prev = stageData.value[i - 1]?.value
97
+ const base = d.color
98
+ ? resolveColor(d.color)
99
+ : props.mode === 'multi'
100
+ ? resolveColor(undefined, i)
101
+ : ramp(props.color, stageData.value.length, i)
102
+ const fromTop = (d.value / top) * 100
103
+ const fromPrev = prev ? (d.value / prev) * 100 : 100
104
+ const isFirst = i === 0
105
+ const tipRows = [`<b>${formatValue(d.value, fmt.value)}</b>`]
106
+ if (!isFirst && props.fromTop) tipRows.push(`${fromTop.toFixed(1)}% of top`)
107
+ if (!isFirst && props.fromPrev) tipRows.push(`${fromPrev.toFixed(1)}% from previous`)
108
+ // Segmented: append a per-source breakdown to the tooltip.
109
+ if (isSegmented.value) {
110
+ (props.series ?? []).forEach((s) => {
111
+ const v = s.data[i] ?? 0
112
+ if (v > 0) tipRows.push(`<span style="opacity:.7">${s.name}:</span> ${formatValue(v, fmt.value)}`)
113
+ })
114
+ }
115
+ // Structured per-source rows for the shared tooltip (segmented mode).
116
+ const rows = isSegmented.value
117
+ ? (props.series ?? []).map((s, si) => ({
118
+ name: s.name,
119
+ color: sources.value[si].color,
120
+ value: formatValue(s.data[i] ?? 0, fmt.value),
121
+ raw: s.data[i] ?? 0,
122
+ })).filter(r => r.raw > 0)
123
+ : [{ name: d.label, color: base, value: formatValue(d.value, fmt.value), raw: d.value }]
124
+ return {
125
+ label: d.label,
126
+ value: d.value,
127
+ display: formatValue(d.value, fmt.value),
128
+ frac: d.value / top, // 0–1 of the widest
129
+ fromTop,
130
+ fromPrev,
131
+ isFirst,
132
+ color: base,
133
+ rows,
134
+ tip: `<div style="text-align:center">${d.label}<br>${tipRows.join('<br>')}</div>`,
135
+ barFill: props.gradient ? `linear-gradient(90deg, color-mix(in srgb, ${base} 82%, black), ${base})` : base,
136
+ }
137
+ })
138
+ })
139
+
140
+ // ── Flow geometry: connected curved trapezoids ───────────────────────────────
141
+ const horizontal = computed(() => props.orientation === 'horizontal')
142
+
143
+ // Main axis = the direction stages advance; cross axis = the tapering dimension.
144
+ // Vertical: main = svg height, cross = W (width).
145
+ // Horizontal: main = svg width, cross = W (height), drawn left→right.
146
+ const mainLen = computed(() => stages.value.length * props.bandHeight)
147
+ const svgW = computed(() => (horizontal.value ? mainLen.value : W))
148
+ const svgH = computed(() => (horizontal.value ? W : mainLen.value))
149
+
150
+ // Build a point in (mainPos along axis, crossOffset from center) → "x y".
151
+ function pt(main: number, cross: number): string {
152
+ const c = W / 2 + cross
153
+ return horizontal.value ? `${main} ${c}` : `${c} ${main}`
154
+ }
155
+
156
+ // Half cross-extent (viewBox units) for a fraction of the widest stage.
157
+ function halfW(frac: number): number {
158
+ const maxHalf = W / 2 - VPAD
159
+ return maxHalf * frac * progress.value + 0.4
160
+ }
161
+
162
+ // Curved tapering band between two cross half-widths along the main axis.
163
+ // `a`=start half-width, `b`=end half-width, offset shifts the cross center
164
+ // (used for stacked source sub-bands).
165
+ function bandPath(m0: number, m1: number, aL: number, aR: number, bL: number, bR: number): string {
166
+ const mm = (m0 + m1) / 2
167
+ return `M ${pt(m0, aL)} L ${pt(m0, aR)} `
168
+ + `C ${pt(mm, aR)} ${pt(mm, bR)} ${pt(m1, bR)} `
169
+ + `L ${pt(m1, bL)} `
170
+ + `C ${pt(mm, bL)} ${pt(mm, aL)} ${pt(m0, aL)} Z`
171
+ }
172
+
173
+ const segments = computed(() => {
174
+ const bh = props.bandHeight
175
+ return stages.value.map((s, i) => {
176
+ const next = stages.value[i + 1] ?? s
177
+ const m0 = i * bh
178
+ const m1 = (i + 1) * bh
179
+ const a = halfW(s.frac)
180
+ const b = halfW(next.frac)
181
+ return {
182
+ ...s,
183
+ path: bandPath(m0, m1, -a, a, -b, b),
184
+ gradId: `${uid}-${i}`,
185
+ }
186
+ })
187
+ })
188
+
189
+ // ── Segmented geometry: each band split by source along the cross axis ────────
190
+ const subSegments = computed(() => {
191
+ if (!isSegmented.value) return []
192
+ const bh = props.bandHeight
193
+ const series = props.series ?? []
194
+ const out: { path: string; color: string; key: string }[] = []
195
+ stages.value.forEach((s, i) => {
196
+ const isLast = i === stages.value.length - 1
197
+ const next = stages.value[i + 1] ?? s
198
+ const m0 = i * bh
199
+ const m1 = (i + 1) * bh
200
+ const totA = s.value || 1
201
+ const totB = next.value || 1
202
+ const aL = -halfW(s.frac)
203
+ const widthA = halfW(s.frac) * 2
204
+ const bL = -halfW(next.frac)
205
+ const widthB = halfW(next.frac) * 2
206
+ let accA = 0
207
+ let accB = 0
208
+ series.forEach((src, si) => {
209
+ const shareA = (src.data[i] ?? 0) / totA
210
+ // Last band has no "next" — keep its bottom equal to its top.
211
+ const shareB = isLast ? shareA : (src.data[i + 1] ?? 0) / totB
212
+ const a0 = aL + accA * widthA
213
+ const a1 = aL + (accA + shareA) * widthA
214
+ const b0 = bL + accB * widthB
215
+ const b1 = bL + (accB + shareB) * widthB
216
+ accA += shareA
217
+ accB += shareB
218
+ out.push({ path: bandPath(m0, m1, a0, a1, b0, b1), color: sources.value[si].color, key: `${i}-${si}` })
219
+ })
220
+ })
221
+ return out
222
+ })
223
+
224
+ // ── Shared hover tooltip (matches LineChart) ─────────────────────────────────
225
+ const hoverIdx = ref<number | null>(null)
226
+ const tipX = ref(0)
227
+ const tipY = ref(0)
228
+
229
+ function onSegEnter(i: number) { hoverIdx.value = i }
230
+ function onSegLeave() { hoverIdx.value = null }
231
+ function onFlowMove(e: MouseEvent) {
232
+ const host = (e.currentTarget as HTMLElement).getBoundingClientRect()
233
+ tipX.value = e.clientX - host.left
234
+ tipY.value = e.clientY - host.top
235
+ }
236
+
237
+ const hoverStage = computed(() => (hoverIdx.value == null ? null : stages.value[hoverIdx.value]))
238
+ const hoverStats = computed(() => {
239
+ const s = hoverStage.value
240
+ if (!s) return [] as { label: string; value: string }[]
241
+ const out: { label: string; value: string }[] = []
242
+ if (!s.isFirst && props.fromPrev) out.push({ label: 'From previous', value: `${s.fromPrev.toFixed(1)}%` })
243
+ if (!s.isFirst && props.fromTop) out.push({ label: 'Of top', value: `${s.fromTop.toFixed(1)}%` })
244
+ return out
245
+ })
246
+ const hoverTotal = computed(() => {
247
+ const s = hoverStage.value
248
+ return s && isSegmented.value ? s.display : null
249
+ })
250
+ const tooltipStyle = computed(() => ({ left: `${tipX.value}px`, top: `${tipY.value}px` }))
251
+ </script>
252
+
253
+ <template>
254
+ <div ref="el" class="bgl-funnel w-100p relative" :class="[{ 'bgl-funnel--bars': !flow }, flow ? `bgl-funnel--${orientation}` : '']">
255
+ <!-- Flowing silhouette + axis labels -->
256
+ <div v-if="flow" class="bgl-funnel__flow relative" @mousemove="onFlowMove" @mouseleave="onSegLeave">
257
+ <svg
258
+ class="bgl-funnel__svg display-block" :viewBox="`0 0 ${svgW} ${svgH}`"
259
+ :style="horizontal ? { height: `${bandHeight * 3}px` } : { height: `${svgH}px` }"
260
+ preserveAspectRatio="none"
261
+ >
262
+ <defs v-if="gradient && !isSegmented">
263
+ <linearGradient
264
+ v-for="(s, i) in segments" :id="s.gradId" :key="i"
265
+ :x1="0" :y1="0" :x2="horizontal ? 0 : 1" :y2="horizontal ? 1 : 0"
266
+ >
267
+ <stop offset="0%" :stop-color="`color-mix(in srgb, ${s.color} 78%, black)`" />
268
+ <stop offset="100%" :stop-color="s.color" />
269
+ </linearGradient>
270
+ </defs>
271
+
272
+ <!-- Simple: one band per stage -->
273
+ <template v-if="!isSegmented">
274
+ <path
275
+ v-for="(s, i) in segments" :key="i"
276
+ :d="s.path" :fill="gradient ? `url(#${s.gradId})` : s.color"
277
+ class="bgl-funnel__seg" :class="{ 'is-dim opacity-5': hoverIdx != null && hoverIdx !== i }"
278
+ @mouseenter="onSegEnter(i)"
279
+ />
280
+ </template>
281
+
282
+ <!-- Segmented: source sub-paths + a transparent band hit-area -->
283
+ <template v-else>
284
+ <path v-for="sub in subSegments" :key="sub.key" :d="sub.path" :fill="sub.color" class="bgl-funnel__sub" />
285
+ <path
286
+ v-for="(s, i) in segments" :key="`hit${i}`"
287
+ :d="s.path" fill="transparent" class="bgl-funnel__hit"
288
+ @mouseenter="onSegEnter(i)"
289
+ />
290
+ </template>
291
+ </svg>
292
+
293
+ <!-- Shared tooltip -->
294
+ <ChartTooltip
295
+ v-if="hoverStage" above :style="tooltipStyle"
296
+ :label="hoverStage.label"
297
+ :hero="isSegmented ? undefined : hoverStage.display"
298
+ :rows="isSegmented ? hoverStage.rows : []"
299
+ :total="isSegmented ? hoverTotal : null"
300
+ :stats="hoverStats"
301
+ />
302
+
303
+ <!-- Axis labels: left gutter (vertical) or below each stage (horizontal) -->
304
+ <div class="bgl-funnel__axis">
305
+ <div
306
+ v-for="(s, i) in segments" :key="i" class="bgl-funnel__tick"
307
+ :style="horizontal ? { flex: '1 1 0' } : { height: `${bandHeight}px` }"
308
+ >
309
+ <span class="bgl-funnel__name ellipsis-1" :title="s.label">{{ s.label }}</span>
310
+ <span class="bgl-funnel__val">{{ s.display }}</span>
311
+ </div>
312
+ </div>
313
+ </div>
314
+
315
+ <!-- Source legend (segmented) -->
316
+ <ul v-if="flow && isSegmented" class="bgl-funnel__legend flex flex-wrap justify-content-center">
317
+ <li v-for="(src, i) in sources" :key="i" class="flex align-items-center">
318
+ <span class="bgl-funnel__dot" :style="{ background: src.color }" />{{ src.name }}
319
+ </li>
320
+ </ul>
321
+
322
+ <!-- Discrete bars (flow=false) -->
323
+ <template v-if="!flow">
324
+ <div v-for="(s, i) in stages" :key="i" class="bgl-funnel__stage">
325
+ <span class="bgl-funnel__name txt-0875rem ellipsis-1" :title="s.label">{{ s.label }}</span>
326
+ <div class="bgl-funnel__bar-wrap flex justify-content-center">
327
+ <div
328
+ v-tooltip="{ content: s.tip, html: true }"
329
+ class="bgl-funnel__bar flex flex-center" :style="{ width: `${Math.max(4, s.fromTop)}%`, background: s.barFill }"
330
+ >
331
+ <span class="bgl-funnel__val semibold">{{ s.display }}</span>
332
+ </div>
333
+ </div>
334
+ </div>
335
+ </template>
336
+ </div>
337
+ </template>
338
+
339
+ <style scoped>
340
+ /* Flow */
341
+ .bgl-funnel__seg { transition: opacity 0.15s ease; outline: none; }
342
+ .bgl-funnel__seg:hover { opacity: 0.85; cursor: default; }
343
+ .bgl-funnel__seg:focus, .bgl-funnel__sub:focus, .bgl-funnel__hit:focus { outline: none; }
344
+ .bgl-funnel__sub { stroke: var(--bgl-box-bg); stroke-width: 0.4; outline: none; }
345
+ .bgl-funnel__hit { cursor: default; outline: none; }
346
+ .bgl-funnel__hit:hover { fill: rgba(255, 255, 255, 0.06); }
347
+ .bgl-funnel__legend { list-style: none; margin: 0.75rem 0 0; padding: 0; gap: 0.5rem 1rem; font-size: 0.8rem; }
348
+ .bgl-funnel__legend li { gap: 0.4rem; }
349
+ .bgl-funnel__dot { width: 10px; height: 10px; border-radius: 3px; }
350
+ .bgl-funnel__val { font-variant-numeric: tabular-nums; }
351
+
352
+ /* Vertical flow: silhouette (fills) + right axis gutter */
353
+ .bgl-funnel--vertical .bgl-funnel__flow { display: flex; align-items: stretch; gap: 0.75rem; width: 100%; }
354
+ .bgl-funnel--vertical .bgl-funnel__svg { flex: 1 1 auto; width: auto; min-width: 0; }
355
+ .bgl-funnel--vertical .bgl-funnel__axis { flex: 0 0 auto; display: flex; flex-direction: column; }
356
+ .bgl-funnel--vertical .bgl-funnel__tick {
357
+ display: flex; flex-direction: column; justify-content: center; line-height: 1.15; overflow: hidden; max-width: 12rem;
358
+ }
359
+
360
+ /* Horizontal flow: silhouette on top, labels below each stage */
361
+ .bgl-funnel--horizontal .bgl-funnel__flow { display: flex; flex-direction: column; gap: 0.4rem; }
362
+ .bgl-funnel--horizontal .bgl-funnel__svg { width: 100%; }
363
+ .bgl-funnel--horizontal .bgl-funnel__axis { display: flex; }
364
+ .bgl-funnel--horizontal .bgl-funnel__tick {
365
+ display: flex; flex-direction: column; align-items: center; text-align: center; line-height: 1.15; overflow: hidden; padding: 0 0.25rem;
366
+ }
367
+
368
+ /* Bars */
369
+ .bgl-funnel--bars { display: grid; gap: 0.5rem; }
370
+ .bgl-funnel__stage { display: grid; grid-template-columns: minmax(60px, 22%) 1fr; align-items: center; gap: 0.75rem; }
371
+ .bgl-funnel--bars .bgl-funnel__name { color: var(--bgl-text); font-weight: 500; }
372
+ .bgl-funnel__bar {
373
+ height: 38px; border-radius: 8px; color: var(--bgl-white); font-weight: 600; font-size: 0.85rem;
374
+ transition: width 0.6s cubic-bezier(0.22, 1, 0.36, 1);
375
+ min-width: 2.5rem; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); cursor: default;
376
+ }
377
+ </style>
@@ -0,0 +1,90 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Gauge — radial progress for a single value (0–max). Great for KPIs, capacity,
4
+ * scores. Defaults to a 270° arc; pass `full` for a complete ring.
5
+ *
6
+ * <Gauge :value="72" />
7
+ * <Gauge :value="4.6" :max="5" color="green" label="Rating" suffix="/5" />
8
+ */
9
+ import { computed, ref } from 'vue'
10
+ import { alpha, resolveColor } from './core/palette'
11
+ import { formatValue } from './core/format'
12
+ import { useChartAnim } from './core/useChartAnim'
13
+
14
+ defineOptions({ name: 'BglGauge' })
15
+
16
+ const props = withDefaults(defineProps<{
17
+ value: number
18
+ max?: number
19
+ min?: number
20
+ size?: number
21
+ thickness?: number
22
+ color?: string
23
+ label?: string
24
+ /** Full 360° ring instead of a 270° gauge. */
25
+ full?: boolean
26
+ /** Show the value text in the center. Default true. */
27
+ showValue?: boolean
28
+ prefix?: string
29
+ suffix?: string
30
+ animated?: boolean
31
+ }>(), {
32
+ max: 100,
33
+ min: 0,
34
+ size: 160,
35
+ thickness: 12,
36
+ showValue: true,
37
+ animated: true,
38
+ })
39
+
40
+ const el = ref<HTMLElement>()
41
+ const { progress } = useChartAnim({ el, enabled: props.animated, duration: 900 })
42
+
43
+ const stroke = computed(() => resolveColor(props.color))
44
+ const r = computed(() => (props.size - props.thickness) / 2)
45
+ const cx = computed(() => props.size / 2)
46
+ const arcSpan = computed(() => (props.full ? 360 : 270))
47
+ const startAngle = computed(() => (props.full ? -90 : 135)) // degrees
48
+
49
+ const frac = computed(() => {
50
+ const range = props.max - props.min || 1
51
+ return Math.min(1, Math.max(0, (props.value - props.min) / range))
52
+ })
53
+
54
+ function polar(angleDeg: number): { x: number; y: number } {
55
+ const a = (angleDeg * Math.PI) / 180
56
+ return { x: cx.value + r.value * Math.cos(a), y: cx.value + r.value * Math.sin(a) }
57
+ }
58
+
59
+ function arcPath(fraction: number): string {
60
+ const span = arcSpan.value * fraction
61
+ if (span <= 0) return ''
62
+ const a0 = startAngle.value
63
+ const a1 = startAngle.value + span
64
+ const p0 = polar(a0)
65
+ const p1 = polar(a1)
66
+ const large = span > 180 ? 1 : 0
67
+ return `M ${p0.x} ${p0.y} A ${r.value} ${r.value} 0 ${large} 1 ${p1.x} ${p1.y}`
68
+ }
69
+
70
+ const trackPath = computed(() => arcPath(1))
71
+ const valuePath = computed(() => arcPath(frac.value * progress.value))
72
+ const valueText = computed(() => formatValue(props.value, { prefix: props.prefix, suffix: props.suffix }))
73
+ </script>
74
+
75
+ <template>
76
+ <div ref="el" class="bgl-gauge relative inline-block" :style="{ width: `${size}px`, height: `${size}px` }">
77
+ <svg :width="size" :height="size" :viewBox="`0 0 ${size} ${size}`" class="display-block">
78
+ <path :d="trackPath" :stroke="alpha(stroke, 14)" :stroke-width="thickness" fill="none" stroke-linecap="round" />
79
+ <path :d="valuePath" :stroke="stroke" :stroke-width="thickness" fill="none" stroke-linecap="round" />
80
+ </svg>
81
+ <div v-if="showValue || label" class="absolute-fill flex-center column pointer-events-none gap-025">
82
+ <span v-if="showValue" class="semibold txt24 line-height-1">{{ valueText }}</span>
83
+ <span v-if="label" class="color-gray txt12">{{ label }}</span>
84
+ </div>
85
+ </div>
86
+ </template>
87
+
88
+ <style scoped>
89
+ /* Layout, sizing and text all come from utility classes in the template. */
90
+ </style>