Public
Edited
May 21
Insert cell
Insert cell
url = "https://docs.google.com/spreadsheets/d/1F1DWsOR4s_St31g_qpzG2PYyGqmXgKcANaiXVX8cfQ4/edit?gid=818095728#gid=818095728"
Insert cell
//data = d3.csv(getCsvUrl(url),d3.autoType);
// d3.ascending
Insert cell
import { Inputs } from "@observablehq/inputs"

Insert cell
mutable trigger = 0

Insert cell
chartContainer = {
const container = html`<div></div>`;
return container;
}

Insert cell
// refresh;
// trigger++;
Insert cell
/* viewof refresh = Generators.input(html`<button>🔁 Rafraîchir</button>`, {
reduce: () => {
mutable trigger = mutable trigger + 1;
}
})
*/
Insert cell
/* html`<div style="margin-top:40px">
<button style="margin-bottom:10px;">Recharger les données</button>
${viewof refresh}
</div>` */
Insert cell
// refresh = Generators.interval(60000)

Insert cell
data = {
await relancer;
const url = "https://docs.google.com/spreadsheets/d/e/2PACX-1vQDbKGWJ0puFH5G7A-omn3esOm-Cu_Numt7ctTIAo9gFMfHFwr9IPfdUXsmu9Muu7MGgxBl8GDbzeUh/pub?gid=818095728&single=true&output=csv";
const res = await fetch(url);
const text = await res.text();
return d3.csvParse(text); // ✅ ça marchera si l'en-tête est bon
}



Insert cell
// html`<button style="margin-top:30px; text-align:left;">${viewof relancer}</button>`

Insert cell
viewof relancer = html`<button id="relancer" style="font-size:16px;padding:6px 12px;margin-top:30px;">🔁 Relancer l’animation</button>`

Insert cell


chart = {


// 🔁 force la réévaluation à chaque clic
await relancer;

// 🔁 Supprime tous les anciens SVG du DOM
d3.selectAll("svg.chart-anim").remove();

const _ = trigger; // 🔁 force la réévaluation

const dataset = data;
// nettoyage automatique au clic suivant
/* invalidate(() => {
d3.select("svg").remove(); // nettoyage si tu veux
}); */

// const _ = mutable trigger; // 👈 force la relance à chaque clic


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();
};
Insert cell
chartRenderer = {
function renderChart() {
chartContainer.innerHTML = ""; // 🧹 vide l'ancien

const svg = d3.create("svg")
.attr("width", 640)
.attr("height", 400);

const x = d3.scaleLinear().domain([0, 10]).range([40, 600]);
const y = d3.scaleLinear().domain([0, 10]).range([350, 20]);

const data = d3.range(11).map(i => ({ x: i, y: Math.random() * 10 }));

const line = d3.line()
.x(d => x(d.x))
.y(d => y(d.y));

const path = svg.append("path")
.datum(data)
.attr("fill", "none")
.attr("stroke", "steelblue")
.attr("stroke-width", 2)
.attr("d", line);

const totalLength = path.node().getTotalLength();

path
.attr("stroke-dasharray", `${totalLength} ${totalLength}`)
.attr("stroke-dashoffset", totalLength)
.transition()
.duration(1500)
.ease(d3.easeCubic)
.attr("stroke-dashoffset", 0);

chartContainer.appendChild(svg.node());
}

// 🔁 Attache le clic une fois, ici
viewof relancer.onclick = renderChart;

// 🟢 Affiche la première fois au chargement
renderChart();

return renderChart;
}

Insert cell
// viewof refresh = Inputs.button({ label: "🔁 Rafraîchir les données" })
Insert cell
//
Insert cell
// viewof refresh = Inputs.button({ label: "🔁 Rafraîchir les données" })
Insert cell
// refresh
Insert cell
// viewof refresh
Insert cell
getCsvUrl = url => {
url = new URL(url);
const id = url.pathname.split("/")[3]
const gid = new URLSearchParams(url.hash.slice(1)).get("gid") || 0;
return `https://docs.google.com/spreadsheets/d/${id}/export?format=csv&gid=${gid}`
}

Insert cell
Insert cell

Purpose-built for displays of data

Observable is your go-to platform for exploring data and creating expressive data visualizations. Use reactive JavaScript notebooks for prototyping and a collaborative canvas for visual data exploration and dashboard creation.
Learn more