@massimo-cassandro/minimo 1.2.0 → 1.3.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.
@@ -0,0 +1,849 @@
1
+ /**
2
+ * Genera un indicatore grafico di tipo rating/tachimetro.
3
+ * Mostra un'asticella mobile su una scala suddivisa in porzioni colorate.
4
+ * Supporta la visualizzazione di etichette principali e fino a due mini-display secondari.
5
+ *
6
+ * @param {Object} options - Oggetto contenente i parametri di configurazione.
7
+ * @returns {Promise<string|undefined>} Restituisce la stringa SVG se container è null, altrimenti la renderizza nel DOM.
8
+ */
9
+
10
+
11
+ import { parseContainer, polarToCartesianString, polarToCartesian } from '../chart-utils.js';
12
+ import { createTextEl } from '../create-text-element.js';
13
+ import { rad30, getDisplayBackgroundPath, calcolaPuntoInterno, normalizeAngle, isCircleDx } from './ratingDisplayUtils.js';
14
+
15
+ export async function ratingDisplay({
16
+
17
+ /**
18
+ * @param {boolean} [debug=false] - Modalità debug: se attiva mostra i riferimenti grafici (centri di rotazione, angoli) e i metadati data-debug-info.
19
+ */
20
+ debug = false,
21
+
22
+ /**
23
+ * @param {HTMLElement|string|null} [container=null] - Elemento DOM o selettore CSS dove iniettare il grafico. Se null, la funzione restituisce l'SVG come stringa.
24
+ */
25
+ container = null,
26
+
27
+ /**
28
+ * @param {number|null} [width=null] - Larghezza del documento SVG. Obbligatoria se eseguito in ambiente Node.
29
+ */
30
+ width = null,
31
+
32
+ /**
33
+ * @param {number|function} [centerGap=r=>r/8] - Distanza tra il centro della circonferenza esterna e ciascuna delle circonferenze interne (*dA* e *dB* nella documentzione).
34
+ * Numero o funzione del raggio dato.
35
+ */
36
+ centerGap = r => r/9,
37
+
38
+ /**
39
+ * @param {number|function} [scaleEndThickness=r=>r/8] - Spessore finale della scala (indicato con *s* nella documentazione).
40
+ * Numero o funzione del raggio dato.
41
+ */
42
+ scaleEndThickness = r => r/5,
43
+
44
+ /**
45
+ * @param {number} [portions=5] - Numero di porzioni (sezioni di colore) in cui è diviso il tachimetro.
46
+ */
47
+ portions = 5,
48
+
49
+ /**
50
+ * @param {number} [portionGap=10] - Distanza tra una porzione e la successiva.
51
+ * Corrisponde all'angolo in gradi tra le due semirette passanti per il centro della circonferenza esterna e i segmenti laterali delle porzioni.
52
+ * Indicato con *gamma* nella documentazione
53
+ */
54
+ portionGap = 2,
55
+
56
+ /**
57
+ * @param {Object} [svgAttrs={}] - Attributi aggiuntivi (es. class, id) da applicare al tag SVG radice.
58
+ */
59
+ svgAttrs = {},
60
+
61
+ /**
62
+ * @param {Object} [displayBackgroundAttrs] - Attributi grafici (fill, stroke) per l'arco di sfondo esterno.
63
+ */
64
+ displayBackgroundAttrs = {
65
+ fill: 'none',
66
+ },
67
+
68
+ /**
69
+ * @param {number} [displayPadding=10] - Spazio tra il bordo del background e gli elementi interni del display.
70
+ */
71
+ displayPadding = 10,
72
+
73
+ /**
74
+ * @param {number} [scaleStartValue=0] - Valore minimo della scala numerica da cui parte il calcolo della posizione.
75
+ */
76
+ scaleStartValue = 0,
77
+
78
+ /**
79
+ * @param {boolean} [rodPositionForceCenter=false] - Se true, l'asticella punta sempre al centro della fascia; se false, segue la proporzione del valore esatto.
80
+ */
81
+ rodPositionForceCenter = false,
82
+
83
+
84
+ /**
85
+ * @param {number|null} [value=null] - Valore numerico da rappresentare. Determina il colore e la posizione dell'asticella.
86
+ */
87
+ value = null,
88
+
89
+ /**
90
+ * @param {string[]|null} [scaleColors=null] - Array di colori per le porzioni della scala. Se null, usa i colori di default della chartInstance.
91
+ */
92
+ scaleColors = null,
93
+
94
+ /** * @param {Object|null} [scaleAttrs=null] - Attributi SVG aggiuntivi applicati ai percorsi (path) delle porzioni della scala.
95
+ */
96
+ scaleAttrs = null,
97
+
98
+ /**
99
+ * @param {string|null} [scaleTicksColor=null] - Colore dei segni di separazione tra le porzioni. Se null, usa lo stesso colore della porzione adiacente.
100
+ */
101
+ scaleTicksColor = null,
102
+
103
+ /**
104
+ * @param {Object} [rodAttrs] - Attributi per l'asticella indicatrice e il suo anello centrale.
105
+ */
106
+ rodAttrs = {
107
+ fill: '#000'
108
+ },
109
+
110
+ /**
111
+ * @param {Object[]|null} [displayLabel=null] - Testo centrale sotto l'asticella. Array di max 2 oggetti: {label, font, fill, fontFilePath}.
112
+ */
113
+ displayLabel = null,
114
+
115
+ /**
116
+ * @param {number} [displayLabelTopMargin=4] - Distanza verticale tra l'anello dell'asticella e la prima riga dell'etichetta.
117
+ */
118
+ displayLabelTopMargin = 4,
119
+
120
+ /**
121
+ * @param {number} [displayLabelRowGap=2] - Spazio verticale tra la prima e la seconda riga dell'etichetta principale.
122
+ */
123
+ displayLabelRowGap = 2,
124
+
125
+ /**
126
+ * @param {Object[]|null} [miniDisplay=null] - Configurazione mini display secondari (max 2). Srray di uno o due oggetti
127
+ * @param {string} miniDisplay[].position - Posizione: 'sx' | 'dx' (alias: 'left' | 'right').
128
+ * @param {number} miniDisplay[].value - Valore numerico da rappresentare.
129
+ * @param {string} miniDisplay[].type - Tipo di visualizzazione: 'gauge' (tachimetro) o 'value' (testo di `value`).
130
+ * @param {Object} [miniDisplay[].typeValueFont] - oggetto analogo a quelli definito in displayLabel per la rappresentazione di `type: value`. Se typeValueFont.label non è presente, viene utilizzato `value` (arrotondato all'intero)
131
+ * @param {Object[]} [miniDisplay[].mdLabel] - Array di etichette per il mini display (stessa struttura di displayLabel).
132
+ * @param {Object} [miniDisplay[].mdArcAttrs] - oggetto simile a displayBackgroundAttrs, è relativo all'arco che avvolge il mini display
133
+ * @param {Object} [miniDisplay[].mdRodAttrs] - Attributi per l'asticella del mini display.
134
+ */
135
+ miniDisplay = null,
136
+
137
+ /**
138
+ * @param {number} [miniDisplayTopMargin=0] - Distanza verticale tra la base del display principale e l'inizio dei mini display.
139
+ */
140
+ miniDisplayTopMargin = 0,
141
+
142
+ /**
143
+ * @param {number} [miniDisplayLabelTopMargin=0] - Distanza verticale tra la base dell'arco del mini display e le sue etichette.
144
+ */
145
+ miniDisplayLabelTopMargin = 0,
146
+
147
+ /**
148
+ * @param {number} [miniDisplayLabelRowGap=2] - Distanza tra le righe di testo (mdLabel) del mini display.
149
+ */
150
+ miniDisplayLabelRowGap = 2,
151
+
152
+ /**
153
+ * @param {boolean} [animation=false] - Se true, abilita l'animazione dell'asticella (richiede implementazione specifica).
154
+ */
155
+ animation = false,
156
+
157
+ // animation_ms = 100, // number
158
+ // animation_fps = 60, // number
159
+
160
+
161
+ }) {
162
+
163
+ const chartInstance = this;
164
+
165
+ // TODO la rotazione dell'asticella deve tenere conto degli step, non può basarsi unicamente sul calcolo dell'angolo
166
+ // in modo particale se i valori degli step non sono uguali
167
+ // TODO opzione per prozioni disuguali basate sul loro valore? (es. 3 porzioni con rapporto 1 1 2, in cui l'ultima corrisponde ad un valore delle altre)
168
+ // TODO valutare l'utilizzo di un ovale ancihé un cerchio per la figura base
169
+ try {
170
+
171
+ scaleColors??= chartInstance.defaults.colors;
172
+
173
+ // colore e indice della porzione corrispondenti al valore
174
+ const getColorFromValue = value => {
175
+ const portionIndex = Math.max(
176
+ 0,
177
+ Math.min( portions - 1, Math.floor(value) - scaleStartValue)
178
+ );
179
+ return [portionIndex, scaleColors[portionIndex]];
180
+ };
181
+
182
+
183
+ // container element e rilevamento larghezza se necessario
184
+ // l'altezza viene calcolata alla fine
185
+ [container, width] = parseContainer({ container: container, width: width });
186
+
187
+ // =>> display principale
188
+ // raggio circonferenza esterna
189
+ const mainRadius = (width/2) - parseFloat(displayBackgroundAttrs?.['stroke-width']??0) - displayPadding??0,
190
+
191
+ /*
192
+ altezza dell'area del display
193
+ è pari al raggio + la distanza corrispondente ad uno spostamento di 30°
194
+ (l'arco di cerchio dell'area display è di 240°),
195
+ distanza che corrisponde al centro di rotazione dell'asticella.
196
+ La base dell'area del display principale è quindi la corda posizionata agli estremi dell'arco.
197
+ In questa fase corrisponde all'altezza dell'SVG.
198
+ Viene ricalcolato dopo l'eventuale aggiunta dei miniDisplay
199
+ */
200
+ height = mainRadius + (mainRadius * Math.sin(rad30)) +
201
+ // spessore traccia arco esterno, se presente (solo per la parte superiore)
202
+ parseFloat(displayBackgroundAttrs?.['stroke-width']??0) +
203
+ // padding
204
+ displayPadding??0 * 2,
205
+ /*
206
+ centro circonferenza principale = centro di rotazione dell'asticella
207
+ */
208
+ mainDisplayCenter = {x: width/2, y: width/2}
209
+ ;
210
+
211
+ centerGap = typeof centerGap === 'number'? centerGap : centerGap(mainRadius);
212
+ scaleEndThickness = typeof scaleEndThickness === 'number'? scaleEndThickness : scaleEndThickness(mainRadius);
213
+
214
+
215
+ // =>> verifiche e impostazioni di base
216
+ if(value == null || typeof value !== 'number') {
217
+ throw '`value` è obbligatorio e deve essere un numero';
218
+ }
219
+
220
+ // valore massimo rappresentabile
221
+ value = Math.min(value, portions);
222
+
223
+
224
+ // il centro `C` deve rimanere all'interno della circonferenza `a` per permettere la tangenza nel punto `Tc`:
225
+ // dC < rA
226
+ if(centerGap >= mainRadius) {
227
+ throw 'Il valore `centerGap` deve essere minore della lunghezza del raggio principale `mainRadius`';
228
+ }
229
+
230
+
231
+ // lo spessore finale `s` della scala non deve portare il raggio `rB` a valori negativi o incoerenti:
232
+ // dB + s < rA
233
+ if(centerGap + scaleEndThickness >= mainRadius) {
234
+ throw 'La somma dei valori `centerGap` e `scaleEndThickness` deve essere inferiore alla lunghezza del raggio principale `mainRadius`';
235
+ }
236
+
237
+
238
+ /*
239
+ la scala parte da 1 e arriva a `portions` (viene tolta una piccola
240
+ porzione finale per maggiore precisione)
241
+ */
242
+ const parsedDisplayValue = rodPositionForceCenter? Math.floor(value) + .499 : value;
243
+
244
+
245
+ // =>> animazione e posizione iniziale dell'asticella (rod)
246
+ let rotazioneAsticella = 0;
247
+
248
+ if(animation) {
249
+
250
+ // TODO animazione
251
+
252
+ // // NB la mutazione non è rilevata se la card è già visibile nel viewport
253
+ // // per questo è presente l'if iniziale
254
+ // if(inViewport) {
255
+ // display_animation();
256
+ // }
257
+
258
+ } else {
259
+ rotazioneAsticella = Math.max(0, ((parsedDisplayValue - scaleStartValue) * 240) / (portions - .001));
260
+ }
261
+
262
+
263
+
264
+ // =>> definizione SVG (viewbox e size vengono impostati alla fine, perché vengono definiti durante l'elaborazione)
265
+ const svgCanvas = chartInstance.createSvgCanvas(container);
266
+ svgCanvas
267
+ .fill('none')
268
+ .attr({...svgAttrs})
269
+ ;
270
+
271
+
272
+ // =>> gruppo Display Principale
273
+ const mainDisplayGroup = svgCanvas.group()
274
+ .attr({
275
+ 'data-debug-info': debug? 'Gruppo Display Principale' : null,
276
+ });
277
+
278
+
279
+ // elemento che funge da background esterno del display
280
+ // NB: è aperto nella parte inferiore, quindi in basso l'eventuale bordo non viene applicato
281
+
282
+
283
+ // =>> area background
284
+ // solo se esistono proprietà in `displayBackgroundAttrs`
285
+
286
+ if(Object.keys(displayBackgroundAttrs??{}).length) {
287
+
288
+ if(displayBackgroundAttrs.stroke && !displayBackgroundAttrs['stroke-width']) {
289
+ displayBackgroundAttrs['stroke-width'] = 1;
290
+ }
291
+
292
+ mainDisplayGroup.path(
293
+ getDisplayBackgroundPath(width/2 - parseFloat(displayBackgroundAttrs?.['stroke-width']??0)/2, mainDisplayCenter.x, mainDisplayCenter.y)
294
+ )
295
+ .attr({
296
+ 'data-debug-info': debug? 'area display principale' : null,
297
+ ...(displayBackgroundAttrs??{})
298
+ });
299
+ }
300
+
301
+
302
+ // =>> scala
303
+ const scaleGroup = mainDisplayGroup.group().attr({
304
+ 'data-debug-info': debug? 'scales' : null,
305
+ stroke: 'none',
306
+ ...(scaleAttrs?? {}),
307
+ }),
308
+ ticksGroup = mainDisplayGroup.group().attr({
309
+ 'data-debug-info': debug? 'ticks' : null,
310
+ fill: 'none',
311
+ 'stroke-width': .5,
312
+ 'stroke-linecap': 'butt',
313
+ }),
314
+ angoloInizio = 210,
315
+ ampiezzaTotale = 240,
316
+ ampiezzaPorzione = ampiezzaTotale / portions,
317
+
318
+ // parametri condivisi dalla funzione di calcolo dei punti interni
319
+ // i
320
+ configCalcoloPuntiInterni = {
321
+ // raggi della circonferenze di sinistra e destra (nella documentazione *c*, in verde, e *b*, in rosso)
322
+ rSx: mainRadius - centerGap, // rB
323
+ rDx: mainRadius - centerGap - scaleEndThickness, // rC
324
+
325
+ // centri delle circonferenze, non necesarie alla funzione calcolaPuntoInterno ma incluse in questo oggetto per altri utilizzi
326
+ centroSx: polarToCartesian(210, mainDisplayCenter.x, mainDisplayCenter.y, centerGap), // C nella documentazione
327
+ centroDx: polarToCartesian(30, mainDisplayCenter.x, mainDisplayCenter.y, centerGap), // B
328
+
329
+ mainCenter: mainDisplayCenter,
330
+ centerGap: centerGap,
331
+
332
+ // scaleEndThickness: scaleEndThickness
333
+ };
334
+
335
+ // =>> porzioni e ticks
336
+ for(let p = 0; p < portions; p++) {
337
+
338
+ // if(p < portions - 1) continue;
339
+
340
+ let startDeg = angoloInizio - (p * ampiezzaPorzione),
341
+ endDeg = startDeg - ampiezzaPorzione;
342
+
343
+
344
+ // aggiunta gap
345
+ if(p > 0) {
346
+ startDeg -= portionGap/2;
347
+ }
348
+ if (p === portions - 1) {
349
+ endDeg = angoloInizio - ampiezzaTotale; // -30
350
+ } else {
351
+ endDeg += portionGap/2;
352
+ }
353
+
354
+ startDeg = normalizeAngle(startDeg);
355
+ endDeg = normalizeAngle(endDeg);
356
+
357
+
358
+ // seguendo la documentazione, i 4 punti da indiduare per ogni porzione sono
359
+ // punti esterni esternoSX (D nella documentazione) e esternoDX (E)
360
+ // punti interni: internoSX (G) e internoDX (F)
361
+ // nella porzione iniziale esternoSX e internoSX coincidono
362
+
363
+ // punti esterni: corrispondono alle coordinate polari per l'angolo dato e il raggio mainRadius
364
+ const esternoSX = polarToCartesianString(startDeg, mainDisplayCenter.x, mainDisplayCenter.y, mainRadius),
365
+ esternoDX = polarToCartesianString(endDeg, mainDisplayCenter.x, mainDisplayCenter.y, mainRadius);
366
+
367
+ // punti interni
368
+ // sono ricavati da una funzione che calcola la distanza rho dal centro della circ. esterna (rA),
369
+ // basandosi su:
370
+ // l'angolo della semiretta che contiene il punto
371
+ // la distanza centerGap tra il centro della circonferenza principale e quella interna in uso (d nella documentazione)
372
+ // e lo spessore finale della scala (scaleEndThickness = s nella documentazione)
373
+
374
+ // tutti i parametri, escluso l'angolo, sono uguali per tutti i punti e sono definiti
375
+ // nell'oggetto configPuntiInterni, definito in precedenza
376
+
377
+ const internoSX = calcolaPuntoInterno({angoloDeg: startDeg, ...configCalcoloPuntiInterni}), // sx
378
+ internoDX = calcolaPuntoInterno({angoloDeg: endDeg, ...configCalcoloPuntiInterni}); // dx
379
+
380
+ // se la porzione include l'angolo di 30° è necessario cambiare circonferenza
381
+ let cambioCirconferenza = '';
382
+ if(!isCircleDx(startDeg) && isCircleDx(endDeg)) {
383
+
384
+
385
+ // coordinate punto sulla circonferenza interna rossa con angolo di 30 gradi
386
+ const punto30 = polarToCartesianString(
387
+ 30,
388
+ configCalcoloPuntiInterni.centroDx.x,
389
+ configCalcoloPuntiInterni.centroDx.y,
390
+ configCalcoloPuntiInterni.rDx
391
+ );
392
+ cambioCirconferenza =
393
+ `A${configCalcoloPuntiInterni.centroDx.x},${configCalcoloPuntiInterni.centroDx.y} 0 0 0 ${punto30}`;
394
+ }
395
+
396
+ const path = `M${esternoSX}` + // punto esterno sx
397
+ // rx ry x-axis-angle large-arc-flag sweep-flag end-x end-y
398
+ `A${mainDisplayCenter.x},${mainDisplayCenter.y} 0 0 1 ${esternoDX}` + // punto esterno dx
399
+ `L${internoDX}` + // punto interno dx
400
+ cambioCirconferenza +
401
+ `A${mainDisplayCenter.x},${mainDisplayCenter.y} 0 0 0 ${internoSX}` + // punto interno sx
402
+ 'z'
403
+
404
+ ;
405
+
406
+ console.log(p, path);
407
+
408
+ scaleGroup.path(path).attr({
409
+ fill: scaleColors?.[p]?? '#000',
410
+ ...{fill: 'none', stroke: 'blue', 'stroke-width': 3},
411
+ });
412
+
413
+
414
+ // ticksGroup.path(p)
415
+ // .attr({
416
+ // stroke: scaleTicksColor? scaleTicksColor : scaleColors?.[idx + 1]?? '#000',
417
+ // });
418
+
419
+ }
420
+
421
+
422
+ // =>> anello e asticella
423
+ const rodGroup = mainDisplayGroup.group()
424
+ .attr({
425
+ 'data-debug-info': debug? 'asticella' : null
426
+ });
427
+
428
+ // anello
429
+ // https://www.smashingmagazine.com/2019/03/svg-circle-decomposition-paths/
430
+ const ringPath = ({cx, cy, outerRadius, innerRadius}) => {
431
+
432
+ return `M${cx - outerRadius},${cy}`+
433
+ // rx ry x-axis-angle large-arc-flag sweep-flag end-x end-y
434
+ `A${outerRadius},${outerRadius} 0 1 0 ${cx+outerRadius},${cy}` +
435
+ `A${outerRadius},${outerRadius} 0 1 0 ${cx-outerRadius},${cy}` +
436
+
437
+ `M${cx - innerRadius},${cy}` +
438
+ `A${innerRadius},${innerRadius} 0 1 0 ${cx+innerRadius},${cy}` +
439
+ `A${innerRadius},${innerRadius} 0 1 0 ${cx-innerRadius},${cy}`;
440
+ };
441
+
442
+ // parametri condivisi tra asticella e anello
443
+ const ringOuterRadius = mainRadius / 9,
444
+ ringInnerRadius = mainRadius / 14
445
+ ;
446
+
447
+ rodGroup.path(ringPath({
448
+ cx: mainDisplayCenter.x,
449
+ cy: mainDisplayCenter.y,
450
+ outerRadius: ringOuterRadius,
451
+ innerRadius: ringInnerRadius
452
+ }))
453
+ .attr({
454
+ 'fill-rule': 'evenodd',
455
+ ...(rodAttrs?? {})
456
+ });
457
+
458
+ // bbox del solo anello di base dell'asticella
459
+ // usato per il posizionamento delle etichette
460
+ const rodGroupBaseBbox = rodGroup.bbox();
461
+
462
+ // asticella
463
+ // rodGroup.polygon('59.1813 139.0943 59.1813 139.0943 98.8572 108.491 105.5448 120.0899 59.1813 139.0943')
464
+ const ampiezzaAsticella = 90 // degrees
465
+ ,lunghezzaAsticella = mainRadius * .55 // TODO considerare spessore finale scala
466
+ // distanza dal centro della circonferenza alla base dell'asticella, deve essere interno al'anello
467
+ ,distanzaCentroBaseAsticella = ringInnerRadius + (ringOuterRadius - ringInnerRadius)/2
468
+ ;
469
+
470
+ rodGroup.path(
471
+ `M${polarToCartesianString(
472
+ 210,
473
+ mainDisplayCenter.x, mainDisplayCenter.y,
474
+ lunghezzaAsticella
475
+ )}`+
476
+ `L${polarToCartesianString(
477
+ 210 - (ampiezzaAsticella/2),
478
+ mainDisplayCenter.x, mainDisplayCenter.y,
479
+ distanzaCentroBaseAsticella
480
+ )}`+
481
+
482
+ // rx ry angle large-arc-flag sweep-flag x y
483
+ `A${distanzaCentroBaseAsticella},${distanzaCentroBaseAsticella} ${ampiezzaAsticella} 0 0 `+
484
+ `${polarToCartesianString(
485
+ 210 + (ampiezzaAsticella/2),
486
+ mainDisplayCenter.x, mainDisplayCenter.y,
487
+ distanzaCentroBaseAsticella
488
+ )}`+
489
+ 'Z'
490
+ )
491
+ .transform({
492
+ origin: {
493
+ x: mainDisplayCenter.x,
494
+ y: mainDisplayCenter.y,
495
+ },
496
+ rotate: rotazioneAsticella
497
+ })
498
+ .attr({
499
+ 'data-debug-info': debug? `Value: ${parsedDisplayValue}, {Angolo: ${rotazioneAsticella}, Start value: ${scaleStartValue}` : null,
500
+ ...(rodAttrs?? {})
501
+ });
502
+
503
+ /*
504
+ // =>> function setLabel
505
+ const setLabel = async ({
506
+ labelObj,
507
+ baseX,
508
+ baseY,
509
+ dy,
510
+ rowGap = 0, // ulteriore correzione tra le distanze delle due righe
511
+ parentEl = svgCanvas
512
+ }) => {
513
+ if(labelObj?.label) {
514
+
515
+ const [label_bbox, labelEl] = await createTextEl (chartInstance, {
516
+ text: labelObj.label,
517
+ textFill: labelObj.fill??= '#000',
518
+ fontAttr: labelObj.font??{},
519
+ fontFilePath: labelObj.fontFilePath,
520
+ parentElement: parentEl
521
+ });
522
+
523
+ // posizionamento Y
524
+ // dy è lo spostamento aggiuntivo pari all'altezza dell'eventuale riga precedente
525
+ const labelY = baseY + (dy? dy + rowGap : 0);
526
+
527
+ // posizionamento, al centro sotto l'asticella
528
+ labelEl.move(
529
+ baseX - label_bbox.width / 2,
530
+ labelY
531
+ )
532
+ .attr({
533
+ 'data-debug-info': debug? `label “${labelObj.label}”` : null
534
+ });
535
+
536
+ return label_bbox.height;
537
+
538
+ }
539
+ };
540
+
541
+ // =>> displayLabel
542
+ if(displayLabelTopMargin != null && typeof displayLabelTopMargin !== 'number') {
543
+ throw '`displayLabelTopMargin` deve essere null o un numero';
544
+ }
545
+
546
+ displayLabel??=[];
547
+ if(displayLabel.length > 2) {
548
+ throw 'Non è possibile definire più di 2 elementi `displayLabel`';
549
+ }
550
+
551
+ let prevLabelHeight = 0;
552
+ for await (const labelObj of displayLabel) {
553
+
554
+ prevLabelHeight = await setLabel({
555
+ labelObj: labelObj,
556
+ baseX: mainDisplayCenter.x,
557
+ baseY: rodGroupBaseBbox.y2 + (displayLabelTopMargin??0),
558
+ dy: prevLabelHeight,
559
+ rowGap: displayLabelRowGap,
560
+ parentEl: mainDisplayGroup
561
+ });
562
+ }
563
+
564
+ // =>> ricalcolo altezza gruppo principale
565
+ const mainDisplayGroupBbox = mainDisplayGroup.bbox();
566
+ height= mainDisplayGroupBbox.y + mainDisplayGroupBbox.y2;
567
+
568
+ // SECTION miniDisplay
569
+ if(miniDisplayTopMargin != null && typeof miniDisplayTopMargin !== 'number') {
570
+ throw '`miniDisplayTopMargin` deve essere null o un numero';
571
+ }
572
+
573
+ miniDisplay??=[];
574
+ if(miniDisplay.length > 2) {
575
+ throw 'Non è possibile impostare più di 2 `miniDisplay`';
576
+ }
577
+
578
+
579
+ let miniDisplayIdx = 1, miniDisplayMaxHeight = 0, mdArcAttrsHeight = null;
580
+ for await (const md of miniDisplay) {
581
+
582
+
583
+ const errorPrefix = `miniDisplay #${miniDisplayIdx}`;
584
+
585
+ // =>> controlli
586
+ ['position', 'type', 'value'].forEach(item => {
587
+ if(md[item] == null) {
588
+ throw `${errorPrefix}: '${item}' è obbligatorio`;
589
+ }
590
+ });
591
+
592
+ if(['sx', 'dx', 'left', 'right'].indexOf(md.position) === -1) {
593
+ throw `${errorPrefix}: 'position' deve essere uno tra 'sx', 'dx', 'left' o 'right'`;
594
+ }
595
+
596
+ if(typeof md.value !== 'number') {
597
+ throw `${errorPrefix}: 'value' deve essere un numero`;
598
+ }
599
+
600
+ if(['gauge', 'value'].indexOf(md.type) === -1) {
601
+ throw `${errorPrefix}: 'type' deve essere uno tra 'gauge' o 'value'`;
602
+ }
603
+
604
+ // default
605
+ md.mdArcAttrs = {
606
+ // defaults
607
+ ...{
608
+ fill: 'none',
609
+ 'stroke-linecap': 'round',
610
+ 'stroke-width': 0
611
+ },
612
+ ...(md.mdArcAttrs??{})
613
+ };
614
+
615
+
616
+ // =>> dimensioni
617
+ const
618
+ // raggio circonferenza che include l'arco
619
+ mdRadius = 26,
620
+
621
+ mdWidrh = (mdRadius * 2) + (md.mdArcAttrs?.['stroke-width']??0), // impegna sia la parte destra che la sinistra
622
+
623
+ // margine orizzontale dal bordo esterno dell'SVG
624
+ xMargin = 20,
625
+
626
+ // coordinate dell'origine del miniDisplay
627
+ mdOrigin = {
628
+ y: height+ (miniDisplayTopMargin?? 0),
629
+ x: (md.position === 'sx' || md.position === 'left')
630
+ ? xMargin
631
+ : width - xMargin - mdWidrh
632
+ }
633
+ ;
634
+
635
+
636
+ // =>> mdGroup
637
+ const mdGroup = svgCanvas.group()
638
+ .attr({
639
+ 'data-debug-info': debug? `miniDisplay #${miniDisplayIdx}` : null,
640
+ });
641
+
642
+ // =>> definizione arco
643
+ const
644
+ // coordinate del centro della circonferenza che include l'arco
645
+ mdArcAttrsCx= mdOrigin.x + mdRadius, // + md.mdArcAttrs.['stroke-width'] / 2,
646
+ mdArcAttrsCy= mdOrigin.y + mdRadius // + md.mdArcAttrs.['stroke-width'] / 2,
647
+ ;
648
+
649
+ // minidisplay arc
650
+ mdGroup.path(getDisplayBackgroundPath(mdRadius, mdArcAttrsCx, mdArcAttrsCy))
651
+ .attr({
652
+ 'data-debug-info': debug? `miniDisplay #${miniDisplayIdx}: arco` : null,
653
+ ...md.mdArcAttrs,
654
+ });
655
+
656
+ // altezza arco, per il posizionamento delle etichette
657
+ // viene misurato solo il primo arco, in modo che le etichette siano comq allineate
658
+ // se necessario, utilizzare il parametro miniDisplayLabelTopMargin per distanziare il testo dalla base dell'arco
659
+ if(mdArcAttrsHeight == null) {
660
+ mdArcAttrsHeight = mdGroup.bbox().height;
661
+ }
662
+
663
+
664
+ // =>> modalità "value"
665
+ if(md.type === 'value') {
666
+
667
+ md.typeValueFont ??= {};
668
+ md.typeValueFont.label ??= String(Math.floor(md.value));
669
+
670
+ const [value_bbox, valueEl] = await createTextEl (chartInstance, {
671
+ text: md.typeValueFont.label,
672
+ textFill: md.typeValueFont.fill??= '#000',
673
+ fontAttr: md.typeValueFont.font??{},
674
+ fontFilePath: md.typeValueFont.fontFilePath,
675
+ parentElement: mdGroup
676
+ });
677
+
678
+ valueEl.move(
679
+ mdArcAttrsCx - value_bbox.w/2,
680
+ mdArcAttrsCy - value_bbox.h/2 - 2, // 2: spostamento basline verso l'alto
681
+ )
682
+ .attr({
683
+ 'data-debug-info': debug? `miniDisplay #${miniDisplayIdx}: value` : null,
684
+ });
685
+
686
+ // =>> modalità "gauge"
687
+ } else {
688
+
689
+ // segmento indicatore colore
690
+ const segmentStrokeWidth = 6
691
+ // spazio segmento arco
692
+ ,distanzaSegmentoArco = 2
693
+ // correzione raggio per dare spazio allo spessore della traccia
694
+ ,segmentRadius = mdRadius - segmentStrokeWidth/2 - distanzaSegmentoArco
695
+ // ampiezza angolo che sottende il segmento circolare
696
+ // 30 è l'angolo del punto iniziale
697
+ ,segmentAngle = toRadians(240/portions - 30)
698
+
699
+ ,startPointX = mdArcAttrsCx - (segmentRadius * Math.cos(rad30))
700
+ ,startPointY = mdArcAttrsCy + (segmentRadius * Math.sin(rad30))
701
+ ,endPointX = mdArcAttrsCx - (segmentRadius * Math.cos(segmentAngle))
702
+ ,endPointY = mdArcAttrsCy - (segmentRadius * Math.sin(segmentAngle))
703
+
704
+ ,segmentPath = `M ${startPointX},${startPointY} ` +
705
+ // rx ry x-axis-angle large-arc-flag sweep-flag end-x end-y
706
+ `A ${segmentRadius} ${segmentRadius} 0 0 1 ${endPointX} ${endPointY}`
707
+
708
+ ,segmentGroup = mdGroup.group()
709
+ ;
710
+
711
+ // =>> segmento mini display gauge
712
+ segmentGroup.path(segmentPath)
713
+ .attr({
714
+ stroke: getColorFromValue(md.value)[1],
715
+ 'stroke-width': segmentStrokeWidth,
716
+ });
717
+
718
+ // =>> anello mini asticella
719
+ const ringOuterRadius = 12;
720
+ mdGroup.path( ringPath({
721
+ cx: mdArcAttrsCx,
722
+ cy: mdArcAttrsCy,
723
+ outerRadius: ringOuterRadius,
724
+ innerRadius: 6
725
+ }))
726
+ .attr({
727
+ ...(rodAttrs?? {}),
728
+ 'fill-rule': 'evenodd'
729
+ });
730
+
731
+ // =>> mini asticella
732
+ // viene prima creata allineata con l'asse X del mini display e poi ruotata al centro della porzione d'inizio
733
+ // in seguito, la rotazione applicata al gruppo la porta nella posizione definitiva
734
+ segmentGroup.polygon(
735
+ `${mdArcAttrsCx - ringOuterRadius/2 + 2},${mdArcAttrsCy - 3} `+
736
+ `${mdArcAttrsCx - segmentRadius + 6},${mdArcAttrsCy} `+
737
+ `${mdArcAttrsCx - ringOuterRadius/2 + 2},${mdArcAttrsCy + 3}`
738
+ )
739
+ .attr(rodAttrs?? {})
740
+ .transform({
741
+ origin: {
742
+ x: mdArcAttrsCx,
743
+ y: mdArcAttrsCy,
744
+ },
745
+ rotate: ((240/portions) / 2) - 30
746
+ })
747
+ ;
748
+
749
+ segmentGroup.transform({
750
+ origin: {
751
+ x: mdArcAttrsCx,
752
+ y: mdArcAttrsCy,
753
+ },
754
+ rotate: (240/portions) * getColorIndexForValueXXXXX(md.value) - scaleStartValue
755
+ });
756
+ }
757
+
758
+ // =>> label mini display
759
+
760
+ if(miniDisplayLabelTopMargin != null && typeof miniDisplayLabelTopMargin !== 'number') {
761
+ throw `${errorPrefix}: 'miniDisplayLabelTopMargin' deve essere null o un numero`;
762
+ }
763
+
764
+ md.mdLabel??=[];
765
+ if(md.mdLabel.length > 2) {
766
+ throw `${errorPrefix}: non è possibile definire più di 2 elementi 'mdLabel'`;
767
+ }
768
+
769
+
770
+ let prevLabelHeight = 0;
771
+ for await (const labelObj of md.mdLabel) {
772
+
773
+ prevLabelHeight = await setLabel({
774
+ labelObj: labelObj,
775
+ baseX: mdArcAttrsCx,
776
+ baseY: mdOrigin.y + mdArcAttrsHeight + (miniDisplayLabelTopMargin??0),
777
+ dy: prevLabelHeight,
778
+ rowGap: miniDisplayLabelRowGap,
779
+ parentEl: mdGroup
780
+ });
781
+ }
782
+
783
+
784
+
785
+ // =>> indicatori per debug
786
+ if(debug) {
787
+ // centro mini display
788
+ const gap = 10;
789
+ const debug_group = mdGroup.group()
790
+ .attr({
791
+ 'data-debug-info': 'centro mini display',
792
+ stroke: '#05923e',
793
+ fill: 'none',
794
+ 'stroke-width': .5,
795
+ class: 'debug-info'
796
+ });
797
+ debug_group.line({x1: mdArcAttrsCx - gap, y1: mdArcAttrsCy, x2: mdArcAttrsCx + gap, y2: mdArcAttrsCy});
798
+ debug_group.line({x1: mdArcAttrsCx, y1: mdArcAttrsCy - gap, x2: mdArcAttrsCx, y2: mdArcAttrsCy + gap});
799
+
800
+ // origine mini display
801
+ // debug_group.line({x1: mdOrigin.x - gap, y1: mdOrigin.y, x2: mdOrigin.x + gap, y2: mdOrigin.y});
802
+ // debug_group.line({x1: mdOrigin.x, y1: mdOrigin.y - gap, x2: mdOrigin.x, y2: mdOrigin.y + gap});
803
+ }
804
+
805
+ // =>> ricalcolo miniDisplayMaxHeight
806
+ miniDisplayMaxHeight = Math.max(
807
+ miniDisplayMaxHeight,
808
+ mdGroup.bbox().h + (miniDisplayTopMargin?? 0) + md.mdArcAttrs['stroke-width'] // strokeWidth: spessore traccia nel caso non ci sia testo
809
+ );
810
+ miniDisplayIdx++;
811
+
812
+ }
813
+
814
+ // !SECTION end miniDisplay
815
+
816
+
817
+ // =>> aggiornamento dimensioni SVG
818
+ height+= miniDisplayMaxHeight;
819
+ // width = mainDisplayGroupBbox.w;
820
+ */
821
+
822
+ svgCanvas
823
+ // .size(width, height)
824
+ .viewbox(0, 0, width, height);
825
+
826
+
827
+ // indicatori per debug
828
+ if(debug) {
829
+ import('./debug-tools.js').then(m => m.run({
830
+ mainDisplayCenter: mainDisplayCenter,
831
+ width: width,
832
+ height: height,
833
+ mainRadius: mainRadius,
834
+ // centerGap: centerGap,
835
+ // scaleEndThickness: scaleEndThickness
836
+
837
+ ...configCalcoloPuntiInterni
838
+ }).addTo(svgCanvas));
839
+
840
+ }
841
+
842
+ if(!container) {
843
+ return svgCanvas.svg();
844
+ }
845
+
846
+ } catch(e) {
847
+ console.error( 'MinimoCharts → ratingDisplay:', e ); // eslint-disable-line
848
+ }
849
+ }