chart = {
await relancer;
d3.selectAll("svg.chart-anim").remove();
const _ = trigger;
const dataset = data;
const localeFr = d3.timeFormatLocale({
dateTime: "%A, le %e %B %Y, %X",
date: "%d/%m/%Y",
time: "%H:%M:%S",
periods: ["AM", "PM"],
days: ["dimanche", "lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi"],
shortDays: ["dim.", "lun.", "mar.", "mer.", "jeu.", "ven.", "sam."],
months: ["Janv", "Fév.", "Mars", "Avr", "Mai", "juin",
"Juil", "Août", "Sept", "Oct", "Nov", "Déc"],
shortMonths: ["janv.", "févr.", "mars", "avr.", "mai", "juin",
"juil.", "août", "sept.", "oct.", "nov.", "déc."]
});
// Format personnalisé pour l’axe X
const formatMonth = localeFr.format("%B");
const width = 800;
const height = 600;
const margin = { top: 20, right: 30, bottom: 30, left: 200 };
data.forEach(d => d.Mois = new Date(d.Mois));
const series = ["4M", "Sans", "HMH", "Somfy", "Nice Home", "SOMMER", "StandardLine", "HMI", "CAME", "Nice", "FADINI", "Marantec", "ACM"];
const x = d3.scaleUtc()
.domain(d3.extent(data, d => d.Mois))
.range([margin.left, width - margin.right]);
/* const y = d3.scaleLinear()
.domain([
0,
d3.max(series, key => d3.max(data, d => d[key]))
]).nice()
.range([height - margin.bottom, margin.top]); */
/* const y = d3.scaleLog()
.base(25)
.domain([
Math.max(1, d3.min(series, key => d3.min(data, d => d[key]))),
d3.max(series, key => d3.max(data, d => d[key]))
])
.range([height - margin.bottom, margin.top]); */
const allValues = data.flatMap(d =>
series.map(s => +d[s]).filter(v => !isNaN(v) && v > 0)
);
const minY = d3.min(allValues);
const maxY = d3.max(allValues);
const y = d3.scaleSymlog()
.constant(1000) // "force" la courbure autour de 1000
.domain([minY, maxY])
.range([height - margin.bottom, margin.top]);
const color = d3.scaleOrdinal()
.domain(series)
.range(d3.schemeCategory10); // 10 couleurs automatiques
const svg = d3.create("svg")
.attr("width", width)
.attr("height", height);
const tickValues = [1000, 2000, 3000, 4000, 5000, 10000, 15000, 20000,30000,40000,50000,60000,80000,100000,120000,150000,200000];
svg.append("g")
.attr("transform", `translate(${margin.left},0)`)
.attr("class", "y-axis") // 👈 nécessaire
.call(d3.axisLeft(y)
.tickValues(tickValues)
.tickFormat(d => `${d / 1000}k`));
//.call(d3.axisLeft(y).ticks(10) .tickFormat(d => `${Math.ceil(d/1000)}k`));//.tickFormat(d3.format("~s")));
// .ticks(10, "~s")); // format court (ex. 10k, 100k)
/* svg.append("g")
.attr("transform", `translate(0,${height - margin.bottom})`)
.call(d3.axisBottom(x)); */
svg.append("g")
.attr("transform", `translate(0,${height - margin.bottom})`)
.attr("class", "x-axis") // 👈 nécessaire
.call(d3.axisBottom(x).tickFormat(formatMonth));
/* svg.append("g")
.attr("transform", `translate(${margin.left},0)`)
//.call(d3.axisLeft(y));
.attr("class", "y-axis") // 👈 nécessaire
.call(d3.axisLeft(y).ticks(2).tickFormat(d3.format("~s"))) // Par exemple, 6 graduations bien espacées */
svg.selectAll(".x-axis text").style("font-size", "15px");
svg.selectAll(".y-axis text").style("font-size", "15px");
const legend = svg.append("g")
.attr("transform", `translate(${margin.left - 200}, ${margin.top})`);
function formatApostrophe(n) {
return Math.ceil(n).toString().replace(/\B(?=(\d{3})+(?!\d))/g, "'");
}
const paths = {};
const labels = {};
const visibleSeries = new Set(series);
const tooltip = d3.select("body").append("div")
.style("position", "absolute")
.style("padding", "12px 20px")
.style("background", "#000")
.style("color", "#fff")
.style("border", "5px solid #ccc")
.style("border-radius", "10px")
.style("font-size", "24px")
.style("pointer-events", "none")
.style("box-shadow", "0 0 20px rgba(0,0,0,0.5)")
.style("opacity", 0);
//for (const key of series) {
for (const [i, key] of series.entries()) {
const lineGenerator = d3.line()
.x(d => x(d.Mois))
.y(d => y(d[key]))
.curve(d3.curveCatmullRom);
const path = svg.append("path")
.datum(data)
.attr("fill", "none")
.attr("stroke", color(key))
.attr("stroke-width", 1.5)
.attr("d", lineGenerator);
paths[key] = path;
const totalLength = path.node().getTotalLength();
// 1. Trouver le point max réel (tu le fais probablement déjà)
const maxPoint = data
.filter(d => d[key] !== undefined && d[key] !== "" && !isNaN(+d[key]))
.reduce((a, b) => (+a[key] > +b[key] ? a : b));
// 2. Création du cercle comète
const comet = svg.append("circle")
.attr("r", 4)
.attr("fill", color(key))
.attr("opacity", 0);
// 3. Animation de suivi
comet
.transition()
.delay(i * 150)
.duration(1500)
.ease(d3.easeCubic)
.attr("opacity", 1)
.attrTween("transform", () => {
return function(t) {
const p = path.node().getPointAtLength(t * totalLength);
return `translate(${p.x},${p.y})`;
};
})
comet.on("mouseover", (event) => {
tooltip
.style("opacity", 1)
.html(`<b>${key}</b><br>Total cumulé : ${formatApostrophe(Math.ceil(totalCumule))}€`);
})
.on("mousemove", (event) => {
tooltip
.style("left", (event.pageX + 12) + "px")
.style("top", (event.pageY - 28) + "px");
})
.on("mouseout", () => {
tooltip.style("opacity", 0)
})
.on("end", () => {
// 4. À la fin → position fixe au point max
comet
.transition()
.duration(200)
.attr("transform", `translate(${x(maxPoint.Mois)},${y(maxPoint[key])})`)
.attr("r", 5); // optionnel : un peu plus gros au sommet
});
const totalCumule = data
.filter(d => d.Mois <= maxPoint.Mois)
.reduce((sum, d) => sum + (+d[key] || 0), 0);
const label = svg.append("text")
.attr("x", x(maxPoint.Mois))
.attr("y", y(maxPoint[key]) - 10)
.attr("text-anchor", "middle")
.attr("font-size", "12px")
.attr("font-weight", "bold")
.attr("fill", color(key))
.style("opacity", 0)
.text(`${formatApostrophe(Math.ceil(maxPoint[key]))}€`);
path
.attr("stroke-dasharray", totalLength + " " + totalLength)
.attr("stroke-dashoffset", totalLength)
.transition()
.delay(i * 300) // 👈 décalage par index
.duration(1500)
.ease(d3.easeCubic)
.attr("stroke-dashoffset", 0)
.on("end", () => {
label.style("opacity", 1); // 👈 le texte du pic apparaît après
});
/* const maxPoint = data
.filter(d => d[key] !== undefined && d[key] !== "" && !isNaN(+d[key]))
.reduce((a, b) => (+a[key] > +b[key] ? a : b)); */
labels[key] = label;
series.forEach((key, i) => {
const g = legend.append("g")
.attr("transform", `translate(0, ${i * 40})`)
.style("cursor", "pointer");
g.append("text")
.attr("x", 16)
.attr("y", 10)
.text(key)
.attr("font-size", "16px")
.attr("fill", "#000")
.attr("transform", `translate(${margin.left-180})`);
const rect = g.append("rect")
.attr("width", 20)
.attr("height", 20)
.attr("fill", color(key));
// Clic pour masquer/afficher la courbe
// const path = paths[key];
// const label = labels[key];
// const totalLength = path.node().getTotalLength();
g.on("click", () => {
const isVisible = visibleSeries.has(key);
if (isVisible) {
path.transition()
.duration(800)
.ease(d3.easeCubicInOut)
.attr("stroke-dasharray", `${totalLength} ${totalLength}`)
.attr("stroke-dashoffset", totalLength);
label.transition()
.duration(200)
.style("opacity", 0);
rect.attr("fill", "#ccc");
visibleSeries.delete(key);
} else {
path
.attr("stroke-dasharray", `${totalLength} ${totalLength}`)
.attr("stroke-dashoffset", totalLength)
.transition()
.duration(1200)
.ease(d3.easeCubicOut)
.attr("stroke-dashoffset", 0);
label.transition()
.delay(1200)
.duration(300)
.style("opacity", 1);
rect.attr("fill", color(key));
visibleSeries.add(key);
}
});
})
}
return svg.node();
};