@massimo-cassandro/minimo 1.29.6 → 1.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/charts/index.js +2 -1
- package/charts/src/bars.js +58 -20
- package/charts/src/hbars.js +419 -0
- package/charts/src/helpers/cartesian-axis.js +262 -4
- package/charts/src/helpers/parse-std-axis-opts.js +51 -10
- package/charts/src/lines.js +6 -2
- package/design-tokens/tokens-config-sample.mjs +1 -1
- package/dev-tools/README.md +3 -11
- package/dev-tools/starter-kit/starter-install.sh +4 -3
- package/package.json +1 -1
- package/src/components/modal-popup/modal-popup.js +79 -28
- package/src/css/forms/forms.css +5 -0
package/charts/index.js
CHANGED
|
@@ -4,9 +4,9 @@ import { MinimoCharts } from './src/helpers/core.js';
|
|
|
4
4
|
import { bars } from './src/bars.js';
|
|
5
5
|
import { goal } from './src/goal.js';
|
|
6
6
|
import { lines } from './src/lines.js';
|
|
7
|
+
import { hbars } from './src/hbars.js';
|
|
7
8
|
// import { ratingDisplay } from './src/rating-display/index.js';
|
|
8
9
|
// import { pie } from './src/charts/pie.js';
|
|
9
|
-
// import { hbars } from './src/charts/hbars.js';
|
|
10
10
|
// import { signalBars } from './src/charts/signal-bars.js';
|
|
11
11
|
// import { radialBars } from './src/charts/radial-bars.js';
|
|
12
12
|
// import { settimanaleMensile } from './src/charts/settimanale-mensile.js';
|
|
@@ -17,6 +17,7 @@ import { lines } from './src/lines.js';
|
|
|
17
17
|
export const barsChart = (chartInstance, params) => bars.call(chartInstance, params);
|
|
18
18
|
export const goalChart = (chartInstance, params) => goal.call(chartInstance, params);
|
|
19
19
|
export const linesChart = (chartInstance, params) => lines.call(chartInstance, params);
|
|
20
|
+
export const hBarsChart = (chartInstance, params) => hbars.call(chartInstance, params);
|
|
20
21
|
// export const ratingDisplayChart = (chartInstance, params) => ratingDisplay.call(chartInstance, params);
|
|
21
22
|
|
|
22
23
|
export { MinimoCharts };
|
package/charts/src/bars.js
CHANGED
|
@@ -101,9 +101,14 @@ const default_params = {
|
|
|
101
101
|
xAxisTitleAttrs: {...default_font_attrs, size: 18},
|
|
102
102
|
xAxisTitleFontFilePath: null,
|
|
103
103
|
|
|
104
|
-
/**
|
|
104
|
+
/**
|
|
105
|
+
valori minimi e massimi per l'asse Y, se null vengono calcolati automaticamente.
|
|
106
|
+
Il calcolo automatico di min_value usa 0 come soglia massima, per far sì che le
|
|
107
|
+
barre partano da 0 quando i dati sono tutti positivi, estendendo il minimo verso
|
|
108
|
+
il basso solo se sono presenti valori negativi
|
|
109
|
+
*/
|
|
105
110
|
max_value: null,
|
|
106
|
-
min_value:
|
|
111
|
+
min_value: null,
|
|
107
112
|
|
|
108
113
|
/**
|
|
109
114
|
classi e attributi dei vari elementi.
|
|
@@ -173,6 +178,11 @@ export async function bars(params) {
|
|
|
173
178
|
throw 'Tutti i valori dei sub-array di `values` devono essere numerici';
|
|
174
179
|
}
|
|
175
180
|
|
|
181
|
+
// calcolo automatico di min_value: 0 se i dati sono tutti positivi,
|
|
182
|
+
// altrimenti il valore minimo effettivo (per includere le barre negative nell'area del grafico)
|
|
183
|
+
if(params.min_value == null) {
|
|
184
|
+
params.min_value = Math.min(0, ...flatValues.filter(v => v != null));
|
|
185
|
+
}
|
|
176
186
|
|
|
177
187
|
// container
|
|
178
188
|
[, params.width, params.height] = parseContainer({ container: params.container, width: params.width, height: params.height });
|
|
@@ -237,9 +247,11 @@ export async function bars(params) {
|
|
|
237
247
|
max_value,
|
|
238
248
|
min_value,
|
|
239
249
|
chart_area,
|
|
250
|
+
stepY_count,
|
|
240
251
|
stepY_value,
|
|
241
252
|
stepY_size,
|
|
242
|
-
stepX_size
|
|
253
|
+
stepX_size,
|
|
254
|
+
zero_y
|
|
243
255
|
} = parseStdAxisOpts({
|
|
244
256
|
max_value: params.max_value,
|
|
245
257
|
min_value: params.min_value,
|
|
@@ -266,10 +278,12 @@ export async function bars(params) {
|
|
|
266
278
|
max_value,
|
|
267
279
|
min_value,
|
|
268
280
|
chart_area,
|
|
281
|
+
stepY_count,
|
|
269
282
|
stepY_value,
|
|
270
283
|
stepY_size,
|
|
271
284
|
stepX_size,
|
|
272
|
-
stepX_count
|
|
285
|
+
stepX_count,
|
|
286
|
+
zero_y
|
|
273
287
|
}
|
|
274
288
|
});
|
|
275
289
|
|
|
@@ -281,7 +295,9 @@ export async function bars(params) {
|
|
|
281
295
|
|
|
282
296
|
// calcolo larghezza barre
|
|
283
297
|
const barsWidth = (stepX_size - params.seriesGap - (params.barsGap * (params.values.length - 1))) / params.values.length
|
|
284
|
-
|
|
298
|
+
// il raggio dell'angolo non può essere più grande della barra stessa, né negativo
|
|
299
|
+
// (barsWidth può risultare negativo se seriesGap/barsGap non lasciano spazio sufficiente per tutte le serie)
|
|
300
|
+
,barsCornerRadius = Math.max(0, Math.min(params.barsCornerRadius, barsWidth / 2))
|
|
285
301
|
;
|
|
286
302
|
|
|
287
303
|
|
|
@@ -303,9 +319,15 @@ export async function bars(params) {
|
|
|
303
319
|
const isLast = idx === thisStepValues.length - 1;
|
|
304
320
|
|
|
305
321
|
if (itemValue != null) {
|
|
306
|
-
//
|
|
307
|
-
|
|
308
|
-
|
|
322
|
+
// valori negativi: la barra scende dalla linea dello zero verso il basso,
|
|
323
|
+
// invece di crescere dal basso verso l'alto
|
|
324
|
+
const isNegative = itemValue < 0,
|
|
325
|
+
// calcolo coordinata Y del valore e altezza assoluta della barra,
|
|
326
|
+
// usando come baseline la linea dello zero (zero_y) invece del fondo del grafico
|
|
327
|
+
itemY = chart_area.top + chart_area.height - (((itemValue - min_value) * chart_area.height) / (max_value - min_value)),
|
|
328
|
+
barTop = isNegative? zero_y : itemY,
|
|
329
|
+
barBottom = isNegative? itemY : zero_y,
|
|
330
|
+
barH = barBottom - barTop,
|
|
309
331
|
parteVerticaleBarra = barH - barsCornerRadius;
|
|
310
332
|
let path;
|
|
311
333
|
|
|
@@ -315,20 +337,36 @@ export async function bars(params) {
|
|
|
315
337
|
|
|
316
338
|
const altezzaCornerRadius = barH;
|
|
317
339
|
|
|
318
|
-
path =
|
|
319
|
-
|
|
320
|
-
`
|
|
321
|
-
|
|
322
|
-
|
|
340
|
+
path = isNegative
|
|
341
|
+
// angoli arrotondati in basso (lato lontano dallo zero), spigolo vivo in alto
|
|
342
|
+
? `M${barX},${barTop}` +
|
|
343
|
+
`q0,${altezzaCornerRadius} ${barsCornerRadius},${altezzaCornerRadius}` +
|
|
344
|
+
`h${barsWidth - barsCornerRadius * 2}` +
|
|
345
|
+
`q${barsCornerRadius},0 ${barsCornerRadius},-${altezzaCornerRadius}` +
|
|
346
|
+
'z'
|
|
347
|
+
// angoli arrotondati in alto (lato lontano dallo zero), spigolo vivo in basso
|
|
348
|
+
: `M${barX},${barBottom}` +
|
|
349
|
+
`q0,-${altezzaCornerRadius} ${barsCornerRadius},-${altezzaCornerRadius}` +
|
|
350
|
+
`h${barsWidth - barsCornerRadius * 2}` +
|
|
351
|
+
`q${barsCornerRadius},0 ${barsCornerRadius},${altezzaCornerRadius}` +
|
|
352
|
+
'z';
|
|
323
353
|
|
|
324
354
|
} else {
|
|
325
|
-
path =
|
|
326
|
-
`
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
355
|
+
path = isNegative
|
|
356
|
+
? `M${barX},${barTop}` +
|
|
357
|
+
`v${parteVerticaleBarra}` +
|
|
358
|
+
`q0,${barsCornerRadius} ${barsCornerRadius},${barsCornerRadius}` +
|
|
359
|
+
`h${barsWidth - barsCornerRadius * 2}` +
|
|
360
|
+
`q${barsCornerRadius},0 ${barsCornerRadius},-${barsCornerRadius}` +
|
|
361
|
+
`v-${parteVerticaleBarra}` +
|
|
362
|
+
'z'
|
|
363
|
+
: `M${barX},${barBottom}` +
|
|
364
|
+
`v-${parteVerticaleBarra}` +
|
|
365
|
+
`q0,-${barsCornerRadius} ${barsCornerRadius},-${barsCornerRadius}` +
|
|
366
|
+
`h${barsWidth - barsCornerRadius * 2}` +
|
|
367
|
+
`q${barsCornerRadius},0 ${barsCornerRadius},${barsCornerRadius}` +
|
|
368
|
+
`v${parteVerticaleBarra}` +
|
|
369
|
+
'z';
|
|
332
370
|
}
|
|
333
371
|
|
|
334
372
|
barsGroup.path(path)
|
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
// barre orizzontali — analogo di bars.js con assi invertiti:
|
|
2
|
+
// categorie sull'asse verticale (a sinistra), valori sull'asse orizzontale (in basso)
|
|
3
|
+
|
|
4
|
+
import { parseStdAxisOpts } from './helpers/parse-std-axis-opts.js';
|
|
5
|
+
import { parseContainer } from './helpers/chart-utils.js';
|
|
6
|
+
import { cartesianAxis } from './helpers/cartesian-axis.js';
|
|
7
|
+
import { legenda } from './helpers/legenda.js';
|
|
8
|
+
|
|
9
|
+
const default_lines_attrs = {
|
|
10
|
+
stroke: '#999',
|
|
11
|
+
'stroke-width': 1,
|
|
12
|
+
'shape-rendering': 'crispEdges',
|
|
13
|
+
'stroke-linecap': 'round',
|
|
14
|
+
fill: 'none'
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const default_font_attrs = {
|
|
18
|
+
// family: 'sans-serif',
|
|
19
|
+
size: 14,
|
|
20
|
+
// stretch: 'normal',
|
|
21
|
+
// style: 'normal',
|
|
22
|
+
// variant: 'normal',
|
|
23
|
+
weight: 400,
|
|
24
|
+
fill: '#999'
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const default_params = {
|
|
28
|
+
|
|
29
|
+
/** mostra info utili per il debug */
|
|
30
|
+
debug: false,
|
|
31
|
+
|
|
32
|
+
/** container (selettore o elemento DOM), se null viene restituito il codice SVG */
|
|
33
|
+
container: null,
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
larghezza e altezza del grafico (px),
|
|
37
|
+
Se il container è presente e i parametro `width` e `height` sono `null`,
|
|
38
|
+
vengono utilizzate le dimensioni del container.
|
|
39
|
+
Se uno dei due valori non è impostato o ricavabile, viene generato un errore.
|
|
40
|
+
NB: il padding del container viene considerato nelle dimensioni del grafico,
|
|
41
|
+
è preferibile non impostarlo.
|
|
42
|
+
*/
|
|
43
|
+
width: null, // null || <value>
|
|
44
|
+
height: 300, // <value> || null
|
|
45
|
+
|
|
46
|
+
/** attributi facoltativi per l'svg (classe, ecc) */
|
|
47
|
+
svgAttrs: {},
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
valori numerici per la definizione delle barre
|
|
51
|
+
è un array di array, in cui ogni subarray contiene i valori di una singola serie di barre
|
|
52
|
+
*/
|
|
53
|
+
values: [],
|
|
54
|
+
|
|
55
|
+
/** spazio tra una barra e l'altra, all'interno dello stesso gruppo di categoria */
|
|
56
|
+
barsGap: 5,
|
|
57
|
+
|
|
58
|
+
/** spazio tra un gruppo di categoria e l'altro */
|
|
59
|
+
seriesGap: 10,
|
|
60
|
+
|
|
61
|
+
/** bars corner radius (lato esterno, lontano dallo zero) */
|
|
62
|
+
barsCornerRadius: 6,
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
etichette di categoria, una per ogni gruppo di barre. Sono posizionate sull'asse verticale (a sinistra)
|
|
66
|
+
*/
|
|
67
|
+
xLabels: [],
|
|
68
|
+
|
|
69
|
+
/** spazio extra attorno al bordo */
|
|
70
|
+
padding: 10,
|
|
71
|
+
|
|
72
|
+
/** suddivisioni asse dei valori (in basso) */
|
|
73
|
+
stepY_count: 4,
|
|
74
|
+
|
|
75
|
+
/** px, altezza o larghezza ticks (secondo l'orientamento) */
|
|
76
|
+
ticksLength: 8,
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
opzioni per legenda come da componente legenda sono impostati automaticamente)
|
|
80
|
+
NB: l'elemento `items` viene generato dinamicamente:
|
|
81
|
+
`shape` è sempre impostato su line
|
|
82
|
+
`attrs` è ricavato dagli attributi delle linee dati
|
|
83
|
+
|
|
84
|
+
Per utilizzare le impostazioni di default, è sufficiente impostare i valori `items.text`
|
|
85
|
+
*/
|
|
86
|
+
legenda: {},
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
larghezza area etichette di categoria (asse verticale, a sinistra)
|
|
90
|
+
*/
|
|
91
|
+
yAxisLabelWidth: 60,
|
|
92
|
+
|
|
93
|
+
/** altezza area etichette dell'asse dei valori (in basso) */
|
|
94
|
+
xAxisLabelHeight: 50,
|
|
95
|
+
|
|
96
|
+
/** titoli assi: yAxisTitle → asse categorie (a sinistra), xAxisTitle → asse valori (in basso) */
|
|
97
|
+
yAxisTitle: null,
|
|
98
|
+
yAxisTitleAttrs: {...default_font_attrs, size: 18},
|
|
99
|
+
yAxisTitleFontFilePath: null,
|
|
100
|
+
|
|
101
|
+
xAxisTitle: null,
|
|
102
|
+
xAxisTitleAttrs: {...default_font_attrs, size: 18},
|
|
103
|
+
xAxisTitleFontFilePath: null,
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
valori minimi e massimi per l'asse dei valori, se null vengono calcolati automaticamente.
|
|
107
|
+
Il calcolo automatico di min_value usa 0 come soglia massima, per far sì che le
|
|
108
|
+
barre partano da 0 quando i dati sono tutti positivi, estendendo il minimo verso
|
|
109
|
+
sinistra solo se sono presenti valori negativi
|
|
110
|
+
*/
|
|
111
|
+
max_value: null,
|
|
112
|
+
min_value: null,
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
classi e attributi dei vari elementi.
|
|
116
|
+
Per gli attributi, ogni variabile è un oggetto di attributi svg.js, es =
|
|
117
|
+
|
|
118
|
+
{
|
|
119
|
+
stroke: '#414141',
|
|
120
|
+
'stroke-width': 1,
|
|
121
|
+
'shape-rendering': 'crispEdges',
|
|
122
|
+
class: 'xxxx'
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
*/
|
|
126
|
+
axisAttrs: {...default_lines_attrs, 'stroke-width': 2},
|
|
127
|
+
ticksAttrs: default_lines_attrs,
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
griglia: showXgrid → griglia orizzontale (asse categorie), showYgrid → griglia verticale (asse valori)
|
|
131
|
+
*/
|
|
132
|
+
showXgrid: false,
|
|
133
|
+
showYgrid: true,
|
|
134
|
+
gridLinesAttrs: default_lines_attrs,
|
|
135
|
+
|
|
136
|
+
labelXAttrs: default_font_attrs, // etichette di categoria (a sinistra)
|
|
137
|
+
labelXFontFilePath: null, // percorso font per textToPath
|
|
138
|
+
|
|
139
|
+
labelYAttrs: default_font_attrs, // etichette dell'asse dei valori (in basso)
|
|
140
|
+
labelYFontFilePath: null, // percorso font per textToPath
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
/** colori delle barre */
|
|
145
|
+
data_colors: null,
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
Funzione per formattare le etichette dell'asse dei valori (in basso).
|
|
150
|
+
Null per nessuna eleborazione.
|
|
151
|
+
La funzione di default assume che il valore sia di tipo numerico
|
|
152
|
+
*/
|
|
153
|
+
labelYFormatter: etiValue => Math.ceil(etiValue / 1e3)
|
|
154
|
+
.toLocaleString('it-IT', {
|
|
155
|
+
minimumFractionDigits: 0,
|
|
156
|
+
maximumFractionDigits: 0
|
|
157
|
+
}) +
|
|
158
|
+
(etiValue > 0? '\u2009K' : ''), // \u2009: thin space (https://www.compart.com/en/unicode/U+2009)
|
|
159
|
+
|
|
160
|
+
};
|
|
161
|
+
export async function hbars(params) {
|
|
162
|
+
|
|
163
|
+
const chartInstance = this;
|
|
164
|
+
|
|
165
|
+
params = {...default_params, ...params};
|
|
166
|
+
params.data_colors??= chartInstance.defaults.colors;
|
|
167
|
+
|
|
168
|
+
try {
|
|
169
|
+
|
|
170
|
+
// controllo values
|
|
171
|
+
params.values.forEach(subarray => {
|
|
172
|
+
if(!Array.isArray(subarray)) {
|
|
173
|
+
throw 'L’elemento `values` deve essere un array di soli array';
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const flatValues = params.values.flat(); // utilizzato anche in seguito
|
|
178
|
+
if(flatValues.filter(v => isNaN(v)).length) {
|
|
179
|
+
throw 'Tutti i valori dei sub-array di `values` devono essere numerici';
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// calcolo automatico di min_value: 0 se i dati sono tutti positivi,
|
|
183
|
+
// altrimenti il valore minimo effettivo (per includere le barre negative nell'area del grafico)
|
|
184
|
+
if(params.min_value == null) {
|
|
185
|
+
params.min_value = Math.min(0, ...flatValues.filter(v => v != null));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// container
|
|
189
|
+
[, params.width, params.height] = parseContainer({ container: params.container, width: params.width, height: params.height });
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
if(!params.width || !params.height) {
|
|
193
|
+
throw `width e/o height mancanti: width: ${params.width}, height: ${params.height}`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// =>> legenda
|
|
197
|
+
// costruzione delle opzioni per la legenda
|
|
198
|
+
const legenda_params = {
|
|
199
|
+
|
|
200
|
+
...{
|
|
201
|
+
/** altezza legenda */
|
|
202
|
+
height: 20,
|
|
203
|
+
|
|
204
|
+
/** spazio tra gli elementi della legenda */
|
|
205
|
+
elements_gap: 25,
|
|
206
|
+
|
|
207
|
+
/** dimensioni delle forme della legenda (NB: solo tondi) */
|
|
208
|
+
circles_radius: 20,
|
|
209
|
+
|
|
210
|
+
/** attributi svg.js dei font del testo della legenda (null utilizza default) */
|
|
211
|
+
fontAttrs: {
|
|
212
|
+
fill: '#333',
|
|
213
|
+
'font-size': 14,
|
|
214
|
+
'font-weight': 400
|
|
215
|
+
},
|
|
216
|
+
fontFilePath: null,
|
|
217
|
+
},
|
|
218
|
+
|
|
219
|
+
...params.legenda
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
// legenda.items (elementi della legenda)
|
|
224
|
+
legenda_params.items = params.legenda.items.map((item, idx) => {
|
|
225
|
+
return {
|
|
226
|
+
...item,
|
|
227
|
+
shape: 'circle',
|
|
228
|
+
attrs: {
|
|
229
|
+
...(item.attrs?? {}),
|
|
230
|
+
fill: params.data_colors[idx]?? '#666',
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
// numero di step dell'asse delle categorie
|
|
237
|
+
const stepX_count = params.values.reduce((prev, curr) => Math.max(prev, curr.length), 0) + 1;
|
|
238
|
+
|
|
239
|
+
const svgCanvas = chartInstance.createSvgCanvas(params.container, params.debug)
|
|
240
|
+
.viewbox(0, 0, params.width, params.height)
|
|
241
|
+
.attr({...params.svgAttrs});
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
// pre-elaborazione e calcolo di alcuni elementi (chartArea)
|
|
245
|
+
// nuove variabili definite da parseStdAxisOpts (orientation:'horizontal':
|
|
246
|
+
// stepY_* si riferiscono all'asse dei valori (X), stepX_* all'asse delle categorie (Y))
|
|
247
|
+
const {
|
|
248
|
+
max_value,
|
|
249
|
+
min_value,
|
|
250
|
+
chart_area,
|
|
251
|
+
stepY_count,
|
|
252
|
+
stepY_value,
|
|
253
|
+
stepY_size,
|
|
254
|
+
stepX_size,
|
|
255
|
+
zero_y: zero_x
|
|
256
|
+
} = parseStdAxisOpts({
|
|
257
|
+
orientation: 'horizontal',
|
|
258
|
+
max_value: params.max_value,
|
|
259
|
+
min_value: params.min_value,
|
|
260
|
+
values: flatValues,
|
|
261
|
+
legenda: legenda_params,
|
|
262
|
+
padding: params.padding,
|
|
263
|
+
width: params.width,
|
|
264
|
+
height: params.height,
|
|
265
|
+
eti_asseX_height: params.xAxisLabelHeight,
|
|
266
|
+
eti_asseY_width: params.yAxisLabelWidth,
|
|
267
|
+
stepY_count: params.stepY_count,
|
|
268
|
+
stepX_count: stepX_count,
|
|
269
|
+
ticksLength: params.ticksLength,
|
|
270
|
+
}); // elaborazione standard
|
|
271
|
+
|
|
272
|
+
// =>> costruzione assi e griglia.
|
|
273
|
+
// Gli argomenti di cartesianAxis sono uguali a quelli di hbars
|
|
274
|
+
// con l'aggiunta di quelli calcolati da parseStdAxisOpts
|
|
275
|
+
// non tutti sono necessari ma vengono passati lo stesso per comodità
|
|
276
|
+
const cartesianAxisGroup = await cartesianAxis(chartInstance, {
|
|
277
|
+
...params,
|
|
278
|
+
orientation: 'horizontal',
|
|
279
|
+
xLabelsOnTicks: false, // posiziona l'etichetta di categoria al centro di ogni step
|
|
280
|
+
...{
|
|
281
|
+
max_value,
|
|
282
|
+
min_value,
|
|
283
|
+
chart_area,
|
|
284
|
+
stepY_count,
|
|
285
|
+
stepY_value,
|
|
286
|
+
stepY_size,
|
|
287
|
+
stepX_size,
|
|
288
|
+
stepX_count,
|
|
289
|
+
zero_y: zero_x
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
svgCanvas.add(cartesianAxisGroup);
|
|
294
|
+
|
|
295
|
+
// *********************************
|
|
296
|
+
// =>> disegno barre
|
|
297
|
+
// *********************************
|
|
298
|
+
|
|
299
|
+
// calcolo altezza (spessore) barre
|
|
300
|
+
const barsHeight = (stepX_size - params.seriesGap - (params.barsGap * (params.values.length - 1))) / params.values.length
|
|
301
|
+
// il raggio dell'angolo non può essere più grande dello spessore della barra stessa, né negativo
|
|
302
|
+
// (barsHeight può risultare negativo se seriesGap/barsGap non lasciano spazio sufficiente per tutte le serie,
|
|
303
|
+
// frequente qui perché l'asse delle categorie è vincolato all'altezza del grafico, spesso più stretta della larghezza)
|
|
304
|
+
,barsCornerRadius = Math.max(0, Math.min(params.barsCornerRadius, barsHeight / 2))
|
|
305
|
+
;
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
const barsGroup = svgCanvas.group()
|
|
309
|
+
.attr({ 'data-debug-info': params.debug? 'Gruppo barre' : null });
|
|
310
|
+
|
|
311
|
+
let barY = chart_area.top + params.seriesGap / 2;
|
|
312
|
+
|
|
313
|
+
for (let thisStepIdx = 0; thisStepIdx < stepX_count; thisStepIdx++) {
|
|
314
|
+
|
|
315
|
+
const thisStepValues = params.values.reduce((result, curr) => {
|
|
316
|
+
result.push(curr[thisStepIdx]?? null);
|
|
317
|
+
return result;
|
|
318
|
+
}, []);
|
|
319
|
+
|
|
320
|
+
// stampa di ogni barra della serie
|
|
321
|
+
for (let idx = 0; idx < thisStepValues.length; idx++) {
|
|
322
|
+
const itemValue = thisStepValues[idx];
|
|
323
|
+
const isLast = idx === thisStepValues.length - 1;
|
|
324
|
+
|
|
325
|
+
if (itemValue != null) {
|
|
326
|
+
// valori negativi: la barra si estende dalla linea dello zero verso sinistra,
|
|
327
|
+
// invece di crescere da sinistra verso destra
|
|
328
|
+
const isNegative = itemValue < 0,
|
|
329
|
+
// calcolo coordinata X del valore, usando come baseline la linea dello zero (zero_x)
|
|
330
|
+
// invece del margine sinistro del grafico. Nessuna inversione: l'asse X cresce verso destra
|
|
331
|
+
itemX = chart_area.left + (((itemValue - min_value) * chart_area.width) / (max_value - min_value)),
|
|
332
|
+
barLeft = isNegative? itemX : zero_x,
|
|
333
|
+
barRight = isNegative? zero_x : itemX,
|
|
334
|
+
barW = barRight - barLeft,
|
|
335
|
+
parteOrizzontaleBarra = barW - barsCornerRadius;
|
|
336
|
+
let path;
|
|
337
|
+
|
|
338
|
+
if(parteOrizzontaleBarra > 0) {
|
|
339
|
+
|
|
340
|
+
if(barW <= barsCornerRadius) {
|
|
341
|
+
|
|
342
|
+
const larghezzaCornerRadius = barW;
|
|
343
|
+
|
|
344
|
+
path = isNegative
|
|
345
|
+
// angoli arrotondati a sinistra (lato lontano dallo zero), spigolo vivo a destra
|
|
346
|
+
? `M${zero_x},${barY}` +
|
|
347
|
+
`q-${larghezzaCornerRadius},0 -${larghezzaCornerRadius},${barsCornerRadius}` +
|
|
348
|
+
`v${barsHeight - barsCornerRadius * 2}` +
|
|
349
|
+
`q0,${barsCornerRadius} ${larghezzaCornerRadius},${barsCornerRadius}` +
|
|
350
|
+
'z'
|
|
351
|
+
// angoli arrotondati a destra (lato lontano dallo zero), spigolo vivo a sinistra
|
|
352
|
+
: `M${zero_x},${barY}` +
|
|
353
|
+
`q${larghezzaCornerRadius},0 ${larghezzaCornerRadius},${barsCornerRadius}` +
|
|
354
|
+
`v${barsHeight - barsCornerRadius * 2}` +
|
|
355
|
+
`q0,${barsCornerRadius} -${larghezzaCornerRadius},${barsCornerRadius}` +
|
|
356
|
+
'z';
|
|
357
|
+
|
|
358
|
+
} else {
|
|
359
|
+
path = isNegative
|
|
360
|
+
? `M${zero_x},${barY}` +
|
|
361
|
+
`h-${parteOrizzontaleBarra}` +
|
|
362
|
+
`q-${barsCornerRadius},0 -${barsCornerRadius},${barsCornerRadius}` +
|
|
363
|
+
`v${barsHeight - barsCornerRadius * 2}` +
|
|
364
|
+
`q0,${barsCornerRadius} ${barsCornerRadius},${barsCornerRadius}` +
|
|
365
|
+
`h${parteOrizzontaleBarra}` +
|
|
366
|
+
'z'
|
|
367
|
+
: `M${zero_x},${barY}` +
|
|
368
|
+
`h${parteOrizzontaleBarra}` +
|
|
369
|
+
`q${barsCornerRadius},0 ${barsCornerRadius},${barsCornerRadius}` +
|
|
370
|
+
`v${barsHeight - barsCornerRadius * 2}` +
|
|
371
|
+
`q0,${barsCornerRadius} -${barsCornerRadius},${barsCornerRadius}` +
|
|
372
|
+
`h-${parteOrizzontaleBarra}` +
|
|
373
|
+
'z';
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
barsGroup.path(path)
|
|
377
|
+
.attr({
|
|
378
|
+
fill: params.data_colors[idx]?? '#666',
|
|
379
|
+
'data-debug-info': params.debug? `value: ${itemValue}` : null
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
barY += barsHeight + (isLast ? 0 : params.barsGap);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
barY += params.seriesGap;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
// *********************************
|
|
393
|
+
// =>> Legenda
|
|
394
|
+
// *********************************
|
|
395
|
+
const legendaEl = await legenda(chartInstance, legenda_params);
|
|
396
|
+
legendaEl.attr({ 'data-cfg.debug-info': params.debug? 'Legenda' : null });
|
|
397
|
+
const legendaBbox = legendaEl.bbox();
|
|
398
|
+
|
|
399
|
+
legendaEl.dmove(
|
|
400
|
+
(params.width - legendaBbox.width) / 2,
|
|
401
|
+
params.padding
|
|
402
|
+
);
|
|
403
|
+
legendaEl.y(params.padding);
|
|
404
|
+
|
|
405
|
+
legendaEl.x((params.width - legendaBbox.width) / 2);
|
|
406
|
+
|
|
407
|
+
svgCanvas.add(legendaEl);
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
if(!params.container) {
|
|
411
|
+
return svgCanvas.svg();
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
} catch(e) {
|
|
416
|
+
console.error( 'MinimoCharts / hbars →', e ); // eslint-disable-line
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
}
|
|
@@ -4,7 +4,18 @@
|
|
|
4
4
|
import { G } from '@svgdotjs/svg.js';
|
|
5
5
|
import { createTextEl } from './create-text-element.js';
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
/**
|
|
8
|
+
dispatcher: orientation 'vertical' (barre/linee verticali, default) o 'horizontal' (barre orizzontali).
|
|
9
|
+
Gli argomenti sono gli stessi per entrambi gli orientamenti (vedi cartesianAxisVertical),
|
|
10
|
+
interpretati diversamente da ciascun ramo (vedi commenti in cartesianAxisHorizontal)
|
|
11
|
+
*/
|
|
12
|
+
export async function cartesianAxis(chartInstance, params) {
|
|
13
|
+
return params.orientation === 'horizontal'
|
|
14
|
+
? cartesianAxisHorizontal(chartInstance, params)
|
|
15
|
+
: cartesianAxisVertical(chartInstance, params);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function cartesianAxisVertical(chartInstance, {
|
|
8
19
|
debug = false,
|
|
9
20
|
chart_area = {},
|
|
10
21
|
padding = 0,
|
|
@@ -20,6 +31,7 @@ export async function cartesianAxis(chartInstance, {
|
|
|
20
31
|
stepY_size,
|
|
21
32
|
stepY_value,
|
|
22
33
|
min_value,
|
|
34
|
+
zero_y,
|
|
23
35
|
stepX_size,
|
|
24
36
|
stepX_count,
|
|
25
37
|
ticksLength,
|
|
@@ -106,7 +118,8 @@ export async function cartesianAxis(chartInstance, {
|
|
|
106
118
|
);
|
|
107
119
|
|
|
108
120
|
// =>> griglia orizzontale (ortogonale all'asse Y)
|
|
109
|
-
|
|
121
|
+
// viene omessa solo la linea che coincide con l'asse X (la linea dello zero)
|
|
122
|
+
if(showYgrid && Math.abs(y - zero_y) > 0.01) {
|
|
110
123
|
yGridGroup.line(
|
|
111
124
|
chart_area.left, y,
|
|
112
125
|
chart_area.right, y
|
|
@@ -147,8 +160,8 @@ export async function cartesianAxis(chartInstance, {
|
|
|
147
160
|
xAxisGroup
|
|
148
161
|
.attr({ 'data-debug-info': debug? 'Gruppo Asse X' : null })
|
|
149
162
|
.line(
|
|
150
|
-
chart_area.left,
|
|
151
|
-
chart_area.right,
|
|
163
|
+
chart_area.left, zero_y,
|
|
164
|
+
chart_area.right, zero_y
|
|
152
165
|
)
|
|
153
166
|
.attr({
|
|
154
167
|
'data-debug-info': debug? 'Asse X' : null,
|
|
@@ -252,3 +265,248 @@ export async function cartesianAxis(chartInstance, {
|
|
|
252
265
|
console.error( 'MinimoCharts → cartesianAxis', e ); // eslint-disable-line
|
|
253
266
|
}
|
|
254
267
|
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
Variante orizzontale (barre orizzontali): rispetto a cartesianAxisVertical i ruoli sono invertiti:
|
|
271
|
+
- l'asse dei VALORI (numerico, formattato con labelYFormatter) è fisso in basso (come l'asse X
|
|
272
|
+
di cartesianAxisVertical), con griglia verticale. La linea dello zero (baseline delle barre)
|
|
273
|
+
è una linea verticale che si sposta in base al segno dei dati (zero_y qui è una coordinata X)
|
|
274
|
+
- l'asse delle CATEGORIE (testuale, da xLabels) è fisso a sinistra (come l'asse Y di
|
|
275
|
+
cartesianAxisVertical), con griglia orizzontale
|
|
276
|
+
stepY_count/stepY_size/stepY_value/zero_y e stepX_size/stepX_count sono gli stessi campi
|
|
277
|
+
restituiti da parseStdAxisOpts con orientation:'horizontal' (vedi commenti lì)
|
|
278
|
+
*/
|
|
279
|
+
async function cartesianAxisHorizontal(chartInstance, {
|
|
280
|
+
debug = false,
|
|
281
|
+
chart_area = {},
|
|
282
|
+
padding = 0,
|
|
283
|
+
width,
|
|
284
|
+
height,
|
|
285
|
+
axisAttrs = {},
|
|
286
|
+
ticksAttrs = {},
|
|
287
|
+
labelYAttrs = {},
|
|
288
|
+
labelYFontFilePath = null,
|
|
289
|
+
labelXAttrs = {},
|
|
290
|
+
labelXFontFilePath = null,
|
|
291
|
+
stepY_count,
|
|
292
|
+
stepY_size,
|
|
293
|
+
stepY_value,
|
|
294
|
+
min_value,
|
|
295
|
+
zero_y: zero_x, // rinominato per chiarezza: qui è una coordinata X
|
|
296
|
+
stepX_size,
|
|
297
|
+
stepX_count,
|
|
298
|
+
ticksLength,
|
|
299
|
+
showXgrid = true, // griglia orizzontale (asse categorie)
|
|
300
|
+
showYgrid = true, // griglia verticale (asse valori)
|
|
301
|
+
gridLinesAttrs = {},
|
|
302
|
+
|
|
303
|
+
yAxisTitle = null, // titolo asse categorie (a sinistra)
|
|
304
|
+
yAxisTitleAttrs = {},
|
|
305
|
+
yAxisTitleFontFilePath = null,
|
|
306
|
+
|
|
307
|
+
xAxisTitle = null, // titolo asse valori (in basso)
|
|
308
|
+
xAxisTitleAttrs = {},
|
|
309
|
+
xAxisTitleFontFilePath = null,
|
|
310
|
+
|
|
311
|
+
xLabels, // array con le etichette di categoria, mostrate sull'asse verticale (a sinistra)
|
|
312
|
+
xLabelsOnTicks = true,
|
|
313
|
+
labelYFormatter = null, // funzione, formatta le etichette numeriche dell'asse valori (in basso)
|
|
314
|
+
}) {
|
|
315
|
+
|
|
316
|
+
try {
|
|
317
|
+
|
|
318
|
+
const cartesianAxisGroup = new G();
|
|
319
|
+
cartesianAxisGroup.attr({ 'data-debug-info': debug? 'Gruppo Base Assi Cartesiani (orizzontale) ed elementi correlati' : null });
|
|
320
|
+
|
|
321
|
+
// =>> ASSE VALORI (in basso, fisso — analogo all'asse X di cartesianAxisVertical)
|
|
322
|
+
// *****************************************
|
|
323
|
+
const valueAxisGroup = cartesianAxisGroup.group();
|
|
324
|
+
valueAxisGroup
|
|
325
|
+
.attr({ 'data-debug-info': debug? 'Gruppo Asse Valori' : null })
|
|
326
|
+
.line(
|
|
327
|
+
chart_area.left, chart_area.bottom,
|
|
328
|
+
chart_area.right, chart_area.bottom
|
|
329
|
+
)
|
|
330
|
+
.attr({
|
|
331
|
+
'data-debug-info': debug? 'Asse Valori' : null,
|
|
332
|
+
...axisAttrs
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
// =>> ticks ed etichette asse valori (da sinistra a destra)
|
|
336
|
+
const ticksValueGroup = valueAxisGroup.group().attr({
|
|
337
|
+
'data-debug-info': debug? 'Gruppo ticks Asse Valori' : null,
|
|
338
|
+
});
|
|
339
|
+
const labelsValueGroup = valueAxisGroup.group().attr({
|
|
340
|
+
'data-debug-info': debug? 'Gruppo etichette Asse Valori' : null,
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
// gruppo per griglia asse valori (verticale, ortogonale all'asse)
|
|
344
|
+
let valueGridGroup = null;
|
|
345
|
+
if(showYgrid) {
|
|
346
|
+
valueGridGroup = cartesianAxisGroup.group()
|
|
347
|
+
.attr({ 'data-debug-info': debug? 'Gruppo Griglia Asse Valori' : null });
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
for await (const stepIdx of Array.from(Array(stepY_count + 1).keys())) {
|
|
351
|
+
|
|
352
|
+
const x = chart_area.left + (stepY_size * stepIdx)
|
|
353
|
+
,label = labelYFormatter(min_value + stepY_value * stepIdx);
|
|
354
|
+
|
|
355
|
+
// =>> ticks asse valori
|
|
356
|
+
ticksValueGroup
|
|
357
|
+
.line(
|
|
358
|
+
x, chart_area.bottom,
|
|
359
|
+
x, chart_area.bottom + ticksLength
|
|
360
|
+
)
|
|
361
|
+
.attr(ticksAttrs);
|
|
362
|
+
|
|
363
|
+
// =>> etichette asse valori
|
|
364
|
+
const labelEl = await createTextEl(chartInstance, {
|
|
365
|
+
text: label,
|
|
366
|
+
textFill: labelYAttrs.fill?? '#000',
|
|
367
|
+
fontAttr: labelYAttrs,
|
|
368
|
+
fontFilePath: labelYFontFilePath,
|
|
369
|
+
parentElement: labelsValueGroup
|
|
370
|
+
}),
|
|
371
|
+
label_bbox = labelEl.bbox();
|
|
372
|
+
|
|
373
|
+
labelEl.move(
|
|
374
|
+
x - label_bbox.width / 2,
|
|
375
|
+
chart_area.bottom + ticksLength + 2 // 2 è un distanziatore tra l'etichetta e la linea
|
|
376
|
+
);
|
|
377
|
+
|
|
378
|
+
// =>> griglia verticale (ortogonale all'asse valori)
|
|
379
|
+
// viene omessa solo la linea che coincide con l'asse categorie (la linea dello zero)
|
|
380
|
+
if(showYgrid && Math.abs(x - zero_x) > 0.01) {
|
|
381
|
+
valueGridGroup.line(
|
|
382
|
+
x, chart_area.top,
|
|
383
|
+
x, chart_area.bottom
|
|
384
|
+
).attr(gridLinesAttrs);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
} // end for await stepIdx
|
|
388
|
+
|
|
389
|
+
// =>> titolo asse valori (in basso)
|
|
390
|
+
if(xAxisTitle) {
|
|
391
|
+
const titleEl = await createTextEl (chartInstance, {
|
|
392
|
+
text: xAxisTitle,
|
|
393
|
+
textFill: xAxisTitleAttrs.fill?? '#000',
|
|
394
|
+
fontAttr: xAxisTitleAttrs,
|
|
395
|
+
fontFilePath: xAxisTitleFontFilePath,
|
|
396
|
+
parentElement: valueAxisGroup
|
|
397
|
+
}),
|
|
398
|
+
title_bbox = titleEl.bbox();
|
|
399
|
+
|
|
400
|
+
titleEl.move(
|
|
401
|
+
chart_area.left + (chart_area.width - title_bbox.width) / 2,
|
|
402
|
+
height - padding - title_bbox.height
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
// =>> ASSE CATEGORIE (a sinistra — analogo all'asse Y di cartesianAxisVertical;
|
|
409
|
+
// la linea dell'asse è quella dello zero, si sposta in base al segno dei dati)
|
|
410
|
+
// *****************************************
|
|
411
|
+
const categoryAxisGroup = cartesianAxisGroup.group();
|
|
412
|
+
categoryAxisGroup
|
|
413
|
+
.attr({ 'data-debug-info': debug? 'Gruppo Asse Categorie' : null })
|
|
414
|
+
.line(
|
|
415
|
+
zero_x, chart_area.top,
|
|
416
|
+
zero_x, chart_area.bottom
|
|
417
|
+
)
|
|
418
|
+
.attr({
|
|
419
|
+
'data-debug-info': debug? 'Asse Categorie' : null,
|
|
420
|
+
...axisAttrs
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
// =>> ticks ed etichette asse categorie (dall'alto verso il basso)
|
|
424
|
+
const ticksCategoryGroup = categoryAxisGroup.group().attr({
|
|
425
|
+
'data-debug-info': debug? 'Gruppo ticks Asse Categorie' : null,
|
|
426
|
+
});
|
|
427
|
+
const labelsCategoryGroup = categoryAxisGroup.group().attr({
|
|
428
|
+
'data-debug-info': debug? 'Gruppo etichette Asse Categorie' : null,
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
// coordinata x per il posizionamento delle etichette (a sinistra dei ticks, fisse su chart_area.left)
|
|
432
|
+
const labelX = chart_area.left - ticksLength - 2; // 2 è un distanziatore tra l'etichetta e la linea
|
|
433
|
+
|
|
434
|
+
// gruppo per griglia asse categorie (orizzontale, ortogonale all'asse)
|
|
435
|
+
// il gruppo viene sempre creato, perché anche se non si stampa la griglia viene
|
|
436
|
+
// cmq aggiunta l'ultima linea in basso
|
|
437
|
+
const categoryGridGroup = cartesianAxisGroup.group()
|
|
438
|
+
.attr({ 'data-debug-info': debug? 'Gruppo Griglia Asse Categorie' : null });
|
|
439
|
+
|
|
440
|
+
for (let i = 0; i <= stepX_count - 1; i++) {
|
|
441
|
+
|
|
442
|
+
const thisLabel = (Array.isArray(xLabels) ? xLabels[i] : undefined) ?? '',
|
|
443
|
+
labelIdx = i,
|
|
444
|
+
isLastStep = labelIdx === stepX_count - 1,
|
|
445
|
+
y = chart_area.top + (stepX_size * labelIdx);
|
|
446
|
+
|
|
447
|
+
// =>> ticks categorie
|
|
448
|
+
ticksCategoryGroup
|
|
449
|
+
.line(
|
|
450
|
+
chart_area.left - ticksLength, y,
|
|
451
|
+
chart_area.left, y
|
|
452
|
+
)
|
|
453
|
+
.attr(ticksAttrs);
|
|
454
|
+
|
|
455
|
+
// =>> etichette categorie
|
|
456
|
+
const labelEl = await createTextEl (chartInstance, {
|
|
457
|
+
text: thisLabel,
|
|
458
|
+
textFill: labelXAttrs.fill?? '#000',
|
|
459
|
+
fontAttr: labelXAttrs,
|
|
460
|
+
fontFilePath: labelXFontFilePath,
|
|
461
|
+
parentElement: labelsCategoryGroup
|
|
462
|
+
}),
|
|
463
|
+
label_bbox = labelEl.bbox();
|
|
464
|
+
|
|
465
|
+
// posizionamento (allineate a destra sulla linea dei ticks)
|
|
466
|
+
labelEl.move(
|
|
467
|
+
labelX - label_bbox.width,
|
|
468
|
+
y - label_bbox.height / 2 + (xLabelsOnTicks? 0 : stepX_size / 2)
|
|
469
|
+
);
|
|
470
|
+
|
|
471
|
+
// =>> griglia orizzontale
|
|
472
|
+
// la prima linea coincide con l'asse valori (in basso, essendo la prima categoria in alto)
|
|
473
|
+
// l'ultima linea in basso viene stampata sempre
|
|
474
|
+
if((showXgrid && labelIdx > 0) || isLastStep ) {
|
|
475
|
+
categoryGridGroup.line(
|
|
476
|
+
chart_area.left, y,
|
|
477
|
+
chart_area.right, y
|
|
478
|
+
).attr(gridLinesAttrs);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
} // end for thisLabel
|
|
482
|
+
|
|
483
|
+
// =>> titolo asse categorie (a sinistra, ruotato)
|
|
484
|
+
if(yAxisTitle) {
|
|
485
|
+
const titleGroup = categoryAxisGroup.group(); // per la rotazione
|
|
486
|
+
titleGroup.attr({
|
|
487
|
+
'data-debug-info': debug? 'Titolo asse Categorie' : null,
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
const titleEl = await createTextEl(chartInstance, {
|
|
491
|
+
text: yAxisTitle,
|
|
492
|
+
textFill: yAxisTitleAttrs.fill?? '#000',
|
|
493
|
+
fontAttr: yAxisTitleAttrs,
|
|
494
|
+
fontFilePath: yAxisTitleFontFilePath,
|
|
495
|
+
parentElement: titleGroup
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
titleEl.rotate(-90);
|
|
499
|
+
const title_bbox = titleGroup.bbox();
|
|
500
|
+
|
|
501
|
+
titleGroup.move(
|
|
502
|
+
padding,
|
|
503
|
+
(chart_area.bottom - title_bbox.height) / 2
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
return cartesianAxisGroup;
|
|
508
|
+
|
|
509
|
+
} catch(e) {
|
|
510
|
+
console.error( 'MinimoCharts → cartesianAxis (horizontal)', e ); // eslint-disable-line
|
|
511
|
+
}
|
|
512
|
+
}
|
|
@@ -33,6 +33,14 @@ export function parseStdAxisOpts(params = {} ) {
|
|
|
33
33
|
stepX_count: null, // non può essere null, va verificato prima
|
|
34
34
|
ticksLength: 0,
|
|
35
35
|
|
|
36
|
+
/**
|
|
37
|
+
'vertical' (barre/linee verticali, default): l'asse dei valori è l'asse Y, l'asse delle categorie è l'asse X.
|
|
38
|
+
'horizontal' (barre orizzontali): l'asse dei valori è l'asse X, l'asse delle categorie è l'asse Y.
|
|
39
|
+
Influisce solo sul calcolo geometrico di stepY_size/zero_y/stepX_size (vedi sotto),
|
|
40
|
+
non sul calcolo "nice zero" di min_value/max_value/stepY_count, che è indipendente dall'orientamento
|
|
41
|
+
*/
|
|
42
|
+
orientation: 'vertical',
|
|
43
|
+
|
|
36
44
|
...params
|
|
37
45
|
};
|
|
38
46
|
|
|
@@ -62,6 +70,23 @@ export function parseStdAxisOpts(params = {} ) {
|
|
|
62
70
|
parsed.min_value = params.min_value;
|
|
63
71
|
}
|
|
64
72
|
|
|
73
|
+
// se i valori attraversano lo zero (min negativo, max positivo), min_value/max_value/stepY_count
|
|
74
|
+
// vengono ricalcolati in modo che tutti gli step della griglia abbiano la stessa dimensione
|
|
75
|
+
// e che lo zero coincida esattamente con una delle suddivisioni (invece di cadere a metà tra due step)
|
|
76
|
+
let stepY_count = params.stepY_count;
|
|
77
|
+
|
|
78
|
+
if(parsed.min_value < 0 && parsed.max_value > 0) {
|
|
79
|
+
const rawStep = (parsed.max_value - parsed.min_value) / stepY_count,
|
|
80
|
+
positive_steps = Math.max(1, Math.round(parsed.max_value / rawStep)),
|
|
81
|
+
step = parsed.max_value / positive_steps,
|
|
82
|
+
negative_steps = Math.max(1, Math.ceil(Math.abs(parsed.min_value) / step));
|
|
83
|
+
|
|
84
|
+
parsed.max_value = positive_steps * step;
|
|
85
|
+
parsed.min_value = -negative_steps * step;
|
|
86
|
+
stepY_count = positive_steps + negative_steps;
|
|
87
|
+
}
|
|
88
|
+
parsed.stepY_count = stepY_count;
|
|
89
|
+
|
|
65
90
|
const legenda_height = params.legenda?.height?? 0;
|
|
66
91
|
const gap_legenda = legenda_height? 10 : 0; // spazio tra legenda e grafico
|
|
67
92
|
|
|
@@ -78,16 +103,32 @@ export function parseStdAxisOpts(params = {} ) {
|
|
|
78
103
|
parsed.chart_area.width = parsed.chart_area.right - parsed.chart_area.left;
|
|
79
104
|
parsed.chart_area.height = parsed.chart_area.bottom - parsed.chart_area.top;
|
|
80
105
|
|
|
81
|
-
// valore di ogni step asse
|
|
82
|
-
// (usato per il calcolo delle etichette
|
|
83
|
-
parsed.stepY_value = (parsed.max_value - parsed.min_value) /
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
106
|
+
// valore di ogni step dell'asse dei valori
|
|
107
|
+
// (usato per il calcolo delle etichette)
|
|
108
|
+
parsed.stepY_value = (parsed.max_value - parsed.min_value) / stepY_count;
|
|
109
|
+
|
|
110
|
+
const isHorizontal = params.orientation === 'horizontal';
|
|
111
|
+
|
|
112
|
+
// lunghezza di ogni step dell'asse dei valori e dell'asse delle categorie:
|
|
113
|
+
// con orientamento verticale l'asse dei valori è l'asse Y (chart_area.height) e quello delle
|
|
114
|
+
// categorie è l'asse X (chart_area.width); con orientamento orizzontale i ruoli sono invertiti
|
|
115
|
+
parsed.stepY_size = (isHorizontal? parsed.chart_area.width : parsed.chart_area.height) / stepY_count;
|
|
116
|
+
parsed.stepX_size = (isHorizontal? parsed.chart_area.height : parsed.chart_area.width) / (params.stepX_count - 1);
|
|
117
|
+
|
|
118
|
+
// coordinata corrispondente al valore 0 (asse Y con orientamento verticale, asse X con orientamento orizzontale)
|
|
119
|
+
// utile quando min_value è negativo: la baseline delle barre viene disegnata in corrispondenza
|
|
120
|
+
// dello zero invece che del valore minimo
|
|
121
|
+
let raw_zero_y;
|
|
122
|
+
if(isHorizontal) {
|
|
123
|
+
// l'asse X cresce naturalmente verso destra (nessuna inversione necessaria, a differenza dell'asse Y)
|
|
124
|
+
raw_zero_y = parsed.chart_area.left +
|
|
125
|
+
(((0 - parsed.min_value) * parsed.chart_area.width) / (parsed.max_value - parsed.min_value));
|
|
126
|
+
parsed.zero_y = Math.max(parsed.chart_area.left, Math.min(parsed.chart_area.right, raw_zero_y));
|
|
127
|
+
} else {
|
|
128
|
+
raw_zero_y = parsed.chart_area.top + parsed.chart_area.height -
|
|
129
|
+
(((0 - parsed.min_value) * parsed.chart_area.height) / (parsed.max_value - parsed.min_value));
|
|
130
|
+
parsed.zero_y = Math.min(parsed.chart_area.bottom, Math.max(parsed.chart_area.top, raw_zero_y));
|
|
131
|
+
}
|
|
91
132
|
|
|
92
133
|
return parsed;
|
|
93
134
|
|
package/charts/src/lines.js
CHANGED
|
@@ -277,9 +277,11 @@ export async function lines(params) {
|
|
|
277
277
|
max_value,
|
|
278
278
|
min_value,
|
|
279
279
|
chart_area,
|
|
280
|
+
stepY_count,
|
|
280
281
|
stepY_value,
|
|
281
282
|
stepY_size,
|
|
282
|
-
stepX_size
|
|
283
|
+
stepX_size,
|
|
284
|
+
zero_y
|
|
283
285
|
} = parseStdAxisOpts({
|
|
284
286
|
max_value: params.max_value,
|
|
285
287
|
min_value: params.min_value,
|
|
@@ -305,10 +307,12 @@ export async function lines(params) {
|
|
|
305
307
|
max_value,
|
|
306
308
|
min_value,
|
|
307
309
|
chart_area,
|
|
310
|
+
stepY_count,
|
|
308
311
|
stepY_value,
|
|
309
312
|
stepY_size,
|
|
310
313
|
stepX_size,
|
|
311
|
-
stepX_count
|
|
314
|
+
stepX_count,
|
|
315
|
+
zero_y
|
|
312
316
|
}
|
|
313
317
|
});
|
|
314
318
|
|
|
@@ -25,7 +25,7 @@ const config = {
|
|
|
25
25
|
stylelintConfigPath: '../../stylelint.config.mjs',
|
|
26
26
|
|
|
27
27
|
// Output directory for the CSS file generated by Style Dictionary
|
|
28
|
-
buildPath: '
|
|
28
|
+
buildPath: './',
|
|
29
29
|
|
|
30
30
|
// Output filename for the generated CSS file
|
|
31
31
|
destFile: 'custom-properties.css',
|
package/dev-tools/README.md
CHANGED
|
@@ -10,9 +10,9 @@ A collection of standalone Node.js CLI utilities to help manage a web project's
|
|
|
10
10
|
|
|
11
11
|
## starter-kit
|
|
12
12
|
|
|
13
|
-
`starter-kit/starter-install.sh` bootstraps a new frontend project: it copies the starter config files (`package.json`, `.gitignore`, `jsconfig.json`, eslint/stylelint configs, webpack config and modules, ...) and installs all the required dev dependencies.
|
|
13
|
+
`starter-kit/starter-install.sh` bootstraps a new frontend project: it copies the starter config files (`package.json`, `.gitignore`, `jsconfig.json`, eslint/stylelint configs, webpack config and modules, ...) into the project root and the frontend files (webpack template, `CLAUDE.md`, `_root_htaccess`/`_root_robots.txt`, ...) into a fixed `./app` folder, then installs all the required dev dependencies.
|
|
14
14
|
|
|
15
|
-
The script **requires zsh** (it exits with an error if launched with `sh` or `bash`). Existing files are never overwritten:
|
|
15
|
+
The script **requires zsh** (it exits with an error if launched with `sh` or `bash`). Existing files are never overwritten: they are copied alongside with a `NEW-` prefix (to be integrated manually or removed). If the `./app` folder already exists, the whole frontend file set is copied into `./NEW-app` instead.
|
|
16
16
|
|
|
17
17
|
### If minimo is already installed
|
|
18
18
|
|
|
@@ -22,7 +22,7 @@ The script is exposed as a `bin` of the package:
|
|
|
22
22
|
npx starter-kit
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
-
Since the installation is fully overwrite-safe, the script can also be **re-run at any time to update** an existing setup (e.g. to refresh the webpack modules or reinstall the dev dependencies): existing files are
|
|
25
|
+
Since the installation is fully overwrite-safe, the script can also be **re-run at any time to update** an existing setup (e.g. to refresh the webpack modules or reinstall the dev dependencies): existing files are copied with the `NEW-` prefix, so running the script again never duplicates or overwrites anything.
|
|
26
26
|
|
|
27
27
|
### Without installing minimo
|
|
28
28
|
|
|
@@ -34,14 +34,6 @@ zsh package/dev-tools/starter-kit/starter-install.sh
|
|
|
34
34
|
rm -rf package
|
|
35
35
|
```
|
|
36
36
|
|
|
37
|
-
To test the installation skipping all `npm install` commands, use the `--no-npm` parameter:
|
|
38
|
-
|
|
39
|
-
```bash
|
|
40
|
-
curl -sL "$(npm view @massimo-cassandro/minimo dist.tarball)" | tar xz package/dev-tools/starter-kit
|
|
41
|
-
zsh package/dev-tools/starter-kit/starter-install.sh --no-npm
|
|
42
|
-
rm -rf package
|
|
43
|
-
```
|
|
44
|
-
|
|
45
37
|
## SVG adn favicons tools
|
|
46
38
|
|
|
47
39
|
```bash
|
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
# IMPORTANTE: questo script è idempotente e deve restare tale.
|
|
4
4
|
# Può essere rilanciato più volte sullo stesso progetto (anche via `npx starter-kit`
|
|
5
5
|
# per aggiornare un setup esistente) senza sovrascrivere nulla né creare duplicati:
|
|
6
|
-
# i file esistenti vengono
|
|
7
|
-
#
|
|
6
|
+
# i file esistenti vengono copiati con prefisso NEW- (da integrare manualmente
|
|
7
|
+
# o rimuovere); la cartella di installazione del frontend, fissa su ./app, se
|
|
8
|
+
# già esistente viene sostituita con ./NEW-app.
|
|
8
9
|
# Ogni modifica futura deve conservare questa proprietà.
|
|
9
10
|
|
|
10
11
|
RED='\033[0;31m'
|
|
@@ -12,7 +13,7 @@ YELLOW='\033[0;33m'
|
|
|
12
13
|
GREEN='\033[0;32m'
|
|
13
14
|
NC='\033[0m' # No Color
|
|
14
15
|
|
|
15
|
-
DEBUG=
|
|
16
|
+
DEBUG=FALSE
|
|
16
17
|
|
|
17
18
|
# Questo script richiede zsh: se lanciato con sh/bash blocca l'esecuzione
|
|
18
19
|
if [ -z "$ZSH_VERSION" ]; then
|
package/package.json
CHANGED
|
@@ -27,6 +27,54 @@ function closeDialog(el) {
|
|
|
27
27
|
}, { once: true });
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Wraps a single domBuilder object (not an array, not a DOM Node) into an array,
|
|
32
|
+
* so `content`/`headerContent`/`footerContent` can be handled uniformly with domBuilder arrays.
|
|
33
|
+
* @param {*} value
|
|
34
|
+
* @returns {*}
|
|
35
|
+
*/
|
|
36
|
+
function normalizeContentParam(value) {
|
|
37
|
+
if(value != null && typeof value === 'object' && !Array.isArray(value) && !(value instanceof Node)) {
|
|
38
|
+
return [value];
|
|
39
|
+
}
|
|
40
|
+
return value;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @param {*} value - a non-null `content`/`headerContent`/`footerContent` value
|
|
45
|
+
* @returns {'domBuilder' | 'node' | 'function' | 'html'}
|
|
46
|
+
*/
|
|
47
|
+
function getContentKind(value) {
|
|
48
|
+
if(Array.isArray(value)) {
|
|
49
|
+
return 'domBuilder';
|
|
50
|
+
}
|
|
51
|
+
if(value instanceof Node) {
|
|
52
|
+
return 'node';
|
|
53
|
+
}
|
|
54
|
+
if(typeof value === 'function') {
|
|
55
|
+
return 'function';
|
|
56
|
+
}
|
|
57
|
+
return 'html';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Resolves the `content`/`children` domBuilder props for a `headerContent`/`footerContent` value.
|
|
62
|
+
* The `function` kind is not handled here: it is invoked separately, from the item's `callback`,
|
|
63
|
+
* once the container element exists.
|
|
64
|
+
* @param {*} value
|
|
65
|
+
* @returns {{content: string | Node | null, children: DomBuilderItem[] | undefined}}
|
|
66
|
+
*/
|
|
67
|
+
function getDomBuilderContentProps(value) {
|
|
68
|
+
if(value == null) {
|
|
69
|
+
return { content: null, children: undefined };
|
|
70
|
+
}
|
|
71
|
+
const kind = getContentKind(value);
|
|
72
|
+
return {
|
|
73
|
+
content: (kind === 'html' || kind === 'node') ? value : null,
|
|
74
|
+
children: kind === 'domBuilder' ? value : undefined
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
30
78
|
/**
|
|
31
79
|
* modalPopup
|
|
32
80
|
* Opens a popup dialog with the given content.
|
|
@@ -40,7 +88,7 @@ function closeDialog(el) {
|
|
|
40
88
|
*
|
|
41
89
|
* @param {Object} params
|
|
42
90
|
* @param {string | null} [params.dialogExtraClassName=null] - Extra class added to the dialog element. (default: null)
|
|
43
|
-
* @param {string | DomBuilderItem | DomBuilderItem[] | null} [params.content=null] - Plain text, HTML, a domBuilder object, or a
|
|
91
|
+
* @param {string | DomBuilderItem | DomBuilderItem[] | Node | ((container: HTMLElement) => void) | null} [params.content=null] - Plain text, HTML, a domBuilder object, a domBuilder array, a DOM node (appended as-is), or a function invoked with the content container element to populate it directly (`content(dialogContentEl)`). (default: null)
|
|
44
92
|
* @param {string | null} [params.contentExtraClassName=null] - Extra class added to the content wrapper. (default: null)
|
|
45
93
|
* @param {HTMLElement | null} [params.triggerElement=null] - Optional element that triggered the popup; when set, `aria-haspopup`, `aria-controls` and `aria-expanded` are managed on it. (default: null)
|
|
46
94
|
* @param {boolean} [params.addFocus=true] - if true, dialog receives focus after opening (default: true)
|
|
@@ -58,8 +106,8 @@ function closeDialog(el) {
|
|
|
58
106
|
* @param {((el: HTMLDialogElement) => void) | null} [params.closeCallback=null] - Called with the dialog element just before it is removed, receives the dialog element (`closeCallback(dialogEl)`). NB: `dialogEl` is removed immediately after closing. (default: null)
|
|
59
107
|
* @param {((data: *, el: Element) => void) | null} [params.ajaxCallback=null] - Called with the Ajax response and the content element. (default: null)
|
|
60
108
|
* @param {boolean} [params.addScrollbarPadding=false] - Adds right padding to compensate for the scrollbar. (default: false)
|
|
61
|
-
* @param {string | DomBuilderItem | DomBuilderItem[] | null} [params.headerContent=null] - Header content: plain text, HTML, a domBuilder object, or a
|
|
62
|
-
* @param {string | DomBuilderItem | DomBuilderItem[] | null} [params.footerContent=null] - Footer content:
|
|
109
|
+
* @param {string | DomBuilderItem | DomBuilderItem[] | Node | ((container: HTMLElement) => void) | null} [params.headerContent=null] - Header content: plain text, HTML, a domBuilder object, a domBuilder array, a DOM node, or a function invoked with the header container element to populate it directly. (default: null)
|
|
110
|
+
* @param {string | DomBuilderItem | DomBuilderItem[] | Node | ((container: HTMLElement) => void) | null} [params.footerContent=null] - Footer content: same accepted types as `headerContent`, invoked with the footer container element. (default: null)
|
|
63
111
|
* @returns {HTMLDialogElement} The dialog element.
|
|
64
112
|
*/
|
|
65
113
|
|
|
@@ -84,7 +132,7 @@ export function modalPopup({
|
|
|
84
132
|
/** iframe url */
|
|
85
133
|
iframeUrl = null,
|
|
86
134
|
|
|
87
|
-
/** content: plain text, html, domBuilder object or
|
|
135
|
+
/** content: plain text, html, domBuilder object/array, DOM node, or function(container) */
|
|
88
136
|
content = null,
|
|
89
137
|
|
|
90
138
|
/** Ajax url and callback.
|
|
@@ -98,13 +146,13 @@ export function modalPopup({
|
|
|
98
146
|
closeCallback = null,
|
|
99
147
|
addScrollbarPadding = false, // adds extra right padding to compensate for the scrollbar
|
|
100
148
|
|
|
101
|
-
/** header content: plain text, HTML, domBuilder object or
|
|
149
|
+
/** header content: plain text, HTML, domBuilder object/array, DOM node, or function(container) */
|
|
102
150
|
headerContent = null,
|
|
103
151
|
|
|
104
152
|
/** extra classname added to header */
|
|
105
153
|
headerExtraClassName = null,
|
|
106
154
|
|
|
107
|
-
/** footer content: plain text, HTML, domBuilder object or
|
|
155
|
+
/** footer content: plain text, HTML, domBuilder object/array, DOM node, or function(container) */
|
|
108
156
|
footerContent = null,
|
|
109
157
|
|
|
110
158
|
/** extra classname added to footer */
|
|
@@ -121,22 +169,13 @@ export function modalPopup({
|
|
|
121
169
|
}
|
|
122
170
|
|
|
123
171
|
// allow a single domBuilder object (in addition to a domBuilder array) by wrapping it in an array
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
if(headerContent != null && typeof headerContent === 'object' && !Array.isArray(headerContent)) {
|
|
128
|
-
headerContent = [headerContent];
|
|
129
|
-
}
|
|
130
|
-
if(footerContent != null && typeof footerContent === 'object' && !Array.isArray(footerContent)) {
|
|
131
|
-
footerContent = [footerContent];
|
|
132
|
-
}
|
|
172
|
+
content = normalizeContentParam(content);
|
|
173
|
+
headerContent = normalizeContentParam(headerContent);
|
|
174
|
+
footerContent = normalizeContentParam(footerContent);
|
|
133
175
|
|
|
134
176
|
let mode;
|
|
135
|
-
if(content != null
|
|
136
|
-
mode =
|
|
137
|
-
|
|
138
|
-
} else if (content != null) {
|
|
139
|
-
mode = 'html';
|
|
177
|
+
if(content != null) {
|
|
178
|
+
mode = getContentKind(content);
|
|
140
179
|
|
|
141
180
|
} else if (ajaxUrl != null && ajaxCallback != null) {
|
|
142
181
|
mode = 'ajax';
|
|
@@ -210,13 +249,17 @@ export function modalPopup({
|
|
|
210
249
|
id: headerId,
|
|
211
250
|
className: classnames(styles.header, headerExtraClassName),
|
|
212
251
|
condition: headerContent != null,
|
|
213
|
-
|
|
214
|
-
|
|
252
|
+
...getDomBuilderContentProps(headerContent),
|
|
253
|
+
callback: el => {
|
|
254
|
+
if(headerContent != null && getContentKind(headerContent) === 'function') {
|
|
255
|
+
/** @type {(container: HTMLElement) => void} */ (headerContent)(/** @type {HTMLElement} */ (el));
|
|
256
|
+
}
|
|
257
|
+
}
|
|
215
258
|
},
|
|
216
259
|
{
|
|
217
260
|
className: classnames(styles.content, contentExtraClassName),
|
|
218
|
-
content: mode === 'html'
|
|
219
|
-
? content
|
|
261
|
+
content: (mode === 'html' || mode === 'node')
|
|
262
|
+
? /** @type {string | Node} */ (content)
|
|
220
263
|
: mode === 'ajax'
|
|
221
264
|
? spinner()
|
|
222
265
|
: null,
|
|
@@ -233,7 +276,11 @@ export function modalPopup({
|
|
|
233
276
|
}
|
|
234
277
|
],
|
|
235
278
|
callback: el => {
|
|
236
|
-
dialogContentEl = el;
|
|
279
|
+
dialogContentEl = /** @type {HTMLElement} */ (el);
|
|
280
|
+
|
|
281
|
+
if(mode === 'function') {
|
|
282
|
+
/** @type {(container: HTMLElement) => void} */ (content)(dialogContentEl);
|
|
283
|
+
}
|
|
237
284
|
|
|
238
285
|
if(mode === 'ajax') {
|
|
239
286
|
try {
|
|
@@ -241,7 +288,7 @@ export function modalPopup({
|
|
|
241
288
|
(async () => {
|
|
242
289
|
const response = await fetch(safeAjaxUrl),
|
|
243
290
|
data = await response.json();
|
|
244
|
-
|
|
291
|
+
dialogContentEl.innerHTML = '';
|
|
245
292
|
ajaxCallback?.(data, dialogContentEl);
|
|
246
293
|
})();
|
|
247
294
|
|
|
@@ -258,8 +305,12 @@ export function modalPopup({
|
|
|
258
305
|
{
|
|
259
306
|
className: classnames(styles.footer, footerExtraClassName),
|
|
260
307
|
condition: footerContent != null,
|
|
261
|
-
|
|
262
|
-
|
|
308
|
+
...getDomBuilderContentProps(footerContent),
|
|
309
|
+
callback: el => {
|
|
310
|
+
if(footerContent != null && getContentKind(footerContent) === 'function') {
|
|
311
|
+
/** @type {(container: HTMLElement) => void} */ (footerContent)(/** @type {HTMLElement} */ (el));
|
|
312
|
+
}
|
|
313
|
+
}
|
|
263
314
|
},
|
|
264
315
|
]
|
|
265
316
|
}
|
package/src/css/forms/forms.css
CHANGED