@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.
- package/dist/components/AccordionItem.vue.d.ts.map +1 -1
- package/dist/components/Avatar.vue.d.ts +6 -1
- package/dist/components/Avatar.vue.d.ts.map +1 -1
- package/dist/components/Badge.vue.d.ts.map +1 -1
- package/dist/components/Card.vue.d.ts +7 -0
- package/dist/components/Card.vue.d.ts.map +1 -1
- package/dist/components/Dropdown.vue.d.ts.map +1 -1
- package/dist/components/EmptyState.vue.d.ts +43 -0
- package/dist/components/EmptyState.vue.d.ts.map +1 -0
- package/dist/components/Icon/Icon.vue.d.ts +13 -0
- package/dist/components/Icon/Icon.vue.d.ts.map +1 -1
- package/dist/components/Image.vue.d.ts +26 -1
- package/dist/components/Image.vue.d.ts.map +1 -1
- package/dist/components/ListItem.vue.d.ts +9 -9
- package/dist/components/ListItem.vue.d.ts.map +1 -1
- package/dist/components/Menu.vue.d.ts.map +1 -1
- package/dist/components/Swiper.vue.d.ts +3 -3
- package/dist/components/calendar/CalendarPopover.vue.d.ts +10 -0
- package/dist/components/calendar/CalendarPopover.vue.d.ts.map +1 -1
- package/dist/components/charts/BarChart.vue.d.ts +34 -0
- package/dist/components/charts/BarChart.vue.d.ts.map +1 -0
- package/dist/components/charts/ChartTooltip.vue.d.ts +33 -0
- package/dist/components/charts/ChartTooltip.vue.d.ts.map +1 -0
- package/dist/components/charts/Donut.vue.d.ts +53 -0
- package/dist/components/charts/Donut.vue.d.ts.map +1 -0
- package/dist/components/charts/Funnel.vue.d.ts +53 -0
- package/dist/components/charts/Funnel.vue.d.ts.map +1 -0
- package/dist/components/charts/Gauge.vue.d.ts +28 -0
- package/dist/components/charts/Gauge.vue.d.ts.map +1 -0
- package/dist/components/charts/LineChart.vue.d.ts +37 -0
- package/dist/components/charts/LineChart.vue.d.ts.map +1 -0
- package/dist/components/charts/RadialBars.vue.d.ts +34 -0
- package/dist/components/charts/RadialBars.vue.d.ts.map +1 -0
- package/dist/components/charts/RankBars.vue.d.ts +27 -0
- package/dist/components/charts/RankBars.vue.d.ts.map +1 -0
- package/dist/components/charts/Sparkline.vue.d.ts +25 -0
- package/dist/components/charts/Sparkline.vue.d.ts.map +1 -0
- package/dist/components/charts/StatCard.vue.d.ts +28 -0
- package/dist/components/charts/StatCard.vue.d.ts.map +1 -0
- package/dist/components/charts/core/data.d.ts +46 -0
- package/dist/components/charts/core/data.d.ts.map +1 -0
- package/dist/components/charts/core/format.d.ts +13 -0
- package/dist/components/charts/core/format.d.ts.map +1 -0
- package/dist/components/charts/core/palette.d.ts +19 -0
- package/dist/components/charts/core/palette.d.ts.map +1 -0
- package/dist/components/charts/core/uid.d.ts +2 -0
- package/dist/components/charts/core/uid.d.ts.map +1 -0
- package/dist/components/charts/core/useChartAnim.d.ts +11 -0
- package/dist/components/charts/core/useChartAnim.d.ts.map +1 -0
- package/dist/components/charts/core/useChartFrame.d.ts +21 -0
- package/dist/components/charts/core/useChartFrame.d.ts.map +1 -0
- package/dist/components/charts/core/useScale.d.ts +16 -0
- package/dist/components/charts/core/useScale.d.ts.map +1 -0
- package/dist/components/charts/index.d.ts +12 -0
- package/dist/components/charts/index.d.ts.map +1 -0
- package/dist/components/form/inputs/RadioGroup.vue.d.ts +1 -0
- package/dist/components/form/inputs/RadioGroup.vue.d.ts.map +1 -1
- package/dist/components/form/inputs/RangeInput.vue.d.ts +13 -4
- package/dist/components/form/inputs/RangeInput.vue.d.ts.map +1 -1
- package/dist/components/form/inputs/SelectInput.vue.d.ts.map +1 -1
- package/dist/components/index.d.ts +3 -1
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/layout/Layout.vue.d.ts +1 -1
- package/dist/components/layout/Layout.vue.d.ts.map +1 -1
- package/dist/components/layout/Panel.vue.d.ts +1 -1
- package/dist/components/layout/Panel.vue.d.ts.map +1 -1
- package/dist/components/layout/Timeline.types.d.ts +9 -0
- package/dist/components/layout/Timeline.types.d.ts.map +1 -0
- package/dist/components/layout/Timeline.vue.d.ts +42 -0
- package/dist/components/layout/Timeline.vue.d.ts.map +1 -0
- package/dist/components/layout/TimelineItem.vue.d.ts +37 -0
- package/dist/components/layout/TimelineItem.vue.d.ts.map +1 -0
- package/dist/components/layout/index.d.ts +3 -0
- package/dist/components/layout/index.d.ts.map +1 -1
- package/dist/dialog/Dialog.vue.d.ts +4 -0
- package/dist/dialog/Dialog.vue.d.ts.map +1 -1
- package/dist/index.cjs +110 -116
- package/dist/index.mjs +38059 -37009
- package/dist/style.css +1 -1
- package/package.json +2 -1
- package/src/components/AccordionItem.vue +24 -22
- package/src/components/Avatar.vue +49 -11
- package/src/components/Badge.vue +4 -7
- package/src/components/Card.vue +32 -2
- package/src/components/Dropdown.vue +14 -3
- package/src/components/EmptyState.vue +91 -0
- package/src/components/Icon/Icon.vue +118 -25
- package/src/components/Image.vue +70 -3
- package/src/components/ListItem.vue +43 -22
- package/src/components/Menu.vue +10 -2
- package/src/components/charts/BarChart.vue +197 -0
- package/src/components/charts/ChartTooltip.vue +74 -0
- package/src/components/charts/Donut.vue +219 -0
- package/src/components/charts/Funnel.vue +377 -0
- package/src/components/charts/Gauge.vue +90 -0
- package/src/components/charts/LineChart.vue +255 -0
- package/src/components/charts/RadialBars.vue +99 -0
- package/src/components/charts/RankBars.vue +72 -0
- package/src/components/charts/Sparkline.vue +90 -0
- package/src/components/charts/StatCard.vue +84 -0
- package/src/components/charts/core/data.ts +95 -0
- package/src/components/charts/core/format.ts +64 -0
- package/src/components/charts/core/palette.ts +52 -0
- package/src/components/charts/core/uid.ts +6 -0
- package/src/components/charts/core/useChartAnim.ts +60 -0
- package/src/components/charts/core/useChartFrame.ts +49 -0
- package/src/components/charts/core/useScale.ts +39 -0
- package/src/components/charts/index.ts +12 -0
- package/src/components/form/inputs/RadioGroup.vue +2 -1
- package/src/components/form/inputs/RangeInput.vue +43 -15
- package/src/components/form/inputs/SelectInput.vue +1 -19
- package/src/components/index.ts +3 -1
- package/src/components/layout/Timeline.types.ts +9 -0
- package/src/components/layout/Timeline.vue +54 -0
- package/src/components/layout/TimelineItem.vue +93 -0
- package/src/components/layout/index.ts +3 -0
- package/src/dialog/Dialog.vue +29 -1
- package/src/styles/bagel.css +1 -0
- package/src/styles/gradients.css +181 -0
- package/src/styles/layout.css +9 -0
- package/src/styles/theme.css +1 -1
- package/dist/components/analytics/BarChart.vue.d.ts +0 -47
- package/dist/components/analytics/BarChart.vue.d.ts.map +0 -1
- package/dist/components/analytics/KpiCard.vue.d.ts +0 -24
- package/dist/components/analytics/KpiCard.vue.d.ts.map +0 -1
- package/dist/components/analytics/LineChart.vue.d.ts +0 -35
- package/dist/components/analytics/LineChart.vue.d.ts.map +0 -1
- package/dist/components/analytics/PieChart.vue.d.ts +0 -53
- package/dist/components/analytics/PieChart.vue.d.ts.map +0 -1
- package/dist/components/analytics/index.d.ts +0 -5
- package/dist/components/analytics/index.d.ts.map +0 -1
- package/src/components/analytics/BarChart.vue +0 -262
- package/src/components/analytics/KpiCard.vue +0 -84
- package/src/components/analytics/LineChart.vue +0 -357
- package/src/components/analytics/PieChart.vue +0 -544
- 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>
|