nuxt-ui-basekit 0.1.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.
- package/LICENSE +21 -0
- package/README.md +159 -0
- package/app/assets/css/basekit.css +86 -0
- package/app/components/BaseKitBackLink.vue +41 -0
- package/app/components/BaseKitChoiceCard.vue +107 -0
- package/app/components/BaseKitConfirmModal.vue +55 -0
- package/app/components/BaseKitDataTable.vue +293 -0
- package/app/components/BaseKitEmptyState.vue +39 -0
- package/app/components/BaseKitFileUpload.vue +90 -0
- package/app/components/BaseKitIconPicker.vue +130 -0
- package/app/components/BaseKitMarkdownEditor.vue +112 -0
- package/app/components/BaseKitPending.vue +54 -0
- package/app/components/BaseKitRecordPicker.vue +154 -0
- package/app/components/BaseKitSettingRow.vue +66 -0
- package/app/components/BaseKitStatTile.vue +64 -0
- package/app/components/BaseKitTabs.vue +161 -0
- package/app/components/BaseKitViewLink.vue +53 -0
- package/app/components/charts/BaseKitChartBars.vue +87 -0
- package/app/components/charts/BaseKitChartColumns.vue +319 -0
- package/app/components/charts/BaseKitChartDonut.vue +175 -0
- package/app/components/charts/BaseKitChartFigure.vue +87 -0
- package/app/components/charts/BaseKitChartMeter.vue +57 -0
- package/app/composables/useBaseKit.ts +155 -0
- package/app/composables/useChartPalette.ts +89 -0
- package/app/composables/useChartWidth.ts +41 -0
- package/app/composables/useConfirm.ts +16 -0
- package/app/stores/confirm.ts +56 -0
- package/app/utils/html-to-markdown.ts +47 -0
- package/app/utils/markdown.ts +24 -0
- package/nuxt.config.ts +43 -0
- package/package.json +78 -0
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Gestapelte Säulen über einer Zeitachse — „was ist je Tag entstanden".
|
|
4
|
+
*
|
|
5
|
+
* Warum gestapelt und nicht mehrere Linien: die Frage ist Anteil am Ganzen,
|
|
6
|
+
* nicht Verlauf einzelner Reihen. Und warum Säulen und keine Fläche: Tageswerte
|
|
7
|
+
* sind gezählte Ereignisse, keine stetige Größe — eine Fläche würde zwischen
|
|
8
|
+
* zwei Tagen interpolieren und behaupten, dass um 12 Uhr nachts etwas
|
|
9
|
+
* halb passiert ist.
|
|
10
|
+
*
|
|
11
|
+
* Trennung zwischen den Segmenten passiert über eine 2-px-Lücke in der
|
|
12
|
+
* Kartenfarbe, nicht über einen Rahmen: ein Rahmen ist Tinte, die keine Daten
|
|
13
|
+
* trägt, und bei dünnen Segmenten überdeckt er den Wert.
|
|
14
|
+
*
|
|
15
|
+
* Höchstens fünf Reihen — siehe `useChartPalette`.
|
|
16
|
+
*/
|
|
17
|
+
import { computed, ref } from 'vue'
|
|
18
|
+
import { baseKitChartColor, useChartFormat } from '../../composables/useChartPalette'
|
|
19
|
+
import { useChartWidth } from '../../composables/useChartWidth'
|
|
20
|
+
|
|
21
|
+
interface ColumnSeries {
|
|
22
|
+
key: string
|
|
23
|
+
label: string
|
|
24
|
+
points: number[]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const props = withDefaults(defineProps<{
|
|
28
|
+
/** Beschriftung je Säule, z. B. ISO-Tage. */
|
|
29
|
+
categories: string[]
|
|
30
|
+
series: ColumnSeries[]
|
|
31
|
+
height?: number
|
|
32
|
+
/** Achsenbeschriftung (kurz) aus einer Kategorie. */
|
|
33
|
+
formatCategory?: (value: string) => string
|
|
34
|
+
/** Ausführliche Fassung für den Tooltip. */
|
|
35
|
+
formatCategoryLong?: (value: string) => string
|
|
36
|
+
ariaLabel?: string
|
|
37
|
+
}>(), {
|
|
38
|
+
height: 200,
|
|
39
|
+
formatCategory: (value: string) => value,
|
|
40
|
+
formatCategoryLong: undefined,
|
|
41
|
+
ariaLabel: undefined,
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
const { number } = useChartFormat()
|
|
45
|
+
const { el, width } = useChartWidth()
|
|
46
|
+
|
|
47
|
+
const PADDING = { top: 10, right: 8, bottom: 22, left: 40 }
|
|
48
|
+
const MAX_COLUMN = 14
|
|
49
|
+
const GAP = 2
|
|
50
|
+
|
|
51
|
+
const active = ref<number | null>(null)
|
|
52
|
+
|
|
53
|
+
const plot = computed(() => {
|
|
54
|
+
const innerWidth = Math.max(60, width.value - PADDING.left - PADDING.right)
|
|
55
|
+
const innerHeight = Math.max(40, props.height - PADDING.top - PADDING.bottom)
|
|
56
|
+
const count = Math.max(1, props.categories.length)
|
|
57
|
+
const band = innerWidth / count
|
|
58
|
+
return {
|
|
59
|
+
innerWidth,
|
|
60
|
+
innerHeight,
|
|
61
|
+
band,
|
|
62
|
+
columnWidth: Math.max(2, Math.min(MAX_COLUMN, band - 3)),
|
|
63
|
+
baseline: PADDING.top + innerHeight,
|
|
64
|
+
}
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
/** Tagessummen — sie bestimmen die Skala und stehen im Tooltip. */
|
|
68
|
+
const totals = computed(() =>
|
|
69
|
+
props.categories.map((_, index) =>
|
|
70
|
+
props.series.reduce((sum, s) => sum + (s.points[index] ?? 0), 0),
|
|
71
|
+
),
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Obere Achsengrenze auf eine runde Zahl aufgerundet. Ohne das steht die
|
|
76
|
+
* Beschriftung auf krummen Werten wie 37, und die Gitterlinien tragen keine
|
|
77
|
+
* ablesbare Auskunft mehr.
|
|
78
|
+
*/
|
|
79
|
+
const scaleMax = computed(() => {
|
|
80
|
+
const peak = Math.max(0, ...totals.value)
|
|
81
|
+
if (peak === 0) return 1
|
|
82
|
+
const magnitude = 10 ** Math.floor(Math.log10(peak))
|
|
83
|
+
for (const step of [1, 2, 2.5, 5, 10]) {
|
|
84
|
+
const candidate = step * magnitude
|
|
85
|
+
if (candidate >= peak) return candidate
|
|
86
|
+
}
|
|
87
|
+
return 10 * magnitude
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
const ticks = computed(() => {
|
|
91
|
+
const max = scaleMax.value
|
|
92
|
+
return [0, max / 2, max].map(value => ({
|
|
93
|
+
value,
|
|
94
|
+
y: plot.value.baseline - (value / max) * plot.value.innerHeight,
|
|
95
|
+
label: number(Math.round(value)),
|
|
96
|
+
}))
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
function columnX(index: number): number {
|
|
100
|
+
return PADDING.left + plot.value.band * (index + 0.5) - plot.value.columnWidth / 2
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Runde Kappe nur oben — unten sitzt die Säule auf der Grundlinie auf. */
|
|
104
|
+
function cappedPath(x: number, y: number, w: number, h: number): string {
|
|
105
|
+
const r = Math.min(4, w / 2, h)
|
|
106
|
+
if (h <= 0) return ''
|
|
107
|
+
return [
|
|
108
|
+
`M ${x} ${y + h}`,
|
|
109
|
+
`L ${x} ${y + r}`,
|
|
110
|
+
`Q ${x} ${y} ${x + r} ${y}`,
|
|
111
|
+
`L ${x + w - r} ${y}`,
|
|
112
|
+
`Q ${x + w} ${y} ${x + w} ${y + r}`,
|
|
113
|
+
`L ${x + w} ${y + h}`,
|
|
114
|
+
'Z',
|
|
115
|
+
].join(' ')
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
interface Segment {
|
|
119
|
+
key: string
|
|
120
|
+
path: string
|
|
121
|
+
color: string
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const columns = computed(() => {
|
|
125
|
+
const { innerHeight, baseline, columnWidth } = plot.value
|
|
126
|
+
const max = scaleMax.value
|
|
127
|
+
|
|
128
|
+
return props.categories.map((category, index) => {
|
|
129
|
+
const segments: Segment[] = []
|
|
130
|
+
let stacked = 0
|
|
131
|
+
|
|
132
|
+
// Von unten nach oben stapeln; die oberste Reihe bekommt die Kappe.
|
|
133
|
+
const present = props.series
|
|
134
|
+
.map((s, order) => ({ s, order, value: s.points[index] ?? 0 }))
|
|
135
|
+
.filter(entry => entry.value > 0)
|
|
136
|
+
|
|
137
|
+
present.forEach((entry, position) => {
|
|
138
|
+
const bottom = baseline - (stacked / max) * innerHeight
|
|
139
|
+
stacked += entry.value
|
|
140
|
+
const top = baseline - (stacked / max) * innerHeight
|
|
141
|
+
|
|
142
|
+
const isTop = position === present.length - 1
|
|
143
|
+
// Lücke nach oben, außer beim obersten Segment — dort säße sie
|
|
144
|
+
// zwischen Säule und Luft und würde die Säule nur kürzen.
|
|
145
|
+
const height = Math.max(0, bottom - top - (isTop ? 0 : GAP))
|
|
146
|
+
if (height <= 0) return
|
|
147
|
+
|
|
148
|
+
segments.push({
|
|
149
|
+
key: entry.s.key,
|
|
150
|
+
color: baseKitChartColor(entry.order),
|
|
151
|
+
path: isTop
|
|
152
|
+
? cappedPath(columnX(index), top, columnWidth, height)
|
|
153
|
+
: `M ${columnX(index)} ${top} h ${columnWidth} v ${height} h ${-columnWidth} Z`,
|
|
154
|
+
})
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
return { category, index, segments, total: totals.value[index] ?? 0 }
|
|
158
|
+
})
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Beschriftung ausdünnen, bis sie nicht mehr kollidiert — und zwar durch
|
|
163
|
+
* Weglassen, nicht durch Verstecken: ein ausgeblendeter Text bleibt im
|
|
164
|
+
* Baum stehen und wird von Screenreadern trotzdem vorgelesen.
|
|
165
|
+
*/
|
|
166
|
+
const axisLabels = computed(() => {
|
|
167
|
+
const perLabel = 56
|
|
168
|
+
const every = Math.max(1, Math.ceil(perLabel / Math.max(1, plot.value.band)))
|
|
169
|
+
|
|
170
|
+
return props.categories
|
|
171
|
+
.map((category, index) => ({ category, index }))
|
|
172
|
+
.filter(entry => entry.index % every === 0)
|
|
173
|
+
.map(entry => ({
|
|
174
|
+
category: entry.category,
|
|
175
|
+
x: PADDING.left + plot.value.band * (entry.index + 0.5),
|
|
176
|
+
text: props.formatCategory(entry.category),
|
|
177
|
+
}))
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
const tooltip = computed(() => {
|
|
181
|
+
if (active.value === null) return null
|
|
182
|
+
const index = active.value
|
|
183
|
+
const long = props.formatCategoryLong ?? props.formatCategory
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
index,
|
|
187
|
+
title: long(props.categories[index] ?? ''),
|
|
188
|
+
total: totals.value[index] ?? 0,
|
|
189
|
+
rows: props.series
|
|
190
|
+
.map((s, order) => ({
|
|
191
|
+
key: s.key,
|
|
192
|
+
label: s.label,
|
|
193
|
+
color: baseKitChartColor(order),
|
|
194
|
+
value: s.points[index] ?? 0,
|
|
195
|
+
}))
|
|
196
|
+
.filter(row => row.value > 0),
|
|
197
|
+
// Am Rand kippt der Kasten nach innen, statt aus der Karte zu laufen.
|
|
198
|
+
anchor: PADDING.left + plot.value.band * (index + 0.5),
|
|
199
|
+
}
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
function readout(index: number): string {
|
|
203
|
+
const long = props.formatCategoryLong ?? props.formatCategory
|
|
204
|
+
const parts = props.series
|
|
205
|
+
.map(s => `${s.label}: ${number(s.points[index] ?? 0)}`)
|
|
206
|
+
.join(', ')
|
|
207
|
+
return `${long(props.categories[index] ?? '')} — ${parts}`
|
|
208
|
+
}
|
|
209
|
+
</script>
|
|
210
|
+
|
|
211
|
+
<template>
|
|
212
|
+
<div ref="el" class="relative w-full">
|
|
213
|
+
<svg
|
|
214
|
+
:width="width"
|
|
215
|
+
:height="props.height"
|
|
216
|
+
:viewBox="`0 0 ${width} ${props.height}`"
|
|
217
|
+
role="group"
|
|
218
|
+
:aria-label="props.ariaLabel"
|
|
219
|
+
class="block max-w-full overflow-visible"
|
|
220
|
+
>
|
|
221
|
+
<!-- Gitter: durchgezogene Haarlinien, eine Stufe von der Fläche weg. -->
|
|
222
|
+
<g>
|
|
223
|
+
<line
|
|
224
|
+
v-for="tick in ticks"
|
|
225
|
+
:key="`grid-${tick.value}`"
|
|
226
|
+
:x1="PADDING.left"
|
|
227
|
+
:x2="width - PADDING.right"
|
|
228
|
+
:y1="tick.y"
|
|
229
|
+
:y2="tick.y"
|
|
230
|
+
:stroke="tick.value === 0 ? 'var(--basekit-chart-axis)' : 'var(--basekit-chart-grid)'"
|
|
231
|
+
stroke-width="1"
|
|
232
|
+
shape-rendering="crispEdges"
|
|
233
|
+
/>
|
|
234
|
+
<text
|
|
235
|
+
v-for="tick in ticks"
|
|
236
|
+
:key="`tick-${tick.value}`"
|
|
237
|
+
:x="PADDING.left - 8"
|
|
238
|
+
:y="tick.y + 3"
|
|
239
|
+
text-anchor="end"
|
|
240
|
+
fill="currentColor"
|
|
241
|
+
class="text-dimmed text-[10px] tabular-nums"
|
|
242
|
+
>{{ tick.label }}</text>
|
|
243
|
+
</g>
|
|
244
|
+
|
|
245
|
+
<!-- Datenmarken -->
|
|
246
|
+
<g
|
|
247
|
+
v-for="column in columns"
|
|
248
|
+
:key="column.category"
|
|
249
|
+
>
|
|
250
|
+
<path
|
|
251
|
+
v-for="segment in column.segments"
|
|
252
|
+
:key="segment.key"
|
|
253
|
+
:d="segment.path"
|
|
254
|
+
:fill="segment.color"
|
|
255
|
+
:opacity="active === null || active === column.index ? 1 : 0.45"
|
|
256
|
+
class="transition-opacity"
|
|
257
|
+
/>
|
|
258
|
+
</g>
|
|
259
|
+
|
|
260
|
+
<!-- Trefferflächen: über die volle Höhe und über die ganze Bandbreite,
|
|
261
|
+
damit niemand eine 14 px schmale Säule treffen muss. -->
|
|
262
|
+
<g>
|
|
263
|
+
<rect
|
|
264
|
+
v-for="column in columns"
|
|
265
|
+
:key="`hit-${column.category}`"
|
|
266
|
+
:x="PADDING.left + plot.band * column.index"
|
|
267
|
+
:y="PADDING.top"
|
|
268
|
+
:width="plot.band"
|
|
269
|
+
:height="plot.innerHeight"
|
|
270
|
+
fill="transparent"
|
|
271
|
+
tabindex="0"
|
|
272
|
+
role="button"
|
|
273
|
+
:aria-label="readout(column.index)"
|
|
274
|
+
class="cursor-default outline-none"
|
|
275
|
+
@pointerenter="active = column.index"
|
|
276
|
+
@pointerleave="active = null"
|
|
277
|
+
@focus="active = column.index"
|
|
278
|
+
@blur="active = null"
|
|
279
|
+
/>
|
|
280
|
+
</g>
|
|
281
|
+
|
|
282
|
+
<!-- Achsenbeschriftung -->
|
|
283
|
+
<text
|
|
284
|
+
v-for="label in axisLabels"
|
|
285
|
+
:key="`label-${label.category}`"
|
|
286
|
+
:x="label.x"
|
|
287
|
+
:y="props.height - 6"
|
|
288
|
+
text-anchor="middle"
|
|
289
|
+
fill="currentColor"
|
|
290
|
+
class="text-dimmed text-[10px]"
|
|
291
|
+
>{{ label.text }}</text>
|
|
292
|
+
</svg>
|
|
293
|
+
|
|
294
|
+
<!-- Tooltip: Wert führt, Reihenname folgt. -->
|
|
295
|
+
<div
|
|
296
|
+
v-if="tooltip"
|
|
297
|
+
class="pointer-events-none absolute top-0 z-10 min-w-40 -translate-x-1/2 rounded-md border border-default bg-default p-2 shadow-lg"
|
|
298
|
+
:style="{ left: `${Math.min(Math.max(tooltip.anchor, 90), width - 90)}px` }"
|
|
299
|
+
>
|
|
300
|
+
<p class="mb-1 text-[11px] font-medium text-muted">
|
|
301
|
+
{{ tooltip.title }}
|
|
302
|
+
</p>
|
|
303
|
+
<ul class="space-y-0.5">
|
|
304
|
+
<li
|
|
305
|
+
v-for="row in tooltip.rows"
|
|
306
|
+
:key="row.key"
|
|
307
|
+
class="flex items-center gap-2 text-xs"
|
|
308
|
+
>
|
|
309
|
+
<span class="h-0.5 w-3 shrink-0 rounded-full" :style="{ backgroundColor: row.color }" />
|
|
310
|
+
<span class="font-semibold tabular-nums text-highlighted">{{ number(row.value) }}</span>
|
|
311
|
+
<span class="truncate text-muted">{{ row.label }}</span>
|
|
312
|
+
</li>
|
|
313
|
+
<li v-if="!tooltip.rows.length" class="text-xs text-muted">
|
|
314
|
+
{{ number(0) }}
|
|
315
|
+
</li>
|
|
316
|
+
</ul>
|
|
317
|
+
</div>
|
|
318
|
+
</div>
|
|
319
|
+
</template>
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Ring für Teil-vom-Ganzen bei wenigen Klassen.
|
|
4
|
+
*
|
|
5
|
+
* Bewusst eng eingesetzt: ein Ring beantwortet „wie verteilt sich das grob",
|
|
6
|
+
* nicht „welcher von zweien ist größer". Sobald zwei Segmente nahe beieinander
|
|
7
|
+
* liegen, ist ein Balken die ehrlichere Form. Höchstens vier Segmente, sonst
|
|
8
|
+
* verlieren die kleinen ihre Beschriftung.
|
|
9
|
+
*
|
|
10
|
+
* In der Mitte steht die Summe — das ist die Zahl, die zuerst gesucht wird,
|
|
11
|
+
* und sie füllt den Platz, den ein Ring ohnehin frei lässt.
|
|
12
|
+
*/
|
|
13
|
+
import { computed, ref } from 'vue'
|
|
14
|
+
import { baseKitChartColor, useChartFormat } from '../../composables/useChartPalette'
|
|
15
|
+
|
|
16
|
+
interface DonutSlice {
|
|
17
|
+
key: string
|
|
18
|
+
label: string
|
|
19
|
+
value: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const props = withDefaults(defineProps<{
|
|
23
|
+
slices: DonutSlice[]
|
|
24
|
+
/** Beschriftung unter der Summe in der Mitte. */
|
|
25
|
+
centerLabel?: string
|
|
26
|
+
size?: number
|
|
27
|
+
ariaLabel?: string
|
|
28
|
+
}>(), {
|
|
29
|
+
centerLabel: undefined,
|
|
30
|
+
size: 168,
|
|
31
|
+
ariaLabel: undefined,
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
const { number, percent } = useChartFormat()
|
|
35
|
+
|
|
36
|
+
const THICKNESS = 18
|
|
37
|
+
/** Lücke zwischen den Segmenten, in Grad — das Gegenstück zur 2-px-Lücke. */
|
|
38
|
+
const GAP_DEGREES = 2
|
|
39
|
+
|
|
40
|
+
const active = ref<string | null>(null)
|
|
41
|
+
|
|
42
|
+
const total = computed(() => props.slices.reduce((sum, slice) => sum + slice.value, 0))
|
|
43
|
+
|
|
44
|
+
const radius = computed(() => props.size / 2 - THICKNESS / 2 - 2)
|
|
45
|
+
const center = computed(() => props.size / 2)
|
|
46
|
+
|
|
47
|
+
function pointOnCircle(angle: number, r: number): [number, number] {
|
|
48
|
+
const radians = ((angle - 90) * Math.PI) / 180
|
|
49
|
+
return [center.value + r * Math.cos(radians), center.value + r * Math.sin(radians)]
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const arcs = computed(() => {
|
|
53
|
+
if (total.value <= 0) return []
|
|
54
|
+
|
|
55
|
+
let cursor = 0
|
|
56
|
+
return props.slices
|
|
57
|
+
.map((slice, order) => ({ slice, order }))
|
|
58
|
+
.filter(entry => entry.slice.value > 0)
|
|
59
|
+
.map(({ slice, order }) => {
|
|
60
|
+
const sweep = (slice.value / total.value) * 360
|
|
61
|
+
const start = cursor
|
|
62
|
+
cursor += sweep
|
|
63
|
+
|
|
64
|
+
// Ein Segment, das den ganzen Kreis füllt, hätte identischen Start- und
|
|
65
|
+
// Endpunkt — der Bogen verschwände. Dann zeichnen wir den vollen Ring.
|
|
66
|
+
const full = sweep >= 359.9
|
|
67
|
+
const gap = full ? 0 : Math.min(GAP_DEGREES, sweep / 3)
|
|
68
|
+
const from = start + gap / 2
|
|
69
|
+
const to = start + sweep - gap / 2
|
|
70
|
+
|
|
71
|
+
const [x1, y1] = pointOnCircle(from, radius.value)
|
|
72
|
+
const [x2, y2] = pointOnCircle(to, radius.value)
|
|
73
|
+
const largeArc = to - from > 180 ? 1 : 0
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
key: slice.key,
|
|
77
|
+
label: slice.label,
|
|
78
|
+
value: slice.value,
|
|
79
|
+
color: baseKitChartColor(order),
|
|
80
|
+
path: full
|
|
81
|
+
? `M ${center.value} ${center.value - radius.value} `
|
|
82
|
+
+ `a ${radius.value} ${radius.value} 0 1 1 -0.01 0`
|
|
83
|
+
: `M ${x1} ${y1} A ${radius.value} ${radius.value} 0 ${largeArc} 1 ${x2} ${y2}`,
|
|
84
|
+
}
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
const legend = computed(() =>
|
|
89
|
+
props.slices.map((slice, order) => ({
|
|
90
|
+
...slice,
|
|
91
|
+
color: baseKitChartColor(order),
|
|
92
|
+
share: percent(slice.value, total.value),
|
|
93
|
+
})),
|
|
94
|
+
)
|
|
95
|
+
</script>
|
|
96
|
+
|
|
97
|
+
<template>
|
|
98
|
+
<!-- Legende unter dem Ring, nicht daneben: die Karte steht auf der
|
|
99
|
+
Übersicht in einer schmalen Spalte, und nebeneinander blieb für die
|
|
100
|
+
Beschriftungen so wenig übrig, dass „Ausgeschieden" auf ein „A"
|
|
101
|
+
zusammenschrumpfte. Untereinander hat jede Zeile die volle Breite. -->
|
|
102
|
+
<div class="flex flex-col items-center gap-5">
|
|
103
|
+
<svg
|
|
104
|
+
:width="props.size"
|
|
105
|
+
:height="props.size"
|
|
106
|
+
:viewBox="`0 0 ${props.size} ${props.size}`"
|
|
107
|
+
role="group"
|
|
108
|
+
:aria-label="props.ariaLabel"
|
|
109
|
+
class="shrink-0"
|
|
110
|
+
>
|
|
111
|
+
<!-- Leerer Ring, wenn nichts da ist: die Form bleibt erkennbar, statt
|
|
112
|
+
dass die Karte auf eine leere Fläche zusammenfällt. -->
|
|
113
|
+
<circle
|
|
114
|
+
v-if="!arcs.length"
|
|
115
|
+
:cx="center"
|
|
116
|
+
:cy="center"
|
|
117
|
+
:r="radius"
|
|
118
|
+
fill="none"
|
|
119
|
+
stroke="var(--basekit-chart-grid)"
|
|
120
|
+
:stroke-width="THICKNESS"
|
|
121
|
+
/>
|
|
122
|
+
|
|
123
|
+
<path
|
|
124
|
+
v-for="arc in arcs"
|
|
125
|
+
:key="arc.key"
|
|
126
|
+
:d="arc.path"
|
|
127
|
+
fill="none"
|
|
128
|
+
:stroke="arc.color"
|
|
129
|
+
:stroke-width="THICKNESS"
|
|
130
|
+
stroke-linecap="butt"
|
|
131
|
+
:opacity="active === null || active === arc.key ? 1 : 0.4"
|
|
132
|
+
class="transition-opacity"
|
|
133
|
+
tabindex="0"
|
|
134
|
+
role="button"
|
|
135
|
+
:aria-label="`${arc.label}: ${number(arc.value)}`"
|
|
136
|
+
@pointerenter="active = arc.key"
|
|
137
|
+
@pointerleave="active = null"
|
|
138
|
+
@focus="active = arc.key"
|
|
139
|
+
@blur="active = null"
|
|
140
|
+
/>
|
|
141
|
+
|
|
142
|
+
<text
|
|
143
|
+
:x="center"
|
|
144
|
+
:y="center - 2"
|
|
145
|
+
text-anchor="middle"
|
|
146
|
+
fill="currentColor"
|
|
147
|
+
class="text-highlighted text-2xl font-semibold"
|
|
148
|
+
>{{ number(total) }}</text>
|
|
149
|
+
<text
|
|
150
|
+
v-if="props.centerLabel"
|
|
151
|
+
:x="center"
|
|
152
|
+
:y="center + 16"
|
|
153
|
+
text-anchor="middle"
|
|
154
|
+
fill="currentColor"
|
|
155
|
+
class="text-dimmed text-[11px]"
|
|
156
|
+
>{{ props.centerLabel }}</text>
|
|
157
|
+
</svg>
|
|
158
|
+
|
|
159
|
+
<!-- Legende trägt die Identität, nicht die Farbe allein. -->
|
|
160
|
+
<ul class="w-full space-y-1.5">
|
|
161
|
+
<li
|
|
162
|
+
v-for="entry in legend"
|
|
163
|
+
:key="entry.key"
|
|
164
|
+
class="flex items-center gap-2 text-sm"
|
|
165
|
+
@pointerenter="active = entry.key"
|
|
166
|
+
@pointerleave="active = null"
|
|
167
|
+
>
|
|
168
|
+
<span class="size-2.5 shrink-0 rounded-sm" :style="{ backgroundColor: entry.color }" />
|
|
169
|
+
<span class="truncate text-muted">{{ entry.label }}</span>
|
|
170
|
+
<span class="ml-auto shrink-0 font-medium tabular-nums text-highlighted">{{ number(entry.value) }}</span>
|
|
171
|
+
<span class="w-12 shrink-0 text-right text-xs tabular-nums text-dimmed">{{ entry.share }}</span>
|
|
172
|
+
</li>
|
|
173
|
+
</ul>
|
|
174
|
+
</div>
|
|
175
|
+
</template>
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Rahmen für ein Diagramm: Karte, Titel, Legende und der Umschalter auf die
|
|
4
|
+
* Tabellenansicht.
|
|
5
|
+
*
|
|
6
|
+
* Der Umschalter ist keine Zugabe. Ein Diagramm codiert Werte über Farbe und
|
|
7
|
+
* Länge; wer davon nichts hat — Farbfehlsichtigkeit, Screenreader, Ausdruck —
|
|
8
|
+
* braucht dieselben Zahlen in lesbarer Form. Deshalb bringt jede Karte hier
|
|
9
|
+
* ihre Tabelle mit, und deshalb ist der Umschalter sichtbar und nicht in einem
|
|
10
|
+
* Menü versteckt.
|
|
11
|
+
*/
|
|
12
|
+
import { computed, ref } from 'vue'
|
|
13
|
+
import { useBaseKitLabels } from '../../composables/useBaseKit'
|
|
14
|
+
|
|
15
|
+
const props = withDefaults(defineProps<{
|
|
16
|
+
title: string
|
|
17
|
+
subtitle?: string
|
|
18
|
+
/** Wohin die Karte führt. Ohne Ziel bleibt der Titel ein Titel. */
|
|
19
|
+
href?: string
|
|
20
|
+
/** Diagramm ausgrauen, während im Hintergrund nachgeladen wird. */
|
|
21
|
+
stale?: boolean
|
|
22
|
+
/** Ohne Tabellen-Slot entfällt der Umschalter. */
|
|
23
|
+
tableAvailable?: boolean
|
|
24
|
+
}>(), {
|
|
25
|
+
subtitle: undefined,
|
|
26
|
+
href: undefined,
|
|
27
|
+
stale: false,
|
|
28
|
+
tableAvailable: true,
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
const labels = useBaseKitLabels()
|
|
32
|
+
const showTable = ref(false)
|
|
33
|
+
|
|
34
|
+
const toggleLabel = computed(() =>
|
|
35
|
+
showTable.value ? labels.value.chartAsChart : labels.value.chartAsTable,
|
|
36
|
+
)
|
|
37
|
+
const toggleIcon = computed(() =>
|
|
38
|
+
showTable.value ? 'i-lucide-chart-column' : 'i-lucide-table',
|
|
39
|
+
)
|
|
40
|
+
</script>
|
|
41
|
+
|
|
42
|
+
<template>
|
|
43
|
+
<UCard :ui="{ body: 'space-y-4' }">
|
|
44
|
+
<div class="flex items-start justify-between gap-3">
|
|
45
|
+
<div class="min-w-0">
|
|
46
|
+
<component
|
|
47
|
+
:is="props.href ? 'NuxtLink' : 'h2'"
|
|
48
|
+
:to="props.href"
|
|
49
|
+
class="text-sm font-semibold text-highlighted"
|
|
50
|
+
:class="props.href ? 'group inline-flex items-center gap-1 hover:text-primary' : 'block'"
|
|
51
|
+
>
|
|
52
|
+
{{ props.title }}
|
|
53
|
+
<UIcon
|
|
54
|
+
v-if="props.href"
|
|
55
|
+
name="i-lucide-arrow-right"
|
|
56
|
+
class="size-3.5 opacity-0 transition-opacity group-hover:opacity-100"
|
|
57
|
+
/>
|
|
58
|
+
</component>
|
|
59
|
+
<p v-if="props.subtitle" class="mt-0.5 text-xs text-muted">
|
|
60
|
+
{{ props.subtitle }}
|
|
61
|
+
</p>
|
|
62
|
+
</div>
|
|
63
|
+
|
|
64
|
+
<UButton
|
|
65
|
+
v-if="props.tableAvailable"
|
|
66
|
+
:icon="toggleIcon"
|
|
67
|
+
:aria-label="toggleLabel"
|
|
68
|
+
:title="toggleLabel"
|
|
69
|
+
color="neutral"
|
|
70
|
+
variant="ghost"
|
|
71
|
+
size="xs"
|
|
72
|
+
@click="showTable = !showTable"
|
|
73
|
+
/>
|
|
74
|
+
</div>
|
|
75
|
+
|
|
76
|
+
<div :class="props.stale ? 'opacity-60 transition-opacity' : 'transition-opacity'">
|
|
77
|
+
<div v-show="!showTable">
|
|
78
|
+
<slot />
|
|
79
|
+
</div>
|
|
80
|
+
<div v-if="props.tableAvailable" v-show="showTable" class="overflow-x-auto">
|
|
81
|
+
<slot name="table" />
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
84
|
+
|
|
85
|
+
<slot name="footer" />
|
|
86
|
+
</UCard>
|
|
87
|
+
</template>
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Ein Verhältnis gegen eine bekannte Grenze — „so viele von so vielen".
|
|
4
|
+
*
|
|
5
|
+
* Ein Meter setzt voraus, dass es ein Ganzes gibt. Wo keins existiert (Bytes
|
|
6
|
+
* ohne Kontingent, Anzahl ohne Obergrenze), gehört die Zahl in eine Kachel und
|
|
7
|
+
* nicht hierher; ein voller Balken behauptete sonst eine erreichte Grenze.
|
|
8
|
+
*/
|
|
9
|
+
import { computed } from 'vue'
|
|
10
|
+
import { baseKitChartColor, useChartFormat } from '../../composables/useChartPalette'
|
|
11
|
+
|
|
12
|
+
const props = withDefaults(defineProps<{
|
|
13
|
+
value: number
|
|
14
|
+
total: number
|
|
15
|
+
label?: string
|
|
16
|
+
hint?: string
|
|
17
|
+
}>(), {
|
|
18
|
+
label: undefined,
|
|
19
|
+
hint: undefined,
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
const { number, percent } = useChartFormat()
|
|
23
|
+
|
|
24
|
+
const ratio = computed(() => (props.total <= 0 ? 0 : Math.min(1, props.value / props.total)))
|
|
25
|
+
const color = baseKitChartColor(0)
|
|
26
|
+
</script>
|
|
27
|
+
|
|
28
|
+
<template>
|
|
29
|
+
<div class="space-y-2">
|
|
30
|
+
<div class="flex items-baseline justify-between gap-3">
|
|
31
|
+
<p class="text-2xl font-semibold text-highlighted">
|
|
32
|
+
{{ number(props.value) }}<span class="text-base font-normal text-muted"> / {{ number(props.total) }}</span>
|
|
33
|
+
</p>
|
|
34
|
+
<p class="text-sm tabular-nums text-muted">
|
|
35
|
+
{{ percent(props.value, props.total) }}
|
|
36
|
+
</p>
|
|
37
|
+
</div>
|
|
38
|
+
|
|
39
|
+
<div
|
|
40
|
+
class="h-2.5 w-full overflow-hidden rounded-full bg-elevated"
|
|
41
|
+
role="meter"
|
|
42
|
+
:aria-valuenow="props.value"
|
|
43
|
+
:aria-valuemin="0"
|
|
44
|
+
:aria-valuemax="props.total"
|
|
45
|
+
:aria-label="props.label"
|
|
46
|
+
>
|
|
47
|
+
<div
|
|
48
|
+
class="h-full rounded-full"
|
|
49
|
+
:style="{ width: `${ratio * 100}%`, backgroundColor: color }"
|
|
50
|
+
/>
|
|
51
|
+
</div>
|
|
52
|
+
|
|
53
|
+
<p v-if="props.hint" class="text-xs text-dimmed">
|
|
54
|
+
{{ props.hint }}
|
|
55
|
+
</p>
|
|
56
|
+
</div>
|
|
57
|
+
</template>
|