@tekkare/romulus 0.1.206 → 0.1.208
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/module.json +1 -1
- package/dist/runtime/components/BarChart.vue +8 -0
- package/dist/runtime/components/ChoroplethMap.vue +11 -1
- package/dist/runtime/components/DashboardGrid.vue +330 -0
- package/dist/runtime/components/LineChart.vue +9 -0
- package/dist/runtime/utils/chart.d.ts +59 -17
- package/dist/runtime/utils/chart.js +20 -11
- package/dist/runtime/utils/dashboardGrid.d.ts +34 -0
- package/dist/runtime/utils/dashboardGrid.js +32 -0
- package/package.json +1 -1
package/dist/module.json
CHANGED
|
@@ -21,11 +21,17 @@ const props = withDefaults(defineProps<{
|
|
|
21
21
|
* ce qui suffit des que les barres sont nombreuses.
|
|
22
22
|
*/
|
|
23
23
|
labelFormatter?: RmBarLabelFormatter
|
|
24
|
+
/**
|
|
25
|
+
* Formate l'axe des valeurs et l'infobulle. Independant de `labelFormatter`,
|
|
26
|
+
* qui seul decide si les barres portent une etiquette.
|
|
27
|
+
*/
|
|
28
|
+
valueFormatter?: (value: number) => string
|
|
24
29
|
}>(), {
|
|
25
30
|
horizontal: false,
|
|
26
31
|
height: 'standard',
|
|
27
32
|
description: undefined,
|
|
28
33
|
labelFormatter: undefined,
|
|
34
|
+
valueFormatter: undefined,
|
|
29
35
|
})
|
|
30
36
|
|
|
31
37
|
const { theme, palette, decal } = useChartTheme()
|
|
@@ -39,6 +45,7 @@ const option = computed(() =>
|
|
|
39
45
|
palette.value.cat,
|
|
40
46
|
decal.value,
|
|
41
47
|
props.labelFormatter,
|
|
48
|
+
props.valueFormatter,
|
|
42
49
|
)
|
|
43
50
|
: rmBarVerticalOption(
|
|
44
51
|
props.categories,
|
|
@@ -47,6 +54,7 @@ const option = computed(() =>
|
|
|
47
54
|
palette.value.cat,
|
|
48
55
|
decal.value,
|
|
49
56
|
props.labelFormatter,
|
|
57
|
+
props.valueFormatter,
|
|
50
58
|
),
|
|
51
59
|
)
|
|
52
60
|
</script>
|
|
@@ -58,6 +58,11 @@ const props = withDefaults(
|
|
|
58
58
|
/** Hauteur du dessin, en pixels. */
|
|
59
59
|
height?: number
|
|
60
60
|
lang?: 'fr' | 'en'
|
|
61
|
+
/**
|
|
62
|
+
* Formate les valeurs de l'infobulle et des bornes de la legende, a la
|
|
63
|
+
* place du format de la langue. Pour une app qui a sa regle d'arrondi.
|
|
64
|
+
*/
|
|
65
|
+
valueFormatter?: (value: number) => string
|
|
61
66
|
}>(),
|
|
62
67
|
{
|
|
63
68
|
data: () => [],
|
|
@@ -67,6 +72,7 @@ const props = withDefaults(
|
|
|
67
72
|
compact: false,
|
|
68
73
|
height: 500,
|
|
69
74
|
lang: 'fr',
|
|
75
|
+
valueFormatter: undefined,
|
|
70
76
|
},
|
|
71
77
|
)
|
|
72
78
|
|
|
@@ -186,7 +192,11 @@ const T = computed(() =>
|
|
|
186
192
|
/** Dégradé de la légende, dans l'ordre de lecture de l'échelle. */
|
|
187
193
|
const legendGradient = computed(() => `linear-gradient(90deg, ${ramp.value.join(', ')})`)
|
|
188
194
|
|
|
189
|
-
const fmt = computed(() =>
|
|
195
|
+
const fmt = computed(() =>
|
|
196
|
+
props.valueFormatter
|
|
197
|
+
? { format: props.valueFormatter }
|
|
198
|
+
: new Intl.NumberFormat(props.lang === 'en' ? 'en-US' : 'fr-FR'),
|
|
199
|
+
)
|
|
190
200
|
</script>
|
|
191
201
|
|
|
192
202
|
<template>
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
RmDashboardGrid - une grille de tuiles que l'on deplace et redimensionne,
|
|
3
|
+
a la maniere des tableaux de bord PostHog ou Grafana.
|
|
4
|
+
|
|
5
|
+
La grille ne sait rien du contenu : elle place des cellules (`layout`) et
|
|
6
|
+
rend chaque tuile par le slot `#item`. Le calcul (chevauchements, tassement
|
|
7
|
+
vers le haut) vit dans utils/dashboardGrid, pur et teste a part ; ce
|
|
8
|
+
composant ne fait que traduire pixels et cellules.
|
|
9
|
+
|
|
10
|
+
Pendant un geste, la grille montre le resultat sans l'emettre : la tuile
|
|
11
|
+
tenue suit le pointeur, une empreinte montre ou elle tombera, les autres
|
|
12
|
+
s'ecartent. `update:layout` ne part qu'au lacher, une fois : l'app enregistre
|
|
13
|
+
un geste, pas chaque pixel.
|
|
14
|
+
|
|
15
|
+
On saisit une tuile par sa poignee, ou par toute zone que l'app marque de
|
|
16
|
+
`data-rm-dash-drag` (l'en-tete d'une carte, par exemple). Un bouton, un lien
|
|
17
|
+
ou un champ dans cette zone restent cliquables : un appui ne devient un
|
|
18
|
+
deplacement qu'au-dela de quelques pixels.
|
|
19
|
+
|
|
20
|
+
Clavier : la poignee est un bouton. Les fleches deplacent la tuile d'une
|
|
21
|
+
cellule, Maj + fleches la redimensionnent, et la nouvelle place est lue
|
|
22
|
+
(region `aria-live`). Sans ca, la disposition serait reservee a la souris.
|
|
23
|
+
|
|
24
|
+
En dessous de `stackBelow` pixels de large, les tuiles s'empilent en une
|
|
25
|
+
colonne dans l'ordre de lecture (haut puis gauche) et l'edition se coupe :
|
|
26
|
+
deplacer une tuile sur une colonne de telephone n'a pas de sens.
|
|
27
|
+
-->
|
|
28
|
+
<script setup lang="ts">
|
|
29
|
+
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
|
30
|
+
import { rmGridCompact, rmGridMove, type RmGridItem } from '../utils/dashboardGrid'
|
|
31
|
+
|
|
32
|
+
const props = withDefaults(defineProps<{
|
|
33
|
+
/** Les tuiles et leurs cellules. Seules les cles de RmGridItem sont lues. */
|
|
34
|
+
layout: RmGridItem[]
|
|
35
|
+
/** Mode edition : poignees, deplacement, redimensionnement. */
|
|
36
|
+
editable?: boolean
|
|
37
|
+
cols?: number
|
|
38
|
+
/** Hauteur d'une ligne de grille, en pixels. */
|
|
39
|
+
rowHeight?: number
|
|
40
|
+
/** Ecart entre deux tuiles, en pixels. */
|
|
41
|
+
gap?: number
|
|
42
|
+
/** Largeur en dessous de laquelle les tuiles s'empilent. */
|
|
43
|
+
stackBelow?: number
|
|
44
|
+
/** Libelle d'une tuile pour la poignee et l'annonce : son titre, en general. */
|
|
45
|
+
itemLabel?: (id: string) => string
|
|
46
|
+
lang?: 'fr' | 'en'
|
|
47
|
+
}>(), {
|
|
48
|
+
editable: false,
|
|
49
|
+
cols: 12,
|
|
50
|
+
rowHeight: 80,
|
|
51
|
+
gap: 16,
|
|
52
|
+
stackBelow: 640,
|
|
53
|
+
itemLabel: undefined,
|
|
54
|
+
lang: 'fr',
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
const emit = defineEmits<{
|
|
58
|
+
'update:layout': [layout: RmGridItem[]]
|
|
59
|
+
}>()
|
|
60
|
+
|
|
61
|
+
defineSlots<{
|
|
62
|
+
item(props: { item: RmGridItem; editing: boolean }): unknown
|
|
63
|
+
}>()
|
|
64
|
+
|
|
65
|
+
const T = computed(() => props.lang === 'en'
|
|
66
|
+
? {
|
|
67
|
+
grip: (name: string) => `Move ${name}. Arrow keys move, Shift and arrow keys resize.`,
|
|
68
|
+
resize: 'Resize',
|
|
69
|
+
at: (i: RmGridItem) => `Column ${i.x + 1}, row ${i.y + 1}, ${i.w} wide, ${i.h} high`,
|
|
70
|
+
}
|
|
71
|
+
: {
|
|
72
|
+
grip: (name: string) => `Déplacer ${name}. Les flèches déplacent, Maj et flèches redimensionnent.`,
|
|
73
|
+
resize: 'Redimensionner',
|
|
74
|
+
at: (i: RmGridItem) => `Colonne ${i.x + 1}, ligne ${i.y + 1}, largeur ${i.w}, hauteur ${i.h}`,
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
const root = ref<HTMLElement>()
|
|
78
|
+
const width = ref(0)
|
|
79
|
+
let observer: ResizeObserver | undefined
|
|
80
|
+
|
|
81
|
+
onMounted(() => {
|
|
82
|
+
if (!root.value) return
|
|
83
|
+
width.value = root.value.clientWidth
|
|
84
|
+
observer = new ResizeObserver(([entry]) => { width.value = entry!.contentRect.width })
|
|
85
|
+
observer.observe(root.value)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
const stacked = computed(() => width.value > 0 && width.value < props.stackBelow)
|
|
89
|
+
const editing = computed(() => props.editable && !stacked.value)
|
|
90
|
+
const colWidth = computed(() => (width.value - props.gap * (props.cols - 1)) / props.cols)
|
|
91
|
+
const stepX = computed(() => colWidth.value + props.gap)
|
|
92
|
+
const stepY = computed(() => props.rowHeight + props.gap)
|
|
93
|
+
|
|
94
|
+
const base = computed(() => rmGridCompact(props.layout.map(pick), props.cols))
|
|
95
|
+
|
|
96
|
+
function pick(item: RmGridItem): RmGridItem {
|
|
97
|
+
const { id, x, y, w, h, minW, minH } = item
|
|
98
|
+
return { id, x, y, w, h, minW, minH }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// --- Geste en cours ------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
interface Gesture {
|
|
104
|
+
id: string
|
|
105
|
+
mode: 'move' | 'resize'
|
|
106
|
+
startX: number
|
|
107
|
+
startY: number
|
|
108
|
+
origin: RmGridItem
|
|
109
|
+
/** Decalage du pointeur, en pixels. */
|
|
110
|
+
dx: number
|
|
111
|
+
dy: number
|
|
112
|
+
started: boolean
|
|
113
|
+
pointerId: number
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const gesture = ref<Gesture | null>(null)
|
|
117
|
+
|
|
118
|
+
/** La cellule visee par le geste. */
|
|
119
|
+
const target = computed(() => {
|
|
120
|
+
const g = gesture.value
|
|
121
|
+
if (!g?.started) return null
|
|
122
|
+
const o = g.origin
|
|
123
|
+
if (g.mode === 'move') {
|
|
124
|
+
return { x: Math.round(o.x + g.dx / stepX.value), y: Math.max(0, Math.round(o.y + g.dy / stepY.value)) }
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
w: Math.max(1, Math.round(o.w + g.dx / stepX.value)),
|
|
128
|
+
h: Math.max(1, Math.round(o.h + g.dy / stepY.value)),
|
|
129
|
+
}
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
const preview = computed(() =>
|
|
133
|
+
target.value && gesture.value ? rmGridMove(base.value, props.cols, gesture.value.id, target.value) : base.value,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
const shown = computed(() =>
|
|
137
|
+
stacked.value ? [...preview.value].sort((a, b) => a.y - b.y || a.x - b.x) : preview.value,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
const height = computed(() => {
|
|
141
|
+
const rows = preview.value.reduce((bottom, item) => Math.max(bottom, item.y + item.h), 0)
|
|
142
|
+
return rows ? rows * stepY.value - props.gap : 0
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
function rect(item: RmGridItem) {
|
|
146
|
+
return {
|
|
147
|
+
left: item.x * stepX.value,
|
|
148
|
+
top: item.y * stepY.value,
|
|
149
|
+
width: item.w * stepX.value - props.gap,
|
|
150
|
+
height: item.h * stepY.value - props.gap,
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function px(box: { left: number; top: number; width: number; height: number }) {
|
|
155
|
+
return {
|
|
156
|
+
transform: `translate(${box.left}px, ${box.top}px)`,
|
|
157
|
+
width: `${box.width}px`,
|
|
158
|
+
height: `${box.height}px`,
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function itemStyle(item: RmGridItem) {
|
|
163
|
+
// Empilees, les tuiles gardent leur hauteur au minimum mais suivent leur contenu.
|
|
164
|
+
if (stacked.value) return { minHeight: `${item.h * stepY.value - props.gap}px` }
|
|
165
|
+
const g = gesture.value
|
|
166
|
+
if (g?.started && g.id === item.id) {
|
|
167
|
+
// La tuile tenue suit le pointeur, depuis sa place d'origine.
|
|
168
|
+
const box = rect(g.origin)
|
|
169
|
+
if (g.mode === 'move') return px({ ...box, left: box.left + g.dx, top: box.top + g.dy })
|
|
170
|
+
return px({ ...box, width: Math.max(colWidth.value, box.width + g.dx), height: Math.max(props.rowHeight, box.height + g.dy) })
|
|
171
|
+
}
|
|
172
|
+
return px(rect(item))
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const placeholder = computed(() => {
|
|
176
|
+
const g = gesture.value
|
|
177
|
+
if (!g?.started) return null
|
|
178
|
+
const item = preview.value.find((i) => i.id === g.id)
|
|
179
|
+
return item ? px(rect(item)) : null
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
/** Ce qui reste cliquable meme dans une zone de saisie. */
|
|
183
|
+
const INTERACTIVE = 'a, input, select, textarea, [contenteditable], [data-rm-dash-nodrag], button:not(.rm-dash__grip)'
|
|
184
|
+
const THRESHOLD = 4
|
|
185
|
+
|
|
186
|
+
function onPointerDown(event: PointerEvent, item: RmGridItem, mode: 'move' | 'resize') {
|
|
187
|
+
if (!editing.value || event.button !== 0) return
|
|
188
|
+
const el = event.target as HTMLElement
|
|
189
|
+
if (mode === 'move') {
|
|
190
|
+
if (!el.closest('.rm-dash__grip, [data-rm-dash-drag]')) return
|
|
191
|
+
if (el.closest(INTERACTIVE)) return
|
|
192
|
+
}
|
|
193
|
+
gesture.value = {
|
|
194
|
+
id: item.id,
|
|
195
|
+
mode,
|
|
196
|
+
startX: event.clientX,
|
|
197
|
+
startY: event.clientY,
|
|
198
|
+
origin: { ...item },
|
|
199
|
+
dx: 0,
|
|
200
|
+
dy: 0,
|
|
201
|
+
started: mode === 'resize',
|
|
202
|
+
pointerId: event.pointerId,
|
|
203
|
+
}
|
|
204
|
+
window.addEventListener('pointermove', onPointerMove)
|
|
205
|
+
window.addEventListener('pointerup', onPointerUp)
|
|
206
|
+
window.addEventListener('pointercancel', cancel)
|
|
207
|
+
window.addEventListener('keydown', onEscape)
|
|
208
|
+
if (mode === 'resize') event.preventDefault()
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function onPointerMove(event: PointerEvent) {
|
|
212
|
+
const g = gesture.value
|
|
213
|
+
if (!g || event.pointerId !== g.pointerId) return
|
|
214
|
+
g.dx = event.clientX - g.startX
|
|
215
|
+
g.dy = event.clientY - g.startY
|
|
216
|
+
if (!g.started && Math.hypot(g.dx, g.dy) > THRESHOLD) {
|
|
217
|
+
g.started = true
|
|
218
|
+
// Plus de selection de texte pendant que l'on tient la tuile.
|
|
219
|
+
window.getSelection()?.removeAllRanges()
|
|
220
|
+
}
|
|
221
|
+
if (g.started) event.preventDefault()
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function onPointerUp() {
|
|
225
|
+
const g = gesture.value
|
|
226
|
+
const result = g?.started && target.value ? preview.value : null
|
|
227
|
+
stop()
|
|
228
|
+
if (g && result) commit(result, g.id)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function onEscape(event: KeyboardEvent) {
|
|
232
|
+
if (event.key === 'Escape') cancel()
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function cancel() {
|
|
236
|
+
stop()
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function stop() {
|
|
240
|
+
gesture.value = null
|
|
241
|
+
window.removeEventListener('pointermove', onPointerMove)
|
|
242
|
+
window.removeEventListener('pointerup', onPointerUp)
|
|
243
|
+
window.removeEventListener('pointercancel', cancel)
|
|
244
|
+
window.removeEventListener('keydown', onEscape)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
onBeforeUnmount(() => {
|
|
248
|
+
stop()
|
|
249
|
+
observer?.disconnect()
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
// --- Clavier ---------------------------------------------------------------
|
|
253
|
+
|
|
254
|
+
const announce = ref('')
|
|
255
|
+
|
|
256
|
+
const KEYS: Record<string, [number, number]> = {
|
|
257
|
+
ArrowLeft: [-1, 0],
|
|
258
|
+
ArrowRight: [1, 0],
|
|
259
|
+
ArrowUp: [0, -1],
|
|
260
|
+
ArrowDown: [0, 1],
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function onKey(event: KeyboardEvent, item: RmGridItem) {
|
|
264
|
+
const step = KEYS[event.key]
|
|
265
|
+
if (!step) return
|
|
266
|
+
event.preventDefault()
|
|
267
|
+
const [dx, dy] = step
|
|
268
|
+
const patch = event.shiftKey
|
|
269
|
+
? { w: Math.max(1, item.w + dx), h: Math.max(1, item.h + dy) }
|
|
270
|
+
: { x: item.x + dx, y: Math.max(0, item.y + dy) }
|
|
271
|
+
commit(rmGridMove(base.value, props.cols, item.id, patch), item.id)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function commit(next: RmGridItem[], id: string) {
|
|
275
|
+
const changed = next.some((item) => {
|
|
276
|
+
const before = base.value.find((b) => b.id === item.id)
|
|
277
|
+
return !before || before.x !== item.x || before.y !== item.y || before.w !== item.w || before.h !== item.h
|
|
278
|
+
})
|
|
279
|
+
const moved = next.find((item) => item.id === id)
|
|
280
|
+
if (moved) announce.value = T.value.at(moved)
|
|
281
|
+
if (changed) emit('update:layout', next)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function label(id: string) {
|
|
285
|
+
return props.itemLabel?.(id) ?? id
|
|
286
|
+
}
|
|
287
|
+
</script>
|
|
288
|
+
|
|
289
|
+
<template>
|
|
290
|
+
<div
|
|
291
|
+
ref="root"
|
|
292
|
+
class="rm-dash"
|
|
293
|
+
:class="{ 'rm-dash--stacked': stacked, 'rm-dash--editing': editing, 'rm-dash--moving': gesture?.started }"
|
|
294
|
+
:style="stacked ? undefined : { height: `${height}px` }"
|
|
295
|
+
>
|
|
296
|
+
<div v-if="placeholder" class="rm-dash__placeholder" :style="placeholder" aria-hidden="true" />
|
|
297
|
+
<div
|
|
298
|
+
v-for="item in shown"
|
|
299
|
+
:key="item.id"
|
|
300
|
+
class="rm-dash__item"
|
|
301
|
+
:class="{ 'rm-dash__item--held': gesture?.started && gesture.id === item.id }"
|
|
302
|
+
:style="width ? itemStyle(item) : undefined"
|
|
303
|
+
@pointerdown="onPointerDown($event, item, 'move')"
|
|
304
|
+
>
|
|
305
|
+
<slot name="item" :item="item" :editing="editing" />
|
|
306
|
+
<template v-if="editing">
|
|
307
|
+
<button
|
|
308
|
+
type="button"
|
|
309
|
+
class="rm-dash__grip"
|
|
310
|
+
:aria-label="T.grip(label(item.id))"
|
|
311
|
+
:title="T.grip(label(item.id))"
|
|
312
|
+
@keydown="onKey($event, item)"
|
|
313
|
+
>
|
|
314
|
+
<RmIcon name="DotsSix" :size="16" weight="bold" />
|
|
315
|
+
</button>
|
|
316
|
+
<span
|
|
317
|
+
class="rm-dash__resize"
|
|
318
|
+
:title="T.resize"
|
|
319
|
+
aria-hidden="true"
|
|
320
|
+
@pointerdown.stop="onPointerDown($event, item, 'resize')"
|
|
321
|
+
/>
|
|
322
|
+
</template>
|
|
323
|
+
</div>
|
|
324
|
+
<p class="rm-dash__live" aria-live="polite">{{ announce }}</p>
|
|
325
|
+
</div>
|
|
326
|
+
</template>
|
|
327
|
+
|
|
328
|
+
<style scoped>
|
|
329
|
+
.rm-dash{position:relative;width:100%}.rm-dash__item{display:flex;flex-direction:column;left:0;min-width:0;position:absolute;top:0;transition:transform var(--duration-base) var(--ease-out),width var(--duration-base) var(--ease-out),height var(--duration-base) var(--ease-out)}.rm-dash__item>:deep(:first-child){flex:1;min-height:0}.rm-dash--stacked{display:flex;flex-direction:column;gap:var(--space-4)}.rm-dash--stacked .rm-dash__item{position:relative;transform:none}.rm-dash__item--held{border-radius:var(--radius-lg);box-shadow:var(--shadow-lg);cursor:grabbing;transition:none;z-index:3}.rm-dash--moving{-webkit-user-select:none;-moz-user-select:none;user-select:none}.rm-dash--editing :deep([data-rm-dash-drag]){cursor:grab;touch-action:none}.rm-dash__placeholder{background:color-mix(in srgb,var(--color-primary) 8%,transparent);border:2px dashed var(--color-primary);border-radius:var(--radius-lg);left:0;position:absolute;top:0;transition:transform var(--duration-fast) var(--ease-out),width var(--duration-fast) var(--ease-out),height var(--duration-fast) var(--ease-out);z-index:0}.rm-dash__grip{align-items:center;background:var(--bg-surface-2);border-radius:999px;color:var(--text-muted);cursor:grab;display:inline-flex;height:16px;justify-content:center;left:50%;padding:0;position:absolute;top:4px;touch-action:none;transform:translateX(-50%);width:36px;z-index:2}.rm-dash__grip:hover{color:var(--text-primary)}.rm-dash__grip:focus-visible{box-shadow:var(--shadow-focus);color:var(--text-primary);outline:none}.rm-dash__resize{background:linear-gradient(135deg,transparent 55%,var(--text-muted) 55%,var(--text-muted) 62%,transparent 62%),linear-gradient(135deg,transparent 75%,var(--text-muted) 75%,var(--text-muted) 82%,transparent 82%);bottom:2px;cursor:nwse-resize;height:14px;position:absolute;right:2px;touch-action:none;width:14px;z-index:2}.rm-dash__live{clip-path:inset(50%);height:1px;overflow:hidden;position:absolute;white-space:nowrap;width:1px}@media (prefers-reduced-motion:reduce){.rm-dash__item,.rm-dash__placeholder{transition:none}}
|
|
330
|
+
</style>
|
|
@@ -52,6 +52,12 @@ const props = withDefaults(defineProps<{
|
|
|
52
52
|
* nombre entier avec ses separateurs de milliers.
|
|
53
53
|
*/
|
|
54
54
|
numberFormat?: 'compact' | 'plain'
|
|
55
|
+
/**
|
|
56
|
+
* Formateur de l'appelant, pour l'axe, les etiquettes ET l'infobulle. Passe
|
|
57
|
+
* avant `numberFormat` : une app qui a sa propre regle d'arrondi la veut
|
|
58
|
+
* partout, y compris au survol.
|
|
59
|
+
*/
|
|
60
|
+
valueFormatter?: (value: number) => string
|
|
55
61
|
height?: keyof typeof RM_CHART_HEIGHTS | number
|
|
56
62
|
description?: string
|
|
57
63
|
}>(), {
|
|
@@ -64,6 +70,7 @@ const props = withDefaults(defineProps<{
|
|
|
64
70
|
legendPosition: 'bottom',
|
|
65
71
|
startAtZero: false,
|
|
66
72
|
numberFormat: undefined,
|
|
73
|
+
valueFormatter: undefined,
|
|
67
74
|
height: 'standard',
|
|
68
75
|
description: undefined,
|
|
69
76
|
})
|
|
@@ -71,6 +78,7 @@ const props = withDefaults(defineProps<{
|
|
|
71
78
|
const { theme, palette, decal } = useChartTheme()
|
|
72
79
|
|
|
73
80
|
const formatter = computed(() => {
|
|
81
|
+
if (props.valueFormatter) return props.valueFormatter
|
|
74
82
|
if (props.numberFormat === 'compact') return rmCompact
|
|
75
83
|
if (props.numberFormat === 'plain') return rmNumber
|
|
76
84
|
return undefined
|
|
@@ -100,6 +108,7 @@ const option = computed(() => {
|
|
|
100
108
|
legendPosition: props.legendPosition,
|
|
101
109
|
startAtZero: props.startAtZero,
|
|
102
110
|
valueFormatter: formatter.value,
|
|
111
|
+
tooltipFormatter: props.valueFormatter,
|
|
103
112
|
}) as Record<string, any>
|
|
104
113
|
if (built.legend && Object.keys(legendSelected.value).length) {
|
|
105
114
|
built.legend = { ...built.legend, selected: { ...legendSelected.value } }
|
|
@@ -200,8 +200,24 @@ export interface RmSeriesInput {
|
|
|
200
200
|
* (« 1 240 (18 %) »), et c'est l'index qui la retrouve.
|
|
201
201
|
*/
|
|
202
202
|
export type RmBarLabelFormatter = (value: number, index: number) => string;
|
|
203
|
-
export declare function rmBarVerticalOption(categories: (string | number)[], series: RmSeriesInput[], theme: RmChartTheme, palette: string[], decal?: boolean, formatter?: RmBarLabelFormatter): {
|
|
203
|
+
export declare function rmBarVerticalOption(categories: (string | number)[], series: RmSeriesInput[], theme: RmChartTheme, palette: string[], decal?: boolean, formatter?: RmBarLabelFormatter, valueFormatter?: (value: number) => string): {
|
|
204
204
|
tooltip: {
|
|
205
|
+
valueFormatter: (value: unknown) => string;
|
|
206
|
+
trigger: string;
|
|
207
|
+
backgroundColor: string;
|
|
208
|
+
textStyle: {
|
|
209
|
+
color: string;
|
|
210
|
+
fontFamily: string;
|
|
211
|
+
fontSize: number;
|
|
212
|
+
};
|
|
213
|
+
borderColor: string;
|
|
214
|
+
borderWidth: number;
|
|
215
|
+
appendTo: string;
|
|
216
|
+
confine: boolean;
|
|
217
|
+
enterable: boolean;
|
|
218
|
+
extraCssText: string;
|
|
219
|
+
} | {
|
|
220
|
+
valueFormatter?: undefined;
|
|
205
221
|
trigger: string;
|
|
206
222
|
backgroundColor: string;
|
|
207
223
|
textStyle: {
|
|
@@ -270,6 +286,7 @@ export declare function rmBarVerticalOption(categories: (string | number)[], ser
|
|
|
270
286
|
show: boolean;
|
|
271
287
|
};
|
|
272
288
|
axisLabel: {
|
|
289
|
+
formatter?: ((value: number) => string) | undefined;
|
|
273
290
|
fontSize: number;
|
|
274
291
|
color: string;
|
|
275
292
|
fontFamily: string;
|
|
@@ -284,6 +301,7 @@ export declare function rmBarVerticalOption(categories: (string | number)[], ser
|
|
|
284
301
|
};
|
|
285
302
|
yAxis: {
|
|
286
303
|
axisLabel: {
|
|
304
|
+
formatter?: ((value: number) => string) | undefined;
|
|
287
305
|
fontSize: number;
|
|
288
306
|
color: string;
|
|
289
307
|
fontFamily: string;
|
|
@@ -334,7 +352,38 @@ export declare function rmBarVerticalOption(categories: (string | number)[], ser
|
|
|
334
352
|
};
|
|
335
353
|
};
|
|
336
354
|
/** Bar chart horizontal, avec valeur affichee en bout de barre. */
|
|
337
|
-
export declare function rmBarHorizontalOption(labels: string[], values: (number | null)[], theme: RmChartTheme, palette: string[], decal?: boolean, formatter?: RmBarLabelFormatter): {
|
|
355
|
+
export declare function rmBarHorizontalOption(labels: string[], values: (number | null)[], theme: RmChartTheme, palette: string[], decal?: boolean, formatter?: RmBarLabelFormatter, valueFormatter?: (value: number) => string): {
|
|
356
|
+
tooltip: {
|
|
357
|
+
valueFormatter: (value: unknown) => string;
|
|
358
|
+
trigger: string;
|
|
359
|
+
backgroundColor: string;
|
|
360
|
+
textStyle: {
|
|
361
|
+
color: string;
|
|
362
|
+
fontFamily: string;
|
|
363
|
+
fontSize: number;
|
|
364
|
+
};
|
|
365
|
+
borderColor: string;
|
|
366
|
+
borderWidth: number;
|
|
367
|
+
appendTo: string;
|
|
368
|
+
confine: boolean;
|
|
369
|
+
enterable: boolean;
|
|
370
|
+
extraCssText: string;
|
|
371
|
+
} | {
|
|
372
|
+
valueFormatter?: undefined;
|
|
373
|
+
trigger: string;
|
|
374
|
+
backgroundColor: string;
|
|
375
|
+
textStyle: {
|
|
376
|
+
color: string;
|
|
377
|
+
fontFamily: string;
|
|
378
|
+
fontSize: number;
|
|
379
|
+
};
|
|
380
|
+
borderColor: string;
|
|
381
|
+
borderWidth: number;
|
|
382
|
+
appendTo: string;
|
|
383
|
+
confine: boolean;
|
|
384
|
+
enterable: boolean;
|
|
385
|
+
extraCssText: string;
|
|
386
|
+
};
|
|
338
387
|
grid: {
|
|
339
388
|
left: number;
|
|
340
389
|
right: number;
|
|
@@ -344,6 +393,7 @@ export declare function rmBarHorizontalOption(labels: string[], values: (number
|
|
|
344
393
|
};
|
|
345
394
|
xAxis: {
|
|
346
395
|
axisLabel: {
|
|
396
|
+
formatter?: ((value: number) => string) | undefined;
|
|
347
397
|
fontSize: number;
|
|
348
398
|
color: string;
|
|
349
399
|
fontFamily: string;
|
|
@@ -365,6 +415,7 @@ export declare function rmBarHorizontalOption(labels: string[], values: (number
|
|
|
365
415
|
show: boolean;
|
|
366
416
|
};
|
|
367
417
|
axisLabel: {
|
|
418
|
+
formatter?: ((value: number) => string) | undefined;
|
|
368
419
|
fontSize: number;
|
|
369
420
|
color: string;
|
|
370
421
|
fontFamily: string;
|
|
@@ -409,21 +460,6 @@ export declare function rmBarHorizontalOption(labels: string[], values: (number
|
|
|
409
460
|
textStyle: {
|
|
410
461
|
fontFamily: string;
|
|
411
462
|
};
|
|
412
|
-
tooltip: {
|
|
413
|
-
trigger: string;
|
|
414
|
-
backgroundColor: string;
|
|
415
|
-
textStyle: {
|
|
416
|
-
color: string;
|
|
417
|
-
fontFamily: string;
|
|
418
|
-
fontSize: number;
|
|
419
|
-
};
|
|
420
|
-
borderColor: string;
|
|
421
|
-
borderWidth: number;
|
|
422
|
-
appendTo: string;
|
|
423
|
-
confine: boolean;
|
|
424
|
-
enterable: boolean;
|
|
425
|
-
extraCssText: string;
|
|
426
|
-
};
|
|
427
463
|
};
|
|
428
464
|
/** Line ou area chart. `step` produit la marche d'escalier des series de prix. */
|
|
429
465
|
/**
|
|
@@ -471,6 +507,11 @@ export declare function rmLineOption(categories: (string | number)[], series: Rm
|
|
|
471
507
|
startAtZero?: boolean;
|
|
472
508
|
/** Formate les valeurs, dans les etiquettes comme dans l'infobulle. */
|
|
473
509
|
valueFormatter?: (value: number) => string;
|
|
510
|
+
/**
|
|
511
|
+
* Formate l'infobulle a la place de `rmNumber`. Une app qui impose sa
|
|
512
|
+
* propre regle d'arrondi la veut aussi au survol, pas seulement sur l'axe.
|
|
513
|
+
*/
|
|
514
|
+
tooltipFormatter?: (value: number) => string;
|
|
474
515
|
}): {
|
|
475
516
|
animation: boolean;
|
|
476
517
|
tooltip: {
|
|
@@ -544,6 +585,7 @@ export declare function rmLineOption(categories: (string | number)[], series: Rm
|
|
|
544
585
|
};
|
|
545
586
|
boundaryGap: boolean;
|
|
546
587
|
axisLabel: {
|
|
588
|
+
formatter?: ((value: number) => string) | undefined;
|
|
547
589
|
fontSize: number;
|
|
548
590
|
color: string;
|
|
549
591
|
fontFamily: string;
|
|
@@ -150,21 +150,26 @@ const valueAxis = (theme, label, hidden = false, formatter) => ({
|
|
|
150
150
|
axisTick: hidden ? { show: false } : void 0,
|
|
151
151
|
splitLine: { lineStyle: { color: theme.splitLine } }
|
|
152
152
|
});
|
|
153
|
-
const axisCommon = (theme) => ({
|
|
154
|
-
axisLabel: { fontSize: 11, color: theme.mutedColor, fontFamily: FONT },
|
|
153
|
+
const axisCommon = (theme, formatter) => ({
|
|
154
|
+
axisLabel: { fontSize: 11, color: theme.mutedColor, fontFamily: FONT, ...formatter ? { formatter } : {} },
|
|
155
155
|
axisLine: { lineStyle: { color: theme.axisLine } },
|
|
156
156
|
splitLine: { lineStyle: { color: theme.splitLine } }
|
|
157
157
|
});
|
|
158
|
-
|
|
158
|
+
const rmTooltipValue = (formatter) => formatter ? { valueFormatter: (value) => typeof value === "number" ? formatter(value) : "" } : {};
|
|
159
|
+
export function rmBarVerticalOption(categories, series, theme, palette, decal = false, formatter, valueFormatter) {
|
|
159
160
|
return {
|
|
160
161
|
...rmBaseOption(theme, decal),
|
|
161
|
-
tooltip: {
|
|
162
|
+
tooltip: {
|
|
163
|
+
...rmBaseOption(theme, decal).tooltip,
|
|
164
|
+
trigger: rmTooltipTrigger(series.length),
|
|
165
|
+
...rmTooltipValue(valueFormatter)
|
|
166
|
+
},
|
|
162
167
|
legend: series.length > 1 ? rmLegend(theme, series.length, "bottom", series.map((s) => String(s.name ?? ""))) : void 0,
|
|
163
168
|
// La legende passee sous le graphe prend sa place dans la gouttiere basse,
|
|
164
169
|
// sinon elle recouvre les libelles de l'axe des abscisses.
|
|
165
170
|
grid: { ...rmBaseOption(theme, decal).grid, bottom: series.length > 1 ? 56 : 36 },
|
|
166
171
|
xAxis: { type: "category", data: categories, ...axisCommon(theme), splitLine: { show: false } },
|
|
167
|
-
yAxis: { type: "value", ...axisCommon(theme) },
|
|
172
|
+
yAxis: { type: "value", ...axisCommon(theme, valueFormatter) },
|
|
168
173
|
series: series.map((s, i) => ({
|
|
169
174
|
type: "bar",
|
|
170
175
|
name: s.name,
|
|
@@ -184,11 +189,12 @@ export function rmBarVerticalOption(categories, series, theme, palette, decal =
|
|
|
184
189
|
}))
|
|
185
190
|
};
|
|
186
191
|
}
|
|
187
|
-
export function rmBarHorizontalOption(labels, values, theme, palette, decal = false, formatter) {
|
|
192
|
+
export function rmBarHorizontalOption(labels, values, theme, palette, decal = false, formatter, valueFormatter) {
|
|
188
193
|
return {
|
|
189
194
|
...rmBaseOption(theme, decal),
|
|
195
|
+
tooltip: { ...rmBaseOption(theme, decal).tooltip, ...rmTooltipValue(valueFormatter) },
|
|
190
196
|
grid: { left: 140, right: 60, top: 16, bottom: 24, containLabel: true },
|
|
191
|
-
xAxis: { type: "value", ...axisCommon(theme) },
|
|
197
|
+
xAxis: { type: "value", ...axisCommon(theme, valueFormatter) },
|
|
192
198
|
yAxis: { type: "category", data: labels, ...axisCommon(theme), splitLine: { show: false } },
|
|
193
199
|
series: [{
|
|
194
200
|
type: "bar",
|
|
@@ -203,7 +209,9 @@ export function rmBarHorizontalOption(labels, values, theme, palette, decal = fa
|
|
|
203
209
|
fontSize: 11,
|
|
204
210
|
color: theme.mutedColor,
|
|
205
211
|
fontFamily: FONT,
|
|
206
|
-
|
|
212
|
+
// L'etiquette est toujours posee ici : sans formateur d'etiquette, elle
|
|
213
|
+
// suit au moins celui des valeurs plutot que le nombre brut.
|
|
214
|
+
formatter: formatter ? (p) => formatter(p.value, p.dataIndex) : valueFormatter ? (p) => valueFormatter(p.value) : void 0
|
|
207
215
|
}
|
|
208
216
|
}]
|
|
209
217
|
};
|
|
@@ -220,7 +228,8 @@ export function rmLineOption(categories, series, theme, palette, options = {}) {
|
|
|
220
228
|
hideValueAxis = false,
|
|
221
229
|
legendPosition = "bottom",
|
|
222
230
|
startAtZero = false,
|
|
223
|
-
valueFormatter
|
|
231
|
+
valueFormatter,
|
|
232
|
+
tooltipFormatter
|
|
224
233
|
} = options;
|
|
225
234
|
const labelled = valueLabels ?? series.length <= RM_LINE_LABEL_MAX_SERIES;
|
|
226
235
|
const AREA_MAX_SERIES = 10;
|
|
@@ -228,7 +237,7 @@ export function rmLineOption(categories, series, theme, palette, options = {}) {
|
|
|
228
237
|
const dual = series.some((s) => s.axis === 1);
|
|
229
238
|
const bottomLegend = legendPosition === "bottom";
|
|
230
239
|
const format = (value) => valueFormatter ? valueFormatter(value) : String(value);
|
|
231
|
-
const tooltipFormat = (value) => valueFormatter ? rmNumber(value) : String(value);
|
|
240
|
+
const tooltipFormat = (value) => tooltipFormatter ? tooltipFormatter(value) : valueFormatter ? rmNumber(value) : String(value);
|
|
232
241
|
const values = series.flatMap((s) => s.data).filter((v) => typeof v === "number");
|
|
233
242
|
const floorAtZero = startAtZero && values.length > 0 && Math.min(...values) >= 0;
|
|
234
243
|
const base = rmBaseOption(theme, decal);
|
|
@@ -241,7 +250,7 @@ export function rmLineOption(categories, series, theme, palette, options = {}) {
|
|
|
241
250
|
tooltip: {
|
|
242
251
|
...base.tooltip,
|
|
243
252
|
trigger: rmTooltipTrigger(series.length),
|
|
244
|
-
...valueFormatter ? { valueFormatter: (value) => typeof value === "number" ? tooltipFormat(value) : "" } : {}
|
|
253
|
+
...valueFormatter || tooltipFormatter ? { valueFormatter: (value) => typeof value === "number" ? tooltipFormat(value) : "" } : {}
|
|
245
254
|
},
|
|
246
255
|
// Legende obligatoire en double axe : sans elle, rien ne dit quelle courbe
|
|
247
256
|
// se lit sur quel axe.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Calcul de la grille de RmDashboardGrid : des tuiles posees en cellules
|
|
3
|
+
* (colonnes fixes, lignes infinies), sans chevauchement, tassees vers le haut.
|
|
4
|
+
*
|
|
5
|
+
* Pur et sans DOM : le composant ne fait que convertir pixels et cellules. La
|
|
6
|
+
* regle est celle des tableaux de bord PostHog ou Grafana : une tuile deplacee
|
|
7
|
+
* prend la place visee, celles qu'elle touche descendent, puis tout remonte
|
|
8
|
+
* dans les trous. Un trou ne survit jamais a un geste.
|
|
9
|
+
*/
|
|
10
|
+
export interface RmGridItem {
|
|
11
|
+
id: string;
|
|
12
|
+
x: number;
|
|
13
|
+
y: number;
|
|
14
|
+
w: number;
|
|
15
|
+
h: number;
|
|
16
|
+
minW?: number;
|
|
17
|
+
minH?: number;
|
|
18
|
+
}
|
|
19
|
+
/** Une tuile ramenee dans la grille : taille entre son minimum et `cols`, x dans les bornes. */
|
|
20
|
+
export declare function rmGridClamp(item: RmGridItem, cols: number): RmGridItem;
|
|
21
|
+
/**
|
|
22
|
+
* Tasse la grille vers le haut et defait les chevauchements.
|
|
23
|
+
*
|
|
24
|
+
* `pinned` garde sa place pendant un geste : c'est la tuile que l'on tient, les
|
|
25
|
+
* autres s'ecartent pour elle. Rend les tuiles dans l'ordre recu.
|
|
26
|
+
*/
|
|
27
|
+
export declare function rmGridCompact(items: RmGridItem[], cols: number, pinned?: string): RmGridItem[];
|
|
28
|
+
/**
|
|
29
|
+
* La grille apres avoir pose `id` en `patch` (position et/ou taille) : la
|
|
30
|
+
* tuile prend la place visee, les autres s'ecartent, puis tout se tasse.
|
|
31
|
+
*/
|
|
32
|
+
export declare function rmGridMove(items: RmGridItem[], cols: number, id: string, patch: Partial<Pick<RmGridItem, 'x' | 'y' | 'w' | 'h'>>): RmGridItem[];
|
|
33
|
+
/** La premiere ligne libre sous toutes les tuiles : la ou poser une tuile ajoutee. */
|
|
34
|
+
export declare function rmGridBottom(items: RmGridItem[]): number;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
function collide(a, b) {
|
|
2
|
+
return a.id !== b.id && a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;
|
|
3
|
+
}
|
|
4
|
+
export function rmGridClamp(item, cols) {
|
|
5
|
+
const w = Math.min(cols, Math.max(item.minW ?? 1, Math.round(item.w)));
|
|
6
|
+
const h = Math.max(item.minH ?? 1, Math.round(item.h));
|
|
7
|
+
const x = Math.min(cols - w, Math.max(0, Math.round(item.x)));
|
|
8
|
+
return { ...item, x, y: Math.max(0, Math.round(item.y)), w, h };
|
|
9
|
+
}
|
|
10
|
+
export function rmGridCompact(items, cols, pinned) {
|
|
11
|
+
const clamped = items.map((item) => rmGridClamp(item, cols));
|
|
12
|
+
const placed = clamped.filter((item) => item.id === pinned);
|
|
13
|
+
const rest = clamped.filter((item) => item.id !== pinned).sort((a, b) => a.y - b.y || a.x - b.x);
|
|
14
|
+
for (const item of rest) {
|
|
15
|
+
while (item.y > 0 && !placed.some((other) => collide({ ...item, y: item.y - 1 }, other))) item.y--;
|
|
16
|
+
let hit = placed.find((other) => collide(item, other));
|
|
17
|
+
while (hit) {
|
|
18
|
+
item.y = hit.y + hit.h;
|
|
19
|
+
hit = placed.find((other) => collide(item, other));
|
|
20
|
+
}
|
|
21
|
+
placed.push(item);
|
|
22
|
+
}
|
|
23
|
+
const byId = new Map(placed.map((item) => [item.id, item]));
|
|
24
|
+
return clamped.map((item) => byId.get(item.id));
|
|
25
|
+
}
|
|
26
|
+
export function rmGridMove(items, cols, id, patch) {
|
|
27
|
+
const moved = items.map((item) => item.id === id ? { ...item, ...patch } : item);
|
|
28
|
+
return rmGridCompact(rmGridCompact(moved, cols, id), cols);
|
|
29
|
+
}
|
|
30
|
+
export function rmGridBottom(items) {
|
|
31
|
+
return items.reduce((bottom, item) => Math.max(bottom, item.y + item.h), 0);
|
|
32
|
+
}
|