@tekkare/romulus 0.1.166 → 0.1.168

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.165",
7
+ "version": "0.1.167",
8
8
  "builder": {
9
9
  "@nuxt/module-builder": "0.8.4",
10
10
  "unbuild": "2.0.0"
@@ -0,0 +1,248 @@
1
+ <!--
2
+ RmChoroplethMap - une valeur par region, peinte sur la carte d'un pays.
3
+
4
+ Le pays est une donnee (`geography`), pas un composant : d'un pays a l'autre
5
+ seul le trace changeait. Le trace est charge a la demande, jeu par jeu (cf.
6
+ internal/geo), parce que tous ensemble ils pesent plus lourd que le reste du
7
+ paquet.
8
+
9
+ L'echelle de couleur est la palette sequentielle du design system
10
+ (`palette.seq`), donc elle suit le mode nuit et la palette daltonienne. Une
11
+ region sans valeur n'est pas peinte en clair : elle est peinte dans une
12
+ surface neutre, hors echelle, parce que « pas de donnee » et « valeur
13
+ basse » ne sont pas la meme chose.
14
+
15
+ ORIGINE AURIGA
16
+ --------------
17
+ Remplace les dix-sept composants `argMapsXxx` (un par pays) et leurs
18
+ `oneRegionXx` (un par region), qui partageaient le meme script a la ligne
19
+ pres.
20
+
21
+ | Auriga | Romulus | Note |
22
+ |-------------------------|-----------------|-------------------------------|
23
+ | `<argMapsUs>`, `<argMapsGermany>`, ... | `geography="us"`, `"de"`, ... | un composant, le pays en donnee |
24
+ | `have-custom-title` | slot `#title` | la presence du slot suffit, la prop disait la meme chose une seconde fois |
25
+ | slot `#Tooltip` | slot `#tooltip` | meme donnee (`{ name, value }`) |
26
+ | slot `#Subcontent` | slot `#footer` | |
27
+ | `hide-drom-tom` | `compact` | le nom ne parlait que de la France |
28
+ | `@openSource` | `@source` | |
29
+ | echelle de 10 couleurs ecrites en dur | `palette.seq` | suit le mode nuit et la palette daltonienne |
30
+ | `results` | - | prop morte : aucune carte ne la lisait |
31
+ -->
32
+ <script setup lang="ts">
33
+ import { computed, onBeforeUnmount, ref, watch } from 'vue'
34
+ import { RM_GEOGRAPHIES, type RmGeoShape } from '../internal/geo'
35
+ import { useChartTheme } from '../composables/useChartTheme'
36
+
37
+ export interface RmChoroplethValue {
38
+ /** Nom de la region, tel que la geographie la nomme. */
39
+ name: string
40
+ value: number | null | undefined
41
+ }
42
+
43
+ const props = withDefaults(
44
+ defineProps<{
45
+ /** Le pays dessine : `fr-regions`, `fr-departements`, `us`, `de`, `kr`. */
46
+ geography: string
47
+ data?: RmChoroplethValue[]
48
+ title?: string | null
49
+ /** Source citee sous le titre. */
50
+ source?: string | null
51
+ /**
52
+ * Inverse la lecture de l'echelle : la valeur basse est la plus foncee.
53
+ * Pour un indicateur ou peu vaut mieux (une couverture manquante).
54
+ */
55
+ reverseColors?: boolean
56
+ /** Resserre le cadrage sur la partie continentale. */
57
+ compact?: boolean
58
+ /** Hauteur du dessin, en pixels. */
59
+ height?: number
60
+ lang?: 'fr' | 'en'
61
+ }>(),
62
+ {
63
+ data: () => [],
64
+ title: null,
65
+ source: null,
66
+ reverseColors: false,
67
+ compact: false,
68
+ height: 500,
69
+ lang: 'fr',
70
+ },
71
+ )
72
+
73
+ const emit = defineEmits<{
74
+ /** La source citee est cliquee. */
75
+ source: []
76
+ /** Une region est cliquee. */
77
+ select: [name: string]
78
+ }>()
79
+
80
+ const { palette } = useChartTheme()
81
+
82
+ const shapes = ref<Record<string, RmGeoShape>>({})
83
+ const loading = ref(true)
84
+
85
+ const geography = computed(() => RM_GEOGRAPHIES[props.geography])
86
+
87
+ watch(
88
+ () => props.geography,
89
+ async (key) => {
90
+ const geo = RM_GEOGRAPHIES[key]
91
+ shapes.value = {}
92
+ if (!geo) {
93
+ loading.value = false
94
+ return
95
+ }
96
+ loading.value = true
97
+ shapes.value = await geo.load()
98
+ loading.value = false
99
+ },
100
+ { immediate: true },
101
+ )
102
+
103
+ const viewBox = computed(() =>
104
+ (props.compact ? geography.value?.compactViewBox : null) ?? geography.value?.viewBox ?? '0 0 100 100',
105
+ )
106
+
107
+ const values = computed(() =>
108
+ props.data.filter(d => d.value !== null && d.value !== undefined).map(d => Number(d.value)),
109
+ )
110
+
111
+ const bounds = computed(() => {
112
+ if (!values.value.length) return null
113
+ return { min: Math.min(...values.value), max: Math.max(...values.value) }
114
+ })
115
+
116
+ /**
117
+ * Cinq tranches d'egale largeur. Auriga en peignait dix, ecrites en dur :
118
+ * au-dela, deux tranches voisines ne se distinguent plus et la legende promet
119
+ * une precision que l'oeil ne lit pas.
120
+ *
121
+ * La premiere teinte de la palette sequentielle est ecartee : elle est presque
122
+ * blanche, et la region la plus basse se lisait alors comme une region sans
123
+ * donnee, qui est la seule chose que cette carte ne doit pas confondre.
124
+ */
125
+ const ramp = computed(() => {
126
+ const seq = palette.value.seq.slice(1)
127
+ return props.reverseColors ? [...seq].reverse() : seq
128
+ })
129
+
130
+ const byName = computed(() => {
131
+ const out = new Map<string, number | null>()
132
+ for (const entry of props.data) {
133
+ out.set(entry.name, entry.value === null || entry.value === undefined ? null : Number(entry.value))
134
+ }
135
+ return out
136
+ })
137
+
138
+ /** Hors echelle : une region sans valeur ne se lit pas comme une valeur basse. */
139
+ const NO_DATA = 'var(--bg-surface-3)'
140
+
141
+ function colorOf(name: string) {
142
+ const value = byName.value.get(name)
143
+ if (value === null || value === undefined || !bounds.value) return NO_DATA
144
+ const { min, max } = bounds.value
145
+ if (max === min) return ramp.value[ramp.value.length - 1]!
146
+ const step = (value - min) / (max - min)
147
+ const index = Math.min(ramp.value.length - 1, Math.floor(step * ramp.value.length))
148
+ return ramp.value[index]!
149
+ }
150
+
151
+ const hovered = ref<string | null>(null)
152
+ const pointer = ref({ x: 0, y: 0 })
153
+
154
+ function onEnter(name: string, event: MouseEvent) {
155
+ hovered.value = name
156
+ onMove(event)
157
+ }
158
+
159
+ function onMove(event: MouseEvent) {
160
+ const host = (event.currentTarget as SVGElement)?.ownerSVGElement?.parentElement
161
+ const box = host?.getBoundingClientRect()
162
+ pointer.value = {
163
+ x: event.clientX - (box?.left ?? 0),
164
+ y: event.clientY - (box?.top ?? 0),
165
+ }
166
+ }
167
+
168
+ function onLeave() {
169
+ hovered.value = null
170
+ }
171
+
172
+ onBeforeUnmount(() => {
173
+ hovered.value = null
174
+ })
175
+
176
+ const hoveredValue = computed(() =>
177
+ hovered.value === null ? null : (byName.value.get(hovered.value) ?? null),
178
+ )
179
+
180
+ const T = computed(() =>
181
+ props.lang === 'en'
182
+ ? { noData: 'No data', source: 'Source' }
183
+ : { noData: 'Pas de donnée', source: 'Source' },
184
+ )
185
+
186
+ /** Dégradé de la légende, dans l'ordre de lecture de l'échelle. */
187
+ const legendGradient = computed(() => `linear-gradient(90deg, ${ramp.value.join(', ')})`)
188
+
189
+ const fmt = computed(() => new Intl.NumberFormat(props.lang === 'en' ? 'en-US' : 'fr-FR'))
190
+ </script>
191
+
192
+ <template>
193
+ <div class="rm-choro">
194
+ <div class="rm-choro__head">
195
+ <slot name="title">
196
+ <p v-if="title" class="rm-choro__title">{{ title }}</p>
197
+ </slot>
198
+ <button v-if="source" type="button" class="rm-choro__source" @click="emit('source')">
199
+ {{ T.source }} : {{ source }}
200
+ </button>
201
+ </div>
202
+
203
+ <div class="rm-choro__plot" :style="{ height: `${height}px` }">
204
+ <RmSkeleton v-if="loading" height="100%" />
205
+ <svg v-else class="rm-choro__svg" :viewBox="viewBox" preserveAspectRatio="xMidYMid meet">
206
+ <path
207
+ v-for="(shape, name) in shapes"
208
+ :key="name"
209
+ :d="shape.path"
210
+ :fill="colorOf(String(name))"
211
+ :stroke="colorOf(String(name)) === NO_DATA ? 'var(--border-default)' : colorOf(String(name))"
212
+ stroke-width="1"
213
+ class="rm-choro__shape"
214
+ :class="{ 'rm-choro__shape--dimmed': hovered !== null && hovered !== name }"
215
+ @mouseenter="onEnter(String(name), $event)"
216
+ @mousemove="onMove"
217
+ @mouseleave="onLeave"
218
+ @click="emit('select', String(name))"
219
+ />
220
+ </svg>
221
+
222
+ <div
223
+ v-if="hovered !== null"
224
+ class="rm-choro__tip"
225
+ :style="{ top: `${pointer.y}px`, left: `${pointer.x}px` }"
226
+ >
227
+ <p class="rm-choro__tip_name">{{ hovered }}</p>
228
+ <slot name="tooltip" :value="{ name: hovered, value: hoveredValue }">
229
+ <p class="rm-choro__tip_value">
230
+ {{ hoveredValue === null ? T.noData : fmt.format(hoveredValue) }}
231
+ </p>
232
+ </slot>
233
+ </div>
234
+ </div>
235
+
236
+ <div v-if="bounds" class="rm-choro__legend">
237
+ <span>{{ fmt.format(Math.floor(bounds.min)) }}</span>
238
+ <span class="rm-choro__legend_bar" :style="{ background: legendGradient }" />
239
+ <span>{{ fmt.format(Math.ceil(bounds.max)) }}</span>
240
+ </div>
241
+
242
+ <slot name="footer" />
243
+ </div>
244
+ </template>
245
+
246
+ <style scoped>
247
+ .rm-choro{background:var(--bg-surface);border:1px solid var(--border-default);border-radius:var(--radius-xl);gap:var(--space-16);padding:var(--space-24)}.rm-choro,.rm-choro__head{display:flex;flex-direction:column}.rm-choro__head{gap:var(--space-4)}.rm-choro__title{color:var(--text-primary);font-family:var(--font-serif);font-size:var(--font-18,18px);font-weight:700;margin:0}.rm-choro__source{align-self:flex-start;background:none;border:0;color:var(--text-muted);cursor:pointer;font-size:var(--font-12,12px);padding:0;text-decoration:underline}.rm-choro__plot{position:relative}.rm-choro__svg{height:100%;width:100%}.rm-choro__shape{cursor:pointer;transition:opacity var(--duration-fast) var(--ease-in-out)}.rm-choro__shape--dimmed{opacity:.45}.rm-choro__tip{background:var(--bg-surface);border:1px solid var(--border-default);border-radius:var(--radius-lg);box-shadow:var(--shadow-md);max-width:260px;padding:var(--space-8) var(--space-12);pointer-events:none;position:absolute;transform:translate(-50%,calc(-100% - 12px));z-index:2}.rm-choro__tip_name{color:var(--text-primary);font-size:var(--font-14,14px);font-weight:700;margin:0}.rm-choro__tip_value{color:var(--text-secondary);font-size:var(--font-14,14px);margin:0}.rm-choro__legend{align-items:center;color:var(--text-muted);display:flex;font-size:var(--font-12,12px);gap:var(--space-8)}.rm-choro__legend_bar{border-radius:4px;flex:1;height:8px}
248
+ </style>
@@ -0,0 +1,72 @@
1
+ <!--
2
+ Champ de texte multiligne.
3
+
4
+ Jumeau de RmInput : meme etiquette, meme message d'erreur, meme dessin de
5
+ bordure et de focus. Seule la hauteur change, et elle est reglable en lignes
6
+ plutot qu'en pixels — une description de trois lignes ne se decrit pas en
7
+ `72px`, et la valeur suivrait mal un changement de graisse de texte.
8
+
9
+ ORIGINE AURIGA
10
+ --------------
11
+ Remplace le `<textarea>` nu que les apps posaient a cote d'un `arg-input`,
12
+ avec leur propre feuille de style : il ne suivait ni le mode nuit ni les
13
+ jetons de bordure, et divergeait d'une app a l'autre.
14
+
15
+ `resize` vaut `vertical` par defaut : l'horizontal deborde des colonnes de
16
+ formulaire et casse la grille.
17
+ -->
18
+ <script setup lang="ts">
19
+ export interface RTextareaProps {
20
+ /** The v-model value */
21
+ modelValue?: string
22
+ /** Label text displayed above the field */
23
+ label?: string
24
+ /** Placeholder text */
25
+ placeholder?: string
26
+ /** Error message to display */
27
+ error?: string
28
+ /** Whether the field is disabled */
29
+ disabled?: boolean
30
+ /** Whether the field is required */
31
+ required?: boolean
32
+ /** Visible height, in lines of text */
33
+ rows?: number
34
+ /** Whether the user may resize the field */
35
+ resize?: 'none' | 'vertical'
36
+ }
37
+
38
+ withDefaults(defineProps<RTextareaProps>(), {
39
+ modelValue: '',
40
+ disabled: false,
41
+ required: false,
42
+ rows: 4,
43
+ resize: 'vertical',
44
+ })
45
+
46
+ defineEmits<{
47
+ 'update:modelValue': [value: string]
48
+ }>()
49
+ </script>
50
+
51
+ <template>
52
+ <div class="r-textarea-wrapper">
53
+ <label v-if="label" class="r-textarea-label">
54
+ {{ label }}
55
+ <span v-if="required" class="r-textarea-required">*</span>
56
+ </label>
57
+ <textarea
58
+ :value="modelValue"
59
+ :placeholder="placeholder"
60
+ :disabled="disabled"
61
+ :required="required"
62
+ :rows="rows"
63
+ :class="['r-textarea', `r-textarea--resize-${resize}`, { 'r-textarea--error': error }]"
64
+ @input="$emit('update:modelValue', ($event.target as HTMLTextAreaElement).value)"
65
+ />
66
+ <p v-if="error" class="r-textarea-error">{{ error }}</p>
67
+ </div>
68
+ </template>
69
+
70
+ <style scoped>
71
+ .r-textarea-wrapper{display:flex;flex-direction:column;gap:var(--space-4)}.r-textarea-label{color:var(--text-secondary);font-size:var(--font-14);font-weight:var(--weight-medium)}.r-textarea-required{color:var(--color-danger);margin-left:2px}.r-textarea{background-color:var(--bg-surface);border:1px solid var(--border-default);border-radius:var(--radius-lg);box-sizing:border-box;color:var(--text-primary);font-family:inherit;font-size:var(--font-14);line-height:1.5;padding:var(--space-8) var(--space-12);transition:border-color var(--duration-fast) var(--ease-in-out),box-shadow var(--duration-fast) var(--ease-in-out);width:100%}.r-textarea--resize-none{resize:none}.r-textarea--resize-vertical{resize:vertical}.r-textarea::-moz-placeholder{color:var(--text-muted)}.r-textarea::placeholder{color:var(--text-muted)}.r-textarea:hover:not(:disabled){border-color:var(--text-muted)}.r-textarea:focus{border-color:var(--color-healthcare);box-shadow:0 0 0 3px var(--bg-hover);outline:none}.r-textarea:disabled{background-color:var(--bg-surface-2);cursor:not-allowed;opacity:.5}.r-textarea--error,.r-textarea--error:focus{border-color:var(--color-danger)}.r-textarea--error:focus{box-shadow:0 0 0 3px rgba(239,68,68,.15)}.r-textarea-error{color:var(--color-danger);font-size:var(--font-12);margin-top:var(--space-4)}
72
+ </style>