canvasframework 0.3.19 → 0.3.21
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/components/Tabs.js +309 -149
- package/core/CanvasFramework.js +2 -0
- package/core/UIBuilder.js +3 -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/components/Tabs.js
CHANGED
|
@@ -8,248 +8,408 @@ import Component from '../core/Component.js';
|
|
|
8
8
|
class Tabs extends Component {
|
|
9
9
|
constructor(framework, options = {}) {
|
|
10
10
|
super(framework, options);
|
|
11
|
+
|
|
11
12
|
this.tabs = options.tabs || [];
|
|
12
13
|
this.selectedIndex = options.selectedIndex || 0;
|
|
13
14
|
this.onChange = options.onChange;
|
|
14
15
|
this.platform = framework.platform;
|
|
15
|
-
this.height = options.height ||
|
|
16
|
-
|
|
17
|
-
this.
|
|
16
|
+
this.height = options.height || 56;
|
|
17
|
+
|
|
18
|
+
this.indicatorColor = options.indicatorColor ||
|
|
19
|
+
(this.platform === 'material' ? '#6200EE' : '#007AFF');
|
|
20
|
+
this.textColor = options.textColor ||
|
|
21
|
+
(this.platform === 'material' ? '#000000' : '#8E8E93');
|
|
18
22
|
this.selectedTextColor = options.selectedTextColor || this.indicatorColor;
|
|
19
23
|
|
|
20
|
-
//
|
|
24
|
+
// Ripple pour Material
|
|
21
25
|
this.ripples = [];
|
|
22
26
|
this.animationFrame = null;
|
|
23
27
|
this.lastAnimationTime = 0;
|
|
24
28
|
|
|
25
|
-
//
|
|
26
|
-
this.
|
|
27
|
-
this.
|
|
29
|
+
// Animation pour Cupertino
|
|
30
|
+
this.pressedTabIndex = -1;
|
|
31
|
+
this.pressAnimation = 0;
|
|
32
|
+
|
|
33
|
+
// ✅ Structure: tableau de tableaux d'enfants
|
|
34
|
+
// tabChildren[0] = [enfants du tab 0]
|
|
35
|
+
// tabChildren[1] = [enfants du tab 1]
|
|
36
|
+
this.tabChildren = this.tabs.map(() => []);
|
|
28
37
|
|
|
38
|
+
// ✅ Configuration: nombre d'enfants par tab
|
|
39
|
+
// Si défini, distribue automatiquement les enfants
|
|
40
|
+
// Ex: childrenPerTab = [3, 2] => 3 enfants pour tab 0, 2 pour tab 1
|
|
41
|
+
this.childrenPerTab = options.childrenPerTab || null;
|
|
42
|
+
this.currentTabIndex = 0;
|
|
43
|
+
this.childAddCount = 0; // Compteur d'enfants ajoutés
|
|
44
|
+
|
|
45
|
+
// Gestionnaire de clic
|
|
29
46
|
this.onPress = this.handlePress.bind(this);
|
|
47
|
+
|
|
48
|
+
// Position par défaut
|
|
49
|
+
this.position = options.position || (this.platform === 'cupertino' ? 'bottom' : 'top');
|
|
50
|
+
|
|
51
|
+
if (this.position === 'bottom' && !options.y) {
|
|
52
|
+
this.y = framework.height - this.height;
|
|
53
|
+
} else if (this.position === 'top' && !options.y) {
|
|
54
|
+
this.y = options.appbar || 0;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Zone de contenu (sous les tabs)
|
|
58
|
+
this.contentY = this.y + this.height;
|
|
59
|
+
this.contentHeight = framework.height - this.height;
|
|
30
60
|
}
|
|
31
61
|
|
|
32
62
|
/**
|
|
33
|
-
*
|
|
34
|
-
* @
|
|
63
|
+
* ✅ Définit le tab actuel pour l'ajout d'enfants (appelé par UIBuilder)
|
|
64
|
+
* @param {number} tabIndex - Index du tab
|
|
35
65
|
*/
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
let needsUpdate = false;
|
|
43
|
-
|
|
44
|
-
// Mettre à jour chaque ripple
|
|
45
|
-
for (let i = this.ripples.length - 1; i >= 0; i--) {
|
|
46
|
-
const ripple = this.ripples[i];
|
|
47
|
-
|
|
48
|
-
// Animer le rayon (expansion)
|
|
49
|
-
if (ripple.radius < ripple.maxRadius) {
|
|
50
|
-
ripple.radius += (ripple.maxRadius / 300) * deltaTime;
|
|
51
|
-
needsUpdate = true;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// Animer l'opacité (fade out)
|
|
55
|
-
if (ripple.radius >= ripple.maxRadius * 0.4) {
|
|
56
|
-
ripple.opacity -= (0.003 * deltaTime);
|
|
57
|
-
if (ripple.opacity < 0) ripple.opacity = 0;
|
|
58
|
-
needsUpdate = true;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
// Supprimer les ripples terminés
|
|
62
|
-
if (ripple.opacity <= 0 && ripple.radius >= ripple.maxRadius * 0.95) {
|
|
63
|
-
this.ripples.splice(i, 1);
|
|
64
|
-
needsUpdate = true;
|
|
65
|
-
}
|
|
66
|
-
}
|
|
66
|
+
setCurrentTab(tabIndex) {
|
|
67
|
+
if (tabIndex >= 0 && tabIndex < this.tabChildren.length) {
|
|
68
|
+
this.currentTabIndex = tabIndex;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
67
71
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
72
|
+
/**
|
|
73
|
+
* ✅ Ajoute un enfant au tab en cours
|
|
74
|
+
* Distribution automatique: divise les enfants équitablement entre les tabs
|
|
75
|
+
* @param {Component} child - Composant enfant
|
|
76
|
+
* @returns {Component} L'enfant ajouté
|
|
77
|
+
*/
|
|
78
|
+
add(child) {
|
|
79
|
+
// Coordonnées relatives à la zone de contenu
|
|
80
|
+
child.x = child.x || 0;
|
|
81
|
+
child.y = child.y || 0;
|
|
82
|
+
|
|
83
|
+
// Dimensions par défaut
|
|
84
|
+
if (!child.width) child.width = this.framework.width;
|
|
85
|
+
|
|
86
|
+
// Marquer l'enfant comme appartenant à ce Tabs
|
|
87
|
+
child.parentTabs = this;
|
|
88
|
+
|
|
89
|
+
// ✅ Calculer quel tab doit recevoir cet enfant
|
|
90
|
+
// On distribue équitablement les enfants entre les tabs
|
|
91
|
+
const totalChildren = this.tabChildren.reduce((sum, arr) => sum + arr.length, 0);
|
|
92
|
+
const childrenPerTab = Math.ceil(totalChildren / this.tabs.length);
|
|
93
|
+
|
|
94
|
+
// Trouver le premier tab qui n'est pas encore plein
|
|
95
|
+
let targetTabIndex = 0;
|
|
96
|
+
for (let i = 0; i < this.tabChildren.length; i++) {
|
|
97
|
+
if (this.tabChildren[i].length < childrenPerTab) {
|
|
98
|
+
targetTabIndex = i;
|
|
99
|
+
break;
|
|
71
100
|
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
this.animationFrame = requestAnimationFrame(animate);
|
|
76
|
-
} else {
|
|
77
|
-
this.animationFrame = null;
|
|
78
|
-
this.lastAnimationTime = 0;
|
|
101
|
+
// Si tous les tabs ont childrenPerTab enfants, recommencer à 0
|
|
102
|
+
if (i === this.tabChildren.length - 1) {
|
|
103
|
+
targetTabIndex = totalChildren % this.tabs.length;
|
|
79
104
|
}
|
|
80
|
-
};
|
|
81
|
-
|
|
82
|
-
if (this.ripples.length > 0 && !this.animationFrame) {
|
|
83
|
-
this.animationFrame = requestAnimationFrame(animate);
|
|
84
105
|
}
|
|
106
|
+
|
|
107
|
+
// Ajouter au tableau du tab calculé
|
|
108
|
+
this.tabChildren[targetTabIndex].push(child);
|
|
109
|
+
|
|
110
|
+
// Visibilité selon le tab sélectionné
|
|
111
|
+
child.visible = (targetTabIndex === this.selectedIndex);
|
|
112
|
+
|
|
113
|
+
return child;
|
|
85
114
|
}
|
|
86
115
|
|
|
87
116
|
/**
|
|
88
|
-
*
|
|
89
|
-
* @private
|
|
117
|
+
* ✅ Met à jour la visibilité des enfants selon l'onglet sélectionné
|
|
90
118
|
*/
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
this.
|
|
94
|
-
|
|
119
|
+
updateChildrenVisibility() {
|
|
120
|
+
this.tabChildren.forEach((children, tabIdx) => {
|
|
121
|
+
const isVisible = (tabIdx === this.selectedIndex);
|
|
122
|
+
children.forEach(child => {
|
|
123
|
+
child.visible = isVisible;
|
|
124
|
+
});
|
|
125
|
+
});
|
|
95
126
|
}
|
|
96
127
|
|
|
97
128
|
/**
|
|
98
|
-
*
|
|
129
|
+
* ✅ Retourne tous les enfants du tab sélectionné
|
|
99
130
|
*/
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
cancelAnimationFrame(this.animationFrame);
|
|
103
|
-
this.animationFrame = null;
|
|
104
|
-
}
|
|
105
|
-
if (super.destroy) {
|
|
106
|
-
super.destroy();
|
|
107
|
-
}
|
|
131
|
+
getActiveChildren() {
|
|
132
|
+
return this.tabChildren[this.selectedIndex] || [];
|
|
108
133
|
}
|
|
109
134
|
|
|
110
135
|
handlePress(x, y) {
|
|
111
|
-
|
|
136
|
+
// D'abord vérifier les clics sur les enfants
|
|
137
|
+
if (y > this.y + this.height && this.checkChildClick(x, y)) {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Ensuite vérifier les clics sur la barre de tabs
|
|
142
|
+
if (!this.isPointInside(x, y)) return;
|
|
112
143
|
|
|
113
|
-
|
|
114
|
-
const
|
|
115
|
-
const deltaX = Math.abs(x - this.lastEventCoords.x);
|
|
116
|
-
const deltaY = Math.abs(y - this.lastEventCoords.y);
|
|
144
|
+
const tabWidth = this.width / this.tabs.length;
|
|
145
|
+
const index = Math.floor((x - this.x) / tabWidth);
|
|
117
146
|
|
|
118
|
-
|
|
147
|
+
if (index < 0 || index >= this.tabs.length) return;
|
|
119
148
|
|
|
120
|
-
|
|
121
|
-
|
|
149
|
+
// Ripple pour Material
|
|
150
|
+
if (this.platform === 'material') {
|
|
151
|
+
const rippleCenterX = this.x + index * tabWidth + tabWidth / 2;
|
|
152
|
+
const maxRippleRadius = Math.min(tabWidth * 0.6, this.height * 0.8);
|
|
153
|
+
|
|
154
|
+
this.ripples.push({
|
|
155
|
+
x: rippleCenterX,
|
|
156
|
+
y: this.y + this.height / 2,
|
|
157
|
+
radius: 0,
|
|
158
|
+
maxRadius: maxRippleRadius,
|
|
159
|
+
opacity: 1
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
if (!this.animationFrame) this.startRippleAnimation();
|
|
163
|
+
}
|
|
164
|
+
// Animation Cupertino
|
|
165
|
+
else if (this.platform === 'cupertino') {
|
|
166
|
+
this.pressedTabIndex = index;
|
|
167
|
+
this.pressAnimation = 1;
|
|
168
|
+
this.requestRender();
|
|
169
|
+
setTimeout(() => this.animatePressRelease(), 100);
|
|
122
170
|
}
|
|
123
171
|
|
|
124
|
-
//
|
|
125
|
-
this.
|
|
126
|
-
|
|
172
|
+
// Changement d'onglet
|
|
173
|
+
if (index !== this.selectedIndex) {
|
|
174
|
+
this.selectedIndex = index;
|
|
175
|
+
this.updateChildrenVisibility();
|
|
176
|
+
if (this.onChange) this.onChange(index, this.tabs[index]);
|
|
177
|
+
}
|
|
127
178
|
|
|
128
|
-
|
|
129
|
-
|
|
179
|
+
this.requestRender();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* ✅ Vérifie les clics sur les enfants du tab actif
|
|
184
|
+
*/
|
|
185
|
+
checkChildClick(x, y) {
|
|
186
|
+
const adjustedY = y - (this.framework.scrollOffset || 0);
|
|
187
|
+
const activeChildren = this.getActiveChildren();
|
|
130
188
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
tabIndex: index
|
|
147
|
-
});
|
|
189
|
+
// Parcourir en ordre inverse (derniers ajoutés = au dessus)
|
|
190
|
+
for (let i = activeChildren.length - 1; i >= 0; i--) {
|
|
191
|
+
const child = activeChildren[i];
|
|
192
|
+
|
|
193
|
+
if (!child.visible) continue;
|
|
194
|
+
|
|
195
|
+
// Calculer les coordonnées absolues de l'enfant
|
|
196
|
+
const childX = this.x + child.x;
|
|
197
|
+
const childY = this.contentY + child.y;
|
|
198
|
+
|
|
199
|
+
// Vérifier si le clic est dans l'enfant
|
|
200
|
+
if (adjustedY >= childY &&
|
|
201
|
+
adjustedY <= childY + child.height &&
|
|
202
|
+
x >= childX &&
|
|
203
|
+
x <= childX + child.width) {
|
|
148
204
|
|
|
149
|
-
//
|
|
150
|
-
if (
|
|
151
|
-
|
|
205
|
+
// Si l'enfant a un onClick ou onPress, le déclencher
|
|
206
|
+
if (child.onClick) {
|
|
207
|
+
child.onClick();
|
|
208
|
+
return true;
|
|
209
|
+
} else if (child.onPress) {
|
|
210
|
+
child.onPress(x, adjustedY);
|
|
211
|
+
return true;
|
|
152
212
|
}
|
|
153
|
-
|
|
154
|
-
// Forcer un redessin
|
|
155
|
-
this.requestRender();
|
|
156
213
|
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
157
218
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
219
|
+
animatePressRelease() {
|
|
220
|
+
let startTime = null;
|
|
221
|
+
const duration = 150;
|
|
222
|
+
|
|
223
|
+
const animate = (timestamp) => {
|
|
224
|
+
if (!startTime) startTime = timestamp;
|
|
225
|
+
const progress = Math.min((timestamp - startTime) / duration, 1);
|
|
163
226
|
|
|
227
|
+
this.pressAnimation = 1 - progress;
|
|
164
228
|
this.requestRender();
|
|
165
|
-
|
|
229
|
+
|
|
230
|
+
if (progress < 1) requestAnimationFrame(animate);
|
|
231
|
+
else {
|
|
232
|
+
this.pressAnimation = 0;
|
|
233
|
+
this.pressedTabIndex = -1;
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
requestAnimationFrame(animate);
|
|
166
238
|
}
|
|
167
239
|
|
|
168
240
|
isPointInside(x, y) {
|
|
169
|
-
return x >= this.x && x <= this.x + this.width &&
|
|
241
|
+
return x >= this.x && x <= this.x + this.width &&
|
|
170
242
|
y >= this.y && y <= this.y + this.height;
|
|
171
243
|
}
|
|
172
244
|
|
|
245
|
+
startRippleAnimation() {
|
|
246
|
+
const animate = (timestamp) => {
|
|
247
|
+
if (!this.lastAnimationTime) this.lastAnimationTime = timestamp;
|
|
248
|
+
const deltaTime = timestamp - this.lastAnimationTime;
|
|
249
|
+
this.lastAnimationTime = timestamp;
|
|
250
|
+
|
|
251
|
+
let needsUpdate = false;
|
|
252
|
+
|
|
253
|
+
for (let i = this.ripples.length - 1; i >= 0; i--) {
|
|
254
|
+
const ripple = this.ripples[i];
|
|
255
|
+
|
|
256
|
+
if (ripple.radius < ripple.maxRadius)
|
|
257
|
+
ripple.radius += (ripple.maxRadius / 300) * deltaTime;
|
|
258
|
+
|
|
259
|
+
if (ripple.radius >= ripple.maxRadius * 0.4) {
|
|
260
|
+
ripple.opacity -= (0.003 * deltaTime);
|
|
261
|
+
if (ripple.opacity < 0) ripple.opacity = 0;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (ripple.opacity <= 0 && ripple.radius >= ripple.maxRadius * 0.95)
|
|
265
|
+
this.ripples.splice(i, 1);
|
|
266
|
+
|
|
267
|
+
needsUpdate = true;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (needsUpdate) this.requestRender();
|
|
271
|
+
|
|
272
|
+
if (this.ripples.length > 0)
|
|
273
|
+
this.animationFrame = requestAnimationFrame(animate);
|
|
274
|
+
else
|
|
275
|
+
this.animationFrame = null;
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
if (this.ripples.length && !this.animationFrame)
|
|
279
|
+
this.animationFrame = requestAnimationFrame(animate);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
requestRender() {
|
|
283
|
+
if (this.framework && this.framework.requestRender)
|
|
284
|
+
this.framework.requestRender();
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* ✅ Dessine les tabs et les enfants du tab actif
|
|
289
|
+
*/
|
|
173
290
|
draw(ctx) {
|
|
174
291
|
ctx.save();
|
|
175
292
|
|
|
293
|
+
// ===== DESSINER LA BARRE DE TABS =====
|
|
294
|
+
|
|
176
295
|
// Background
|
|
177
|
-
ctx.fillStyle = '#
|
|
296
|
+
ctx.fillStyle = this.platform === 'material' ? '#FFF' : '#F2F2F7';
|
|
178
297
|
ctx.fillRect(this.x, this.y, this.width, this.height);
|
|
179
298
|
|
|
180
|
-
// Bordure inférieure
|
|
181
|
-
ctx.strokeStyle = '#E0E0E0';
|
|
182
|
-
ctx.lineWidth = 1;
|
|
183
|
-
ctx.beginPath();
|
|
184
|
-
ctx.moveTo(this.x, this.y + this.height);
|
|
185
|
-
ctx.lineTo(this.x + this.width, this.y + this.height);
|
|
186
|
-
ctx.stroke();
|
|
187
|
-
|
|
188
|
-
const tabWidth = this.width / this.tabs.length;
|
|
189
|
-
|
|
190
|
-
// 🔹 Dessiner les ripples Material EN PREMIER
|
|
191
299
|
if (this.platform === 'material') {
|
|
192
|
-
|
|
300
|
+
ctx.strokeStyle = '#E0E0E0';
|
|
301
|
+
ctx.lineWidth = 1;
|
|
302
|
+
ctx.beginPath();
|
|
303
|
+
ctx.moveTo(this.x, this.y + this.height);
|
|
304
|
+
ctx.lineTo(this.x + this.width, this.y + this.height);
|
|
305
|
+
ctx.stroke();
|
|
193
306
|
}
|
|
194
|
-
|
|
195
|
-
|
|
307
|
+
|
|
308
|
+
const tabWidth = this.width / this.tabs.length;
|
|
309
|
+
|
|
310
|
+
// Ripples
|
|
311
|
+
if (this.platform === 'material') this.drawRipples(ctx, tabWidth);
|
|
312
|
+
|
|
196
313
|
for (let i = 0; i < this.tabs.length; i++) {
|
|
197
314
|
const tab = this.tabs[i];
|
|
198
315
|
const tabX = this.x + i * tabWidth;
|
|
199
316
|
const isSelected = i === this.selectedIndex;
|
|
317
|
+
|
|
318
|
+
// Cupertino pressed effect
|
|
319
|
+
if (this.platform === 'cupertino' && i === this.pressedTabIndex) {
|
|
320
|
+
ctx.fillStyle = `rgba(0,122,255,${0.1 * this.pressAnimation})`;
|
|
321
|
+
ctx.fillRect(tabX, this.y, tabWidth, this.height);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Indicators
|
|
325
|
+
if (this.platform === 'cupertino' && isSelected) {
|
|
326
|
+
ctx.fillStyle = '#007AFF';
|
|
327
|
+
ctx.fillRect(tabX + tabWidth/2 - 15, this.y + this.height - 2, 30, 2);
|
|
328
|
+
}
|
|
329
|
+
|
|
200
330
|
const color = isSelected ? this.selectedTextColor : this.textColor;
|
|
201
|
-
|
|
202
|
-
//
|
|
331
|
+
|
|
332
|
+
// Icon
|
|
203
333
|
if (tab.icon) {
|
|
204
|
-
ctx.font = '
|
|
334
|
+
ctx.font = '24px -apple-system, sans-serif';
|
|
205
335
|
ctx.textAlign = 'center';
|
|
206
336
|
ctx.textBaseline = 'middle';
|
|
207
337
|
ctx.fillStyle = color;
|
|
208
|
-
|
|
338
|
+
const iconY = this.platform === 'material' ? this.y + 18 : this.y + 20;
|
|
339
|
+
ctx.fillText(tab.icon, tabX + tabWidth/2, iconY);
|
|
209
340
|
}
|
|
210
|
-
|
|
341
|
+
|
|
211
342
|
// Label
|
|
212
|
-
|
|
343
|
+
const fontSize = this.platform === 'material' ? 14 : 12;
|
|
344
|
+
const fontWeight = isSelected ? '600' : '400';
|
|
345
|
+
ctx.font = `${fontWeight} ${fontSize}px -apple-system, Roboto, sans-serif`;
|
|
213
346
|
ctx.fillStyle = color;
|
|
214
347
|
ctx.textAlign = 'center';
|
|
215
348
|
ctx.textBaseline = 'middle';
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
349
|
+
|
|
350
|
+
const labelY = this.platform === 'material'
|
|
351
|
+
? (tab.icon ? this.y + 36 : this.y + this.height / 2)
|
|
352
|
+
: (tab.icon ? this.y + 42 : this.y + this.height / 2);
|
|
353
|
+
|
|
354
|
+
ctx.fillText(tab.label, tabX + tabWidth/2, labelY);
|
|
355
|
+
|
|
356
|
+
// Material indicator
|
|
220
357
|
if (isSelected && this.platform === 'material') {
|
|
221
358
|
ctx.fillStyle = this.indicatorColor;
|
|
222
|
-
ctx.fillRect(tabX, this.y + this.height -
|
|
359
|
+
ctx.fillRect(tabX, this.y + this.height - 3, tabWidth, 3);
|
|
223
360
|
}
|
|
224
361
|
}
|
|
225
|
-
|
|
362
|
+
|
|
226
363
|
ctx.restore();
|
|
364
|
+
|
|
365
|
+
// ===== DESSINER LES ENFANTS DU TAB ACTIF =====
|
|
366
|
+
const activeChildren = this.getActiveChildren();
|
|
367
|
+
|
|
368
|
+
for (let child of activeChildren) {
|
|
369
|
+
if (child.visible) {
|
|
370
|
+
ctx.save();
|
|
371
|
+
|
|
372
|
+
// Sauvegarder les coordonnées originales
|
|
373
|
+
const originalX = child.x;
|
|
374
|
+
const originalY = child.y;
|
|
375
|
+
|
|
376
|
+
// Ajuster les coordonnées pour être absolues
|
|
377
|
+
child.x = this.x + originalX;
|
|
378
|
+
child.y = this.contentY + originalY;
|
|
379
|
+
|
|
380
|
+
// Dessiner l'enfant
|
|
381
|
+
child.draw(ctx);
|
|
382
|
+
|
|
383
|
+
// Restaurer les coordonnées originales
|
|
384
|
+
child.x = originalX;
|
|
385
|
+
child.y = originalY;
|
|
386
|
+
|
|
387
|
+
ctx.restore();
|
|
388
|
+
}
|
|
389
|
+
}
|
|
227
390
|
}
|
|
228
391
|
|
|
229
|
-
/**
|
|
230
|
-
* Dessine les ripples (Material)
|
|
231
|
-
* @private
|
|
232
|
-
*/
|
|
233
392
|
drawRipples(ctx, tabWidth) {
|
|
234
|
-
// Sauvegarder le contexte
|
|
235
393
|
ctx.save();
|
|
236
|
-
|
|
237
|
-
// Créer un masque de clipping pour limiter les ripples aux onglets
|
|
238
394
|
ctx.beginPath();
|
|
239
395
|
ctx.rect(this.x, this.y, this.width, this.height);
|
|
240
396
|
ctx.clip();
|
|
241
397
|
|
|
242
398
|
for (let ripple of this.ripples) {
|
|
243
399
|
ctx.globalAlpha = ripple.opacity;
|
|
244
|
-
ctx.fillStyle = this.indicatorColor
|
|
400
|
+
ctx.fillStyle = this.indicatorColor;
|
|
245
401
|
ctx.beginPath();
|
|
246
|
-
ctx.arc(ripple.x, ripple.y, ripple.radius, 0, Math.PI
|
|
402
|
+
ctx.arc(ripple.x, ripple.y, ripple.radius, 0, Math.PI*2);
|
|
247
403
|
ctx.fill();
|
|
248
404
|
}
|
|
249
405
|
|
|
250
|
-
// Restaurer le contexte
|
|
251
406
|
ctx.restore();
|
|
252
407
|
}
|
|
408
|
+
|
|
409
|
+
destroy() {
|
|
410
|
+
if (this.animationFrame) cancelAnimationFrame(this.animationFrame);
|
|
411
|
+
if (super.destroy) super.destroy();
|
|
412
|
+
}
|
|
253
413
|
}
|
|
254
414
|
|
|
255
415
|
export default Tabs;
|
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,8 @@ const Components = {
|
|
|
131
132
|
Column,
|
|
132
133
|
Positioned,
|
|
133
134
|
Banner,
|
|
135
|
+
Chart,
|
|
136
|
+
QRCode,
|
|
134
137
|
Stack
|
|
135
138
|
};
|
|
136
139
|
|
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
|
|