canvasframework 0.3.20 → 0.3.22
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/components/Chart.js +700 -0
- package/core/CanvasFramework.js +2 -0
- package/core/UIBuilder.js +2 -0
- package/index.js +2 -1
- package/package.json +1 -1
|
@@ -0,0 +1,700 @@
|
|
|
1
|
+
import Component from '../core/Component.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Composant de graphiques avec support Line, Bar, Pie, Doughnut
|
|
5
|
+
* @class
|
|
6
|
+
* @extends Component
|
|
7
|
+
*/
|
|
8
|
+
class Chart extends Component {
|
|
9
|
+
constructor(framework, options = {}) {
|
|
10
|
+
super(framework, options);
|
|
11
|
+
|
|
12
|
+
this.type = options.type || 'line'; // 'line', 'bar', 'pie', 'doughnut', 'area'
|
|
13
|
+
this.data = options.data || { labels: [], datasets: [] };
|
|
14
|
+
this.title = options.title || '';
|
|
15
|
+
this.showLegend = options.showLegend !== false;
|
|
16
|
+
this.showGrid = options.showGrid !== false;
|
|
17
|
+
this.showValues = options.showValues || false;
|
|
18
|
+
this.animated = options.animated !== false;
|
|
19
|
+
this.colors = options.colors || this.getDefaultColors();
|
|
20
|
+
|
|
21
|
+
// Marges et padding
|
|
22
|
+
this.padding = options.padding || 40;
|
|
23
|
+
this.legendHeight = this.showLegend ? 60 : 0;
|
|
24
|
+
this.titleHeight = this.title ? 40 : 0;
|
|
25
|
+
|
|
26
|
+
// Zone de dessin du graphique
|
|
27
|
+
this.chartArea = {
|
|
28
|
+
x: this.padding,
|
|
29
|
+
y: this.padding + this.titleHeight,
|
|
30
|
+
width: this.width - this.padding * 2,
|
|
31
|
+
height: this.height - this.padding * 2 - this.legendHeight - this.titleHeight
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// Animation
|
|
35
|
+
this.animationProgress = this.animated ? 0 : 1;
|
|
36
|
+
this.animationDuration = 1000;
|
|
37
|
+
this.animationStartTime = null;
|
|
38
|
+
|
|
39
|
+
// Interaction
|
|
40
|
+
this.hoveredIndex = -1;
|
|
41
|
+
this.hoveredDatasetIndex = -1;
|
|
42
|
+
|
|
43
|
+
// Options spécifiques par type
|
|
44
|
+
this.lineOptions = {
|
|
45
|
+
tension: options.tension || 0.4, // Courbure des lignes
|
|
46
|
+
pointRadius: options.pointRadius || 4,
|
|
47
|
+
lineWidth: options.lineWidth || 2,
|
|
48
|
+
fill: options.fill || false
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
this.barOptions = {
|
|
52
|
+
barWidth: options.barWidth || null, // Auto si null
|
|
53
|
+
groupGap: options.groupGap || 10,
|
|
54
|
+
barGap: options.barGap || 4
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
this.pieOptions = {
|
|
58
|
+
innerRadius: options.innerRadius || 0, // >0 pour doughnut
|
|
59
|
+
startAngle: options.startAngle || -Math.PI / 2,
|
|
60
|
+
labelDistance: options.labelDistance || 1.3
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// Calculer les échelles
|
|
64
|
+
this.calculateScales();
|
|
65
|
+
|
|
66
|
+
// Démarrer l'animation si activée
|
|
67
|
+
if (this.animated) {
|
|
68
|
+
this.startAnimation();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Couleurs par défaut
|
|
74
|
+
*/
|
|
75
|
+
getDefaultColors() {
|
|
76
|
+
return [
|
|
77
|
+
'#6200EE', '#03DAC6', '#FF6F00', '#E91E63',
|
|
78
|
+
'#2196F3', '#4CAF50', '#FFC107', '#9C27B0',
|
|
79
|
+
'#00BCD4', '#FF5722', '#795548', '#607D8B'
|
|
80
|
+
];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Calcule les échelles min/max pour les axes
|
|
85
|
+
*/
|
|
86
|
+
calculateScales() {
|
|
87
|
+
if (this.type === 'pie' || this.type === 'doughnut') {
|
|
88
|
+
// Pas d'échelles pour les graphiques circulaires
|
|
89
|
+
this.totalValue = this.data.datasets[0]?.data.reduce((a, b) => a + b, 0) || 0;
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
let allValues = [];
|
|
94
|
+
this.data.datasets.forEach(dataset => {
|
|
95
|
+
allValues = allValues.concat(dataset.data);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
this.minValue = Math.min(0, ...allValues);
|
|
99
|
+
this.maxValue = Math.max(...allValues);
|
|
100
|
+
|
|
101
|
+
// Ajouter une marge de 10%
|
|
102
|
+
const range = this.maxValue - this.minValue;
|
|
103
|
+
this.minValue -= range * 0.1;
|
|
104
|
+
this.maxValue += range * 0.1;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Démarre l'animation
|
|
109
|
+
*/
|
|
110
|
+
startAnimation() {
|
|
111
|
+
this.animationStartTime = Date.now();
|
|
112
|
+
this.animationProgress = 0;
|
|
113
|
+
|
|
114
|
+
const animate = () => {
|
|
115
|
+
const elapsed = Date.now() - this.animationStartTime;
|
|
116
|
+
this.animationProgress = Math.min(elapsed / this.animationDuration, 1);
|
|
117
|
+
|
|
118
|
+
// Easing
|
|
119
|
+
this.animationProgress = this.easeOutCubic(this.animationProgress);
|
|
120
|
+
|
|
121
|
+
this.requestRender();
|
|
122
|
+
|
|
123
|
+
if (this.animationProgress < 1) {
|
|
124
|
+
requestAnimationFrame(animate);
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
requestAnimationFrame(animate);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Fonction d'easing
|
|
133
|
+
*/
|
|
134
|
+
easeOutCubic(t) {
|
|
135
|
+
return 1 - Math.pow(1 - t, 3);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Convertit une valeur en coordonnée Y
|
|
140
|
+
*/
|
|
141
|
+
valueToY(value) {
|
|
142
|
+
const range = this.maxValue - this.minValue;
|
|
143
|
+
const ratio = (value - this.minValue) / range;
|
|
144
|
+
return this.chartArea.y + this.chartArea.height - (ratio * this.chartArea.height);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Convertit un index en coordonnée X
|
|
149
|
+
*/
|
|
150
|
+
indexToX(index, total) {
|
|
151
|
+
const step = this.chartArea.width / (total - 1 || 1);
|
|
152
|
+
return this.chartArea.x + index * step;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Dessine le titre
|
|
157
|
+
*/
|
|
158
|
+
drawTitle(ctx) {
|
|
159
|
+
if (!this.title) return;
|
|
160
|
+
|
|
161
|
+
ctx.save();
|
|
162
|
+
ctx.fillStyle = this.platform === 'material' ? '#000000' : '#000000';
|
|
163
|
+
ctx.font = 'bold 18px -apple-system, Roboto, sans-serif';
|
|
164
|
+
ctx.textAlign = 'center';
|
|
165
|
+
ctx.textBaseline = 'middle';
|
|
166
|
+
ctx.fillText(this.title, this.x + this.width / 2, this.y + this.titleHeight / 2);
|
|
167
|
+
ctx.restore();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Dessine la grille
|
|
172
|
+
*/
|
|
173
|
+
drawGrid(ctx) {
|
|
174
|
+
if (!this.showGrid || this.type === 'pie' || this.type === 'doughnut') return;
|
|
175
|
+
|
|
176
|
+
ctx.save();
|
|
177
|
+
ctx.strokeStyle = 'rgba(0, 0, 0, 0.1)';
|
|
178
|
+
ctx.lineWidth = 1;
|
|
179
|
+
|
|
180
|
+
// Lignes horizontales
|
|
181
|
+
const steps = 5;
|
|
182
|
+
for (let i = 0; i <= steps; i++) {
|
|
183
|
+
const y = this.y + this.chartArea.y + (this.chartArea.height / steps) * i;
|
|
184
|
+
|
|
185
|
+
ctx.beginPath();
|
|
186
|
+
ctx.moveTo(this.x + this.chartArea.x, y);
|
|
187
|
+
ctx.lineTo(this.x + this.chartArea.x + this.chartArea.width, y);
|
|
188
|
+
ctx.stroke();
|
|
189
|
+
|
|
190
|
+
// Labels
|
|
191
|
+
const value = this.maxValue - (this.maxValue - this.minValue) * (i / steps);
|
|
192
|
+
ctx.fillStyle = 'rgba(0, 0, 0, 0.6)';
|
|
193
|
+
ctx.font = '12px -apple-system, Roboto, sans-serif';
|
|
194
|
+
ctx.textAlign = 'right';
|
|
195
|
+
ctx.textBaseline = 'middle';
|
|
196
|
+
ctx.fillText(value.toFixed(1), this.x + this.chartArea.x - 5, y);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
ctx.restore();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Dessine un graphique en ligne
|
|
204
|
+
*/
|
|
205
|
+
drawLineChart(ctx) {
|
|
206
|
+
const numPoints = this.data.labels.length;
|
|
207
|
+
|
|
208
|
+
this.data.datasets.forEach((dataset, datasetIndex) => {
|
|
209
|
+
const color = dataset.color || this.colors[datasetIndex % this.colors.length];
|
|
210
|
+
|
|
211
|
+
ctx.save();
|
|
212
|
+
|
|
213
|
+
// Zone remplie (area chart)
|
|
214
|
+
if (this.lineOptions.fill || this.type === 'area') {
|
|
215
|
+
ctx.beginPath();
|
|
216
|
+
ctx.moveTo(
|
|
217
|
+
this.x + this.indexToX(0, numPoints),
|
|
218
|
+
this.y + this.valueToY(0)
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
dataset.data.forEach((value, index) => {
|
|
222
|
+
const x = this.x + this.indexToX(index, numPoints);
|
|
223
|
+
const y = this.y + this.valueToY(value * this.animationProgress);
|
|
224
|
+
|
|
225
|
+
if (index === 0) {
|
|
226
|
+
ctx.lineTo(x, y);
|
|
227
|
+
} else {
|
|
228
|
+
// Courbe de Bézier pour lisser
|
|
229
|
+
const prevX = this.x + this.indexToX(index - 1, numPoints);
|
|
230
|
+
const prevY = this.y + this.valueToY(dataset.data[index - 1] * this.animationProgress);
|
|
231
|
+
const cpX = prevX + (x - prevX) * this.lineOptions.tension;
|
|
232
|
+
|
|
233
|
+
ctx.bezierCurveTo(cpX, prevY, cpX, y, x, y);
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
ctx.lineTo(
|
|
238
|
+
this.x + this.indexToX(numPoints - 1, numPoints),
|
|
239
|
+
this.y + this.valueToY(0)
|
|
240
|
+
);
|
|
241
|
+
ctx.closePath();
|
|
242
|
+
|
|
243
|
+
ctx.fillStyle = color + '33'; // 20% opacité
|
|
244
|
+
ctx.fill();
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Ligne
|
|
248
|
+
ctx.beginPath();
|
|
249
|
+
dataset.data.forEach((value, index) => {
|
|
250
|
+
const x = this.x + this.indexToX(index, numPoints);
|
|
251
|
+
const y = this.y + this.valueToY(value * this.animationProgress);
|
|
252
|
+
|
|
253
|
+
if (index === 0) {
|
|
254
|
+
ctx.moveTo(x, y);
|
|
255
|
+
} else {
|
|
256
|
+
const prevX = this.x + this.indexToX(index - 1, numPoints);
|
|
257
|
+
const prevY = this.y + this.valueToY(dataset.data[index - 1] * this.animationProgress);
|
|
258
|
+
const cpX = prevX + (x - prevX) * this.lineOptions.tension;
|
|
259
|
+
|
|
260
|
+
ctx.bezierCurveTo(cpX, prevY, cpX, y, x, y);
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
ctx.strokeStyle = color;
|
|
265
|
+
ctx.lineWidth = this.lineOptions.lineWidth;
|
|
266
|
+
ctx.stroke();
|
|
267
|
+
|
|
268
|
+
// Points
|
|
269
|
+
dataset.data.forEach((value, index) => {
|
|
270
|
+
const x = this.x + this.indexToX(index, numPoints);
|
|
271
|
+
const y = this.y + this.valueToY(value * this.animationProgress);
|
|
272
|
+
|
|
273
|
+
const isHovered = this.hoveredIndex === index &&
|
|
274
|
+
this.hoveredDatasetIndex === datasetIndex;
|
|
275
|
+
const radius = isHovered ? this.lineOptions.pointRadius * 1.5 :
|
|
276
|
+
this.lineOptions.pointRadius;
|
|
277
|
+
|
|
278
|
+
ctx.beginPath();
|
|
279
|
+
ctx.arc(x, y, radius, 0, Math.PI * 2);
|
|
280
|
+
ctx.fillStyle = '#FFFFFF';
|
|
281
|
+
ctx.fill();
|
|
282
|
+
ctx.strokeStyle = color;
|
|
283
|
+
ctx.lineWidth = 2;
|
|
284
|
+
ctx.stroke();
|
|
285
|
+
|
|
286
|
+
// Valeur au survol
|
|
287
|
+
if (isHovered && this.showValues) {
|
|
288
|
+
ctx.fillStyle = color;
|
|
289
|
+
ctx.font = 'bold 12px -apple-system, Roboto, sans-serif';
|
|
290
|
+
ctx.textAlign = 'center';
|
|
291
|
+
ctx.fillText(value.toFixed(1), x, y - 15);
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
ctx.restore();
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
// Labels X
|
|
299
|
+
this.drawXLabels(ctx);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Dessine un graphique en barres
|
|
304
|
+
*/
|
|
305
|
+
drawBarChart(ctx) {
|
|
306
|
+
const numBars = this.data.labels.length;
|
|
307
|
+
const numDatasets = this.data.datasets.length;
|
|
308
|
+
const groupWidth = this.chartArea.width / numBars;
|
|
309
|
+
const barWidth = this.barOptions.barWidth ||
|
|
310
|
+
(groupWidth - this.barOptions.groupGap * 2) / numDatasets -
|
|
311
|
+
this.barOptions.barGap;
|
|
312
|
+
|
|
313
|
+
this.data.datasets.forEach((dataset, datasetIndex) => {
|
|
314
|
+
const color = dataset.color || this.colors[datasetIndex % this.colors.length];
|
|
315
|
+
|
|
316
|
+
dataset.data.forEach((value, index) => {
|
|
317
|
+
const groupX = this.x + this.chartArea.x + index * groupWidth;
|
|
318
|
+
const barX = groupX + this.barOptions.groupGap +
|
|
319
|
+
datasetIndex * (barWidth + this.barOptions.barGap);
|
|
320
|
+
|
|
321
|
+
const barHeight = Math.abs(this.valueToY(value) - this.valueToY(0)) *
|
|
322
|
+
this.animationProgress;
|
|
323
|
+
const barY = this.y + this.valueToY(value * this.animationProgress);
|
|
324
|
+
|
|
325
|
+
const isHovered = this.hoveredIndex === index &&
|
|
326
|
+
this.hoveredDatasetIndex === datasetIndex;
|
|
327
|
+
|
|
328
|
+
ctx.fillStyle = isHovered ? this.adjustColor(color, 20) : color;
|
|
329
|
+
ctx.fillRect(barX, barY, barWidth, barHeight);
|
|
330
|
+
|
|
331
|
+
// Bordure
|
|
332
|
+
if (this.platform === 'material') {
|
|
333
|
+
ctx.strokeStyle = this.adjustColor(color, -20);
|
|
334
|
+
ctx.lineWidth = 1;
|
|
335
|
+
ctx.strokeRect(barX, barY, barWidth, barHeight);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Valeur
|
|
339
|
+
if (this.showValues || isHovered) {
|
|
340
|
+
ctx.fillStyle = '#000000';
|
|
341
|
+
ctx.font = '11px -apple-system, Roboto, sans-serif';
|
|
342
|
+
ctx.textAlign = 'center';
|
|
343
|
+
ctx.fillText(
|
|
344
|
+
value.toFixed(1),
|
|
345
|
+
barX + barWidth / 2,
|
|
346
|
+
barY - 5
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
// Labels X
|
|
353
|
+
this.drawXLabels(ctx);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Dessine un graphique circulaire (Pie/Doughnut)
|
|
358
|
+
*/
|
|
359
|
+
drawPieChart(ctx) {
|
|
360
|
+
const centerX = this.x + this.width / 2;
|
|
361
|
+
const centerY = this.y + this.chartArea.y + this.chartArea.height / 2;
|
|
362
|
+
const radius = Math.min(this.chartArea.width, this.chartArea.height) / 2 - 20;
|
|
363
|
+
const innerRadius = this.type === 'doughnut' ?
|
|
364
|
+
(this.pieOptions.innerRadius || radius * 0.5) : 0;
|
|
365
|
+
|
|
366
|
+
let currentAngle = this.pieOptions.startAngle;
|
|
367
|
+
const dataset = this.data.datasets[0];
|
|
368
|
+
|
|
369
|
+
dataset.data.forEach((value, index) => {
|
|
370
|
+
const sliceAngle = (value / this.totalValue) * Math.PI * 2 * this.animationProgress;
|
|
371
|
+
const color = dataset.colors?.[index] || this.colors[index % this.colors.length];
|
|
372
|
+
|
|
373
|
+
const isHovered = this.hoveredIndex === index;
|
|
374
|
+
const displayRadius = isHovered ? radius + 10 : radius;
|
|
375
|
+
|
|
376
|
+
// Slice
|
|
377
|
+
ctx.save();
|
|
378
|
+
ctx.beginPath();
|
|
379
|
+
ctx.arc(centerX, centerY, displayRadius, currentAngle, currentAngle + sliceAngle);
|
|
380
|
+
ctx.arc(centerX, centerY, innerRadius, currentAngle + sliceAngle, currentAngle, true);
|
|
381
|
+
ctx.closePath();
|
|
382
|
+
|
|
383
|
+
ctx.fillStyle = color;
|
|
384
|
+
ctx.fill();
|
|
385
|
+
|
|
386
|
+
// Bordure
|
|
387
|
+
ctx.strokeStyle = '#FFFFFF';
|
|
388
|
+
ctx.lineWidth = 2;
|
|
389
|
+
ctx.stroke();
|
|
390
|
+
ctx.restore();
|
|
391
|
+
|
|
392
|
+
// Label
|
|
393
|
+
if (this.animationProgress > 0.8) {
|
|
394
|
+
const labelAngle = currentAngle + sliceAngle / 2;
|
|
395
|
+
const labelRadius = displayRadius * this.pieOptions.labelDistance;
|
|
396
|
+
const labelX = centerX + Math.cos(labelAngle) * labelRadius;
|
|
397
|
+
const labelY = centerY + Math.sin(labelAngle) * labelRadius;
|
|
398
|
+
|
|
399
|
+
ctx.fillStyle = '#000000';
|
|
400
|
+
ctx.font = '12px -apple-system, Roboto, sans-serif';
|
|
401
|
+
ctx.textAlign = 'center';
|
|
402
|
+
ctx.textBaseline = 'middle';
|
|
403
|
+
|
|
404
|
+
const percentage = ((value / this.totalValue) * 100).toFixed(1);
|
|
405
|
+
ctx.fillText(`${percentage}%`, labelX, labelY);
|
|
406
|
+
|
|
407
|
+
if (this.data.labels[index]) {
|
|
408
|
+
ctx.font = 'bold 11px -apple-system, Roboto, sans-serif';
|
|
409
|
+
ctx.fillText(this.data.labels[index], labelX, labelY + 15);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
currentAngle += sliceAngle;
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
// Valeur centrale pour doughnut
|
|
417
|
+
if (this.type === 'doughnut' && this.animationProgress > 0.8) {
|
|
418
|
+
ctx.fillStyle = '#000000';
|
|
419
|
+
ctx.font = 'bold 24px -apple-system, Roboto, sans-serif';
|
|
420
|
+
ctx.textAlign = 'center';
|
|
421
|
+
ctx.textBaseline = 'middle';
|
|
422
|
+
ctx.fillText(this.totalValue.toFixed(0), centerX, centerY);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Dessine les labels de l'axe X
|
|
428
|
+
*/
|
|
429
|
+
drawXLabels(ctx) {
|
|
430
|
+
ctx.save();
|
|
431
|
+
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
|
|
432
|
+
ctx.font = '12px -apple-system, Roboto, sans-serif';
|
|
433
|
+
ctx.textAlign = 'center';
|
|
434
|
+
ctx.textBaseline = 'top';
|
|
435
|
+
|
|
436
|
+
this.data.labels.forEach((label, index) => {
|
|
437
|
+
const x = this.x + this.indexToX(index, this.data.labels.length);
|
|
438
|
+
const y = this.y + this.chartArea.y + this.chartArea.height + 10;
|
|
439
|
+
ctx.fillText(label, x, y);
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
ctx.restore();
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Dessine la légende
|
|
447
|
+
*/
|
|
448
|
+
drawLegend(ctx) {
|
|
449
|
+
if (!this.showLegend) return;
|
|
450
|
+
|
|
451
|
+
const legendY = this.y + this.height - this.legendHeight + 10;
|
|
452
|
+
const itemWidth = 120;
|
|
453
|
+
const itemsPerRow = Math.floor(this.width / itemWidth);
|
|
454
|
+
|
|
455
|
+
ctx.save();
|
|
456
|
+
|
|
457
|
+
this.data.datasets.forEach((dataset, index) => {
|
|
458
|
+
const color = dataset.color || this.colors[index % this.colors.length];
|
|
459
|
+
const row = Math.floor(index / itemsPerRow);
|
|
460
|
+
const col = index % itemsPerRow;
|
|
461
|
+
|
|
462
|
+
const x = this.x + col * itemWidth + 20;
|
|
463
|
+
const y = legendY + row * 25;
|
|
464
|
+
|
|
465
|
+
// Carré de couleur
|
|
466
|
+
ctx.fillStyle = color;
|
|
467
|
+
ctx.fillRect(x, y, 12, 12);
|
|
468
|
+
|
|
469
|
+
// Label
|
|
470
|
+
ctx.fillStyle = '#000000';
|
|
471
|
+
ctx.font = '12px -apple-system, Roboto, sans-serif';
|
|
472
|
+
ctx.textAlign = 'left';
|
|
473
|
+
ctx.textBaseline = 'middle';
|
|
474
|
+
ctx.fillText(dataset.label || `Dataset ${index + 1}`, x + 18, y + 6);
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
ctx.restore();
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Ajuste la luminosité d'une couleur
|
|
482
|
+
*/
|
|
483
|
+
adjustColor(color, amount) {
|
|
484
|
+
const num = parseInt(color.replace('#', ''), 16);
|
|
485
|
+
const r = Math.min(255, Math.max(0, (num >> 16) + amount));
|
|
486
|
+
const g = Math.min(255, Math.max(0, ((num >> 8) & 0x00FF) + amount));
|
|
487
|
+
const b = Math.min(255, Math.max(0, (num & 0x0000FF) + amount));
|
|
488
|
+
return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, '0')}`;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Gère le survol
|
|
493
|
+
*/
|
|
494
|
+
handleHover(x, y) {
|
|
495
|
+
const relativeX = x - this.x;
|
|
496
|
+
const relativeY = y - this.y;
|
|
497
|
+
|
|
498
|
+
if (this.type === 'pie' || this.type === 'doughnut') {
|
|
499
|
+
this.handlePieHover(relativeX, relativeY);
|
|
500
|
+
} else if (this.type === 'line' || this.type === 'area') {
|
|
501
|
+
this.handleLineHover(relativeX, relativeY);
|
|
502
|
+
} else if (this.type === 'bar') {
|
|
503
|
+
this.handleBarHover(relativeX, relativeY);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Gère le survol pour les graphiques circulaires
|
|
509
|
+
*/
|
|
510
|
+
handlePieHover(x, y) {
|
|
511
|
+
const centerX = this.width / 2;
|
|
512
|
+
const centerY = this.chartArea.y + this.chartArea.height / 2;
|
|
513
|
+
|
|
514
|
+
const dx = x - centerX;
|
|
515
|
+
const dy = y - centerY;
|
|
516
|
+
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
517
|
+
const angle = Math.atan2(dy, dx);
|
|
518
|
+
|
|
519
|
+
const radius = Math.min(this.chartArea.width, this.chartArea.height) / 2 - 20;
|
|
520
|
+
const innerRadius = this.type === 'doughnut' ? (this.pieOptions.innerRadius || radius * 0.5) : 0;
|
|
521
|
+
|
|
522
|
+
if (distance >= innerRadius && distance <= radius) {
|
|
523
|
+
let currentAngle = this.pieOptions.startAngle;
|
|
524
|
+
const dataset = this.data.datasets[0];
|
|
525
|
+
|
|
526
|
+
for (let i = 0; i < dataset.data.length; i++) {
|
|
527
|
+
const sliceAngle = (dataset.data[i] / this.totalValue) * Math.PI * 2;
|
|
528
|
+
|
|
529
|
+
let normalizedAngle = angle - this.pieOptions.startAngle;
|
|
530
|
+
if (normalizedAngle < 0) normalizedAngle += Math.PI * 2;
|
|
531
|
+
|
|
532
|
+
if (normalizedAngle >= (currentAngle - this.pieOptions.startAngle) &&
|
|
533
|
+
normalizedAngle <= (currentAngle - this.pieOptions.startAngle + sliceAngle)) {
|
|
534
|
+
this.hoveredIndex = i;
|
|
535
|
+
this.requestRender();
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
currentAngle += sliceAngle;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
this.hoveredIndex = -1;
|
|
544
|
+
this.requestRender();
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Gère le survol pour les graphiques en ligne
|
|
549
|
+
*/
|
|
550
|
+
handleLineHover(x, y) {
|
|
551
|
+
let closestIndex = -1;
|
|
552
|
+
let closestDataset = -1;
|
|
553
|
+
let closestDistance = Infinity;
|
|
554
|
+
|
|
555
|
+
this.data.datasets.forEach((dataset, datasetIndex) => {
|
|
556
|
+
dataset.data.forEach((value, index) => {
|
|
557
|
+
const pointX = this.indexToX(index, this.data.labels.length);
|
|
558
|
+
const pointY = this.valueToY(value);
|
|
559
|
+
|
|
560
|
+
const dx = x - pointX;
|
|
561
|
+
const dy = y - pointY;
|
|
562
|
+
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
563
|
+
|
|
564
|
+
if (distance < closestDistance && distance < 20) {
|
|
565
|
+
closestDistance = distance;
|
|
566
|
+
closestIndex = index;
|
|
567
|
+
closestDataset = datasetIndex;
|
|
568
|
+
}
|
|
569
|
+
});
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
if (this.hoveredIndex !== closestIndex || this.hoveredDatasetIndex !== closestDataset) {
|
|
573
|
+
this.hoveredIndex = closestIndex;
|
|
574
|
+
this.hoveredDatasetIndex = closestDataset;
|
|
575
|
+
this.requestRender();
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Gère le survol pour les graphiques en barres
|
|
581
|
+
*/
|
|
582
|
+
handleBarHover(x, y) {
|
|
583
|
+
const numBars = this.data.labels.length;
|
|
584
|
+
const numDatasets = this.data.datasets.length;
|
|
585
|
+
const groupWidth = this.chartArea.width / numBars;
|
|
586
|
+
const barWidth = this.barOptions.barWidth ||
|
|
587
|
+
(groupWidth - this.barOptions.groupGap * 2) / numDatasets -
|
|
588
|
+
this.barOptions.barGap;
|
|
589
|
+
|
|
590
|
+
let found = false;
|
|
591
|
+
|
|
592
|
+
this.data.datasets.forEach((dataset, datasetIndex) => {
|
|
593
|
+
dataset.data.forEach((value, index) => {
|
|
594
|
+
const groupX = this.chartArea.x + index * groupWidth;
|
|
595
|
+
const barX = groupX + this.barOptions.groupGap +
|
|
596
|
+
datasetIndex * (barWidth + this.barOptions.barGap);
|
|
597
|
+
const barY = this.valueToY(value);
|
|
598
|
+
const barHeight = Math.abs(this.valueToY(value) - this.valueToY(0));
|
|
599
|
+
|
|
600
|
+
if (x >= barX && x <= barX + barWidth &&
|
|
601
|
+
y >= barY && y <= barY + barHeight) {
|
|
602
|
+
this.hoveredIndex = index;
|
|
603
|
+
this.hoveredDatasetIndex = datasetIndex;
|
|
604
|
+
found = true;
|
|
605
|
+
}
|
|
606
|
+
});
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
if (!found) {
|
|
610
|
+
this.hoveredIndex = -1;
|
|
611
|
+
this.hoveredDatasetIndex = -1;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
this.requestRender();
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* Vérifie si un point est dans le composant
|
|
619
|
+
*/
|
|
620
|
+
isPointInside(x, y) {
|
|
621
|
+
return x >= this.x && x <= this.x + this.width &&
|
|
622
|
+
y >= this.y && y <= this.y + this.height;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Gère le mouvement de la souris
|
|
627
|
+
*/
|
|
628
|
+
onMove(x, y) {
|
|
629
|
+
this.handleHover(x, y);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Demande un nouveau rendu
|
|
634
|
+
*/
|
|
635
|
+
requestRender() {
|
|
636
|
+
if (this.framework && this.framework.requestRender) {
|
|
637
|
+
this.framework.requestRender();
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* Met à jour les données du graphique
|
|
643
|
+
*/
|
|
644
|
+
updateData(newData) {
|
|
645
|
+
this.data = newData;
|
|
646
|
+
this.calculateScales();
|
|
647
|
+
|
|
648
|
+
if (this.animated) {
|
|
649
|
+
this.startAnimation();
|
|
650
|
+
} else {
|
|
651
|
+
this.requestRender();
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* Dessine le composant
|
|
657
|
+
*/
|
|
658
|
+
draw(ctx) {
|
|
659
|
+
ctx.save();
|
|
660
|
+
|
|
661
|
+
// Background
|
|
662
|
+
ctx.fillStyle = '#FFFFFF';
|
|
663
|
+
ctx.fillRect(this.x, this.y, this.width, this.height);
|
|
664
|
+
|
|
665
|
+
// Bordure
|
|
666
|
+
if (this.platform === 'material') {
|
|
667
|
+
ctx.strokeStyle = '#E0E0E0';
|
|
668
|
+
ctx.lineWidth = 1;
|
|
669
|
+
ctx.strokeRect(this.x, this.y, this.width, this.height);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// Titre
|
|
673
|
+
this.drawTitle(ctx);
|
|
674
|
+
|
|
675
|
+
// Grille
|
|
676
|
+
this.drawGrid(ctx);
|
|
677
|
+
|
|
678
|
+
// Graphique selon le type
|
|
679
|
+
switch (this.type) {
|
|
680
|
+
case 'line':
|
|
681
|
+
case 'area':
|
|
682
|
+
this.drawLineChart(ctx);
|
|
683
|
+
break;
|
|
684
|
+
case 'bar':
|
|
685
|
+
this.drawBarChart(ctx);
|
|
686
|
+
break;
|
|
687
|
+
case 'pie':
|
|
688
|
+
case 'doughnut':
|
|
689
|
+
this.drawPieChart(ctx);
|
|
690
|
+
break;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// Légende
|
|
694
|
+
this.drawLegend(ctx);
|
|
695
|
+
|
|
696
|
+
ctx.restore();
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
export default Chart;
|
package/core/CanvasFramework.js
CHANGED
|
@@ -51,6 +51,7 @@ import PasswordInput from '../components/PasswordInput.js';
|
|
|
51
51
|
import InputTags from '../components/InputTags.js';
|
|
52
52
|
import InputDatalist from '../components/InputDatalist.js';
|
|
53
53
|
import Banner from '../components/Banner.js';
|
|
54
|
+
import Chart from '../components/Chart.js';
|
|
54
55
|
|
|
55
56
|
// Utils
|
|
56
57
|
import SafeArea from '../utils/SafeArea.js';
|
|
@@ -121,6 +122,7 @@ const FIXED_COMPONENT_TYPES = new Set([
|
|
|
121
122
|
Drawer,
|
|
122
123
|
Dialog,
|
|
123
124
|
Modal,
|
|
125
|
+
Tabs,
|
|
124
126
|
FAB,
|
|
125
127
|
Toast,
|
|
126
128
|
Banner,
|
package/core/UIBuilder.js
CHANGED
|
@@ -52,6 +52,7 @@ import PasswordInput from '../components/PasswordInput.js';
|
|
|
52
52
|
import InputTags from '../components/InputTags.js';
|
|
53
53
|
import InputDatalist from '../components/InputDatalist.js';
|
|
54
54
|
import Banner from '../components/Banner.js';
|
|
55
|
+
import Chart from '../components/Chart.js';
|
|
55
56
|
|
|
56
57
|
// Features
|
|
57
58
|
import PullToRefresh from '../features/PullToRefresh.js';
|
|
@@ -131,6 +132,7 @@ const Components = {
|
|
|
131
132
|
Column,
|
|
132
133
|
Positioned,
|
|
133
134
|
Banner,
|
|
135
|
+
Chart,
|
|
134
136
|
Stack
|
|
135
137
|
};
|
|
136
138
|
|
package/index.js
CHANGED
|
@@ -59,6 +59,7 @@ export { default as PasswordInput } from './components/PasswordInput.js';
|
|
|
59
59
|
export { default as InputTags } from './components/InputTags.js';
|
|
60
60
|
export { default as InputDatalist } from './components/InputDatalist.js';
|
|
61
61
|
export { default as Banner } from './components/Banner.js';
|
|
62
|
+
export { default as Chart } from './components/Chart.js';
|
|
62
63
|
|
|
63
64
|
// Utils
|
|
64
65
|
export { default as SafeArea } from './utils/SafeArea.js';
|
|
@@ -100,7 +101,7 @@ export { default as FeatureFlags } from './manager/FeatureFlags.js';
|
|
|
100
101
|
|
|
101
102
|
// Version du framework
|
|
102
103
|
|
|
103
|
-
export const VERSION = '0.3.
|
|
104
|
+
export const VERSION = '0.3.21';
|
|
104
105
|
|
|
105
106
|
|
|
106
107
|
|