@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 CHANGED
@@ -4,7 +4,7 @@
4
4
  "compatibility": {
5
5
  "nuxt": ">=3.16.0"
6
6
  },
7
- "version": "0.1.205",
7
+ "version": "0.1.207",
8
8
  "builder": {
9
9
  "@nuxt/module-builder": "0.8.4",
10
10
  "unbuild": "2.0.0"
@@ -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(() => new Intl.NumberFormat(props.lang === 'en' ? 'en-US' : 'fr-FR'))
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
- export function rmBarVerticalOption(categories, series, theme, palette, decal = false, formatter) {
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: { ...rmBaseOption(theme, decal).tooltip, trigger: rmTooltipTrigger(series.length) },
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
- formatter: formatter ? (p) => formatter(p.value, p.dataIndex) : void 0
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tekkare/romulus",
3
- "version": "0.1.206",
3
+ "version": "0.1.208",
4
4
  "description": "Romulus Design System - Nuxt module by Tekkare",
5
5
  "license": "MIT",
6
6
  "type": "module",