Fork of React & D3 Vibecoding Starter
This interactive visualization recreates Charles Minard's classic 1869 flow map of Napoleon's 1812 Russian campaign, showing the diminishing size of the Grande Armée through advancing and retreating troop paths with width proportional to surviving soldiers. The React-based application embeds campaign data as constants and renders an SVG map with city markers, rivers, and temperature annotations during the retreat. D3.js v7 provides the area generator with Catmull-Rom curves for troop flows, scales, axes, and tooltip interactions, while React manages the component lifecycle and responsive dimensions through a ResizeObserver hook. The visualization also includes a chronological timeline of key campaign events.
AI-generated description// Parse the data from embedded constants
function parseData() {
// Troop data
const troopDataRaw = longitude,latitude,group,direction,survivors 24.0,55.0,1,A,340000 24.5,54.5,1,A,340000 25.5,54.5,1,A,340000 26.0,54.7,1,A,320000 27.0,54.8,1,A,300000 28.0,54.9,1,A,280000 28.5,55.0,1,A,240000 29.0,55.1,1,A,210000 30.0,55.2,1,A,180000 30.3,55.3,1,A,175000 32.0,54.8,1,A,145000 33.2,54.9,1,A,140000 34.4,55.5,1,A,127100 35.5,55.4,1,A,100000 36.0,55.5,1,A,100000 37.6,55.8,1,A,100000 37.5,55.7,1,R,100000 37.0,55.0,1,R,98000 36.8,55.0,1,R,97000 36.0,54.7,1,R,96000 35.0,54.5,1,R,87000 33.3,54.8,1,R,55000 32.0,54.6,1,R,37000 30.4,54.4,1,R,24000 29.2,54.3,1,R,20000 28.5,54.2,1,R,20000 28.3,54.3,1,R,20000 27.5,54.5,1,R,20000 26.8,54.3,1,R,12000 26.4,54.4,1,R,14000 25.0,54.4,1,R,8000 24.4,54.4,1,R,4000 24.2,54.4,1,R,4000 24.1,54.4,1,R,4000 24.0,55.0,2,A,60000 24.5,54.5,2,A,60000 26.6,55.7,2,A,40000 27.4,55.6,2,A,33000 28.7,55.5,2,A,33000 32.0,54.8,2,R,30000 33.2,54.9,2,R,30000 34.4,55.5,2,R,30000 32.0,54.8,3,A,22000 26.4,54.4,3,R,6000 25.0,54.4,3,R,6000;
// Temperature data
const temperatureDataRaw = longitude,temp,days,month,day 37.6,0,6,Oct,18 37.5,0,6,Oct,24 36.8,-9,16,Nov,9 36.0,-21,1,Nov,14 35.0,-11,10,Nov,24 33.3,-20,4,Nov,28 32.0,-24,3,Dec,1 30.4,-30,5,Dec,6 29.2,-26,1,Dec,7 27.5,-20,5,Dec,12 26.8,-27,4,Dec,16 25.0,-26,16,Dec,31;
// City data
const cityDataRaw = longitude,latitude,city,date 24.0,55.0,"Kowno","June 24" 25.5,54.5,"Wilna","June 28" 26.4,54.4,"Smorgoni","Dec 6" 28.7,55.5,"Polotzk","July 30" 29.2,54.3,"Molodexno","Dec 4" 30.4,54.4,"Minsk","Nov 26" 32.0,54.8,"Studienka","Nov 26-28" 33.2,54.9,"Witebsk","July 27" 34.4,55.5,"Smolensk","Aug 16, Nov 14" 36.0,55.5,"Dorogobouge","Aug 26, Oct 30" 37.6,55.8,"Moskowa","Sept 14, Oct 18";
// Rivers data
const riverDataRaw = 28.5,55.0,30.0,55.2,"Dvina" 32.0,54.8,33.2,54.9,"Berezina" 34.4,55.5,36.0,55.5,"Dnieper";
// Parse CSV data const troopData = d3.csvParse(troopDataRaw); const temperatureData = d3.csvParse(temperatureDataRaw); const cityData = d3.csvParse(cityDataRaw);
// Parse river data manually const riverData = riverDataRaw.trim().split('\n').map(line => { const parts = line.split(','); return { x1: +parts[0], y1: +parts[1], x2: +parts[2], y2: +parts[3], name: parts[4].replace(/"/g, '') }; });
// Convert string values to numbers and add additional data troopData.forEach(d => { d.longitude = +d.longitude; d.latitude = +d.latitude; d.group = +d.group; d.survivors = +d.survivors; // Add additional info for tooltip display d.directionDisplay = d.direction === "A" ? "Advancing" : "Retreating"; d.groupDisplay = d.group === 1 ? "Main Army" : d.group === 2 ? "Northern Flank" : "Southern Detachment"; });
temperatureData.forEach(d => {
d.longitude = +d.longitude;
d.temp = +d.temp;
d.days = +d.days;
// Create a date string for better display
d.date = ${d.month} ${d.day}, 1812;
// Add formatted temperature for display (including both Réaumur and Celsius)
d.tempC = Math.round(d.temp * 1.25);
d.tempDisplay = ${d.temp}°R (${d.tempC}°C);
});
cityData.forEach(d => { d.longitude = +d.longitude; d.latitude = +d.latitude; });
// Add chronological info to key events const events = [ { date: "June 24, 1812", description: "Napoleon's Grande Armée crosses the Neman River, beginning the invasion of Russia." }, { date: "September 14, 1812", description: "French forces occupy Moscow, but find it largely abandoned and set ablaze." }, { date: "October 19, 1812", description: "Napoleon begins the retreat from Moscow as winter approaches." }, { date: "November 26-29, 1812", description: "Crossing of the Berezina River, a major disaster for the retreating French army." }, { date: "December 14, 1812", description: "Remnants of the Grande Armée cross back over the Neman River, ending the campaign." } ];
return { troopData, temperatureData, cityData, riverData, events }; }
// Group the troop data by group and direction
function processData(troopData) {
// Split data by group and direction
return d3.group(troopData, d => ${d.group}-${d.direction});
}
// Create annotations for key events function createAnnotations(svg, xScale, yScale) { // Key events annotation const annotations = [ { x: 37.6, y: 55.8, text: "Moscow reached Sept 14", dx: 10, dy: -10 }, { x: 32.0, y: 54.6, text: "Berezina River crossing Nov 27-29", dx: 15, dy: -15 }, { x: 24.0, y: 55.0, text: "Remnants returning Dec 14", dx: -10, dy: 20 } ];
const annotationGroups = svg.selectAll(".annotation-group") .data(annotations) .enter() .append("g") .attr("class", "annotation-group");
// Add connecting lines annotationGroups.append("line") .attr("class", "annotation-line") .attr("x1", d => xScale(d.x)) .attr("y1", d => yScale(d.y)) .attr("x2", d => xScale(d.x) + d.dx * 0.7) .attr("y2", d => yScale(d.y) + d.dy * 0.7);
// Add dots at the event locations annotationGroups.append("circle") .attr("class", "annotation-dot") .attr("cx", d => xScale(d.x)) .attr("cy", d => yScale(d.y)) .attr("r", 2);
// Add the text annotationGroups.append("text") .attr("class", "annotation") .attr("x", d => xScale(d.x) + d.dx) .attr("y", d => yScale(d.y) + d.dy) .text(d => d.text); }
// Draw connecting lines between temperature points and map function drawConnectingLines(svg, tempSvg, troopData, temperatureData, xScale, yScale, tempXScale, tempYScale, height, tempChartHeight) { // Filter retreat data to match temperature points const retreatData = troopData.filter(d => d.direction === "R");
temperatureData.forEach(tempPoint => { // Find closest point on retreat path const closestPoint = retreatData.reduce((closest, current) => { const currentDist = Math.abs(current.longitude - tempPoint.longitude); const closestDist = Math.abs(closest.longitude - tempPoint.longitude); return currentDist < closestDist ? current : closest; }, retreatData[0]);
// Draw line from temp chart to map
const mapX = xScale(tempPoint.longitude);
const mapY = yScale(closestPoint.latitude);
const tempX = tempXScale(tempPoint.longitude);
const tempY = tempYScale(tempPoint.temp);
// Create connecting line in map SVG
svg.append("line")
.attr("class", "connecting-line")
.attr("x1", mapX)
.attr("y1", mapY)
.attr("x2", mapX)
.attr("y2", height - tempChartHeight);
// Create connecting line in temp SVG
tempSvg.append("line")
.attr("class", "connecting-line")
.attr("x1", tempX)
.attr("y1", 0)
.attr("x2", tempX)
.attr("y2", tempY);
}); }
// Add small dates to the temperature chart
function addDateLabels(tempSvg, temperatureData, tempXScale, tempYScale) {
tempSvg.selectAll(".date-label")
.data(temperatureData)
.enter()
.append("text")
.attr("class", "date-label")
.attr("x", d => tempXScale(d.longitude))
.attr("y", d => tempYScale(d.temp) + 15)
.attr("text-anchor", "middle")
.text(d => ${d.month} ${d.day});
}
// Create the temperature chart function createTemperatureChart(tempSvg, temperatureData, tempXScale, tempYScale, tempInnerWidth, tempInnerHeight, tempMargin, tooltip) { // Draw temperature line const tempLine = d3.line() .x(d => tempXScale(d.longitude)) .y(d => tempYScale(d.temp)) .curve(d3.curveMonotoneX);
tempSvg.append("path") .datum(temperatureData) .attr("class", "temperature-line") .attr("d", tempLine);
// Add temperature points
tempSvg.selectAll(".temp-point")
.data(temperatureData)
.enter()
.append("circle")
.attr("class", "temp-point")
.attr("cx", d => tempXScale(d.longitude))
.attr("cy", d => tempYScale(d.temp))
.attr("r", 3.5)
.attr("fill", "#d62728")
.on("mouseover", function(event, d) {
d3.select(this).attr("r", 5);
tooltip.transition()
.duration(200)
.style("opacity", 0.9);
tooltip.html(<strong>${d.date}</strong><br>Temperature: ${d.tempDisplay}<br>Location: ${d.longitude}°E)
.style("left", (event.pageX + 10) + "px")
.style("top", (event.pageY - 28) + "px");
})
.on("mouseout", function() {
d3.select(this).attr("r", 3.5);
tooltip.transition()
.duration(200)
.style("opacity", 0);
});
// Add temperature values above points
tempSvg.selectAll(".temp-label")
.data(temperatureData)
.enter()
.append("text")
.attr("class", "city-label")
.attr("x", d => tempXScale(d.longitude))
.attr("y", d => tempYScale(d.temp) - 8)
.text(d => ${d.temp}°);
// Add small date labels below points addDateLabels(tempSvg, temperatureData, tempXScale, tempYScale);
// Add temperature axis labels tempSvg.append("text") .attr("class", "axis-title") .attr("text-anchor", "middle") .attr("x", tempInnerWidth / 2) .attr("y", tempInnerHeight + tempMargin.bottom - 3) .text("Temperature during the retreat (degrees Réaumur, October-December 1812)");
// Add temperature axis
tempSvg.append("g")
.attr("class", "axis")
.attr("transform", translate(0, ${tempInnerHeight}))
.call(d3.axisBottom(tempXScale)
.ticks(10)
.tickFormat(d => ${d}°E)
.tickSize(-tempInnerHeight));
tempSvg.append("g") .attr("class", "axis") .call(d3.axisLeft(tempYScale) .ticks(5) .tickSize(-tempInnerWidth)); }
// Create army paths for troop movements function createArmyPaths(svg, data, groupKey, widthScale, xScale, yScale, tooltip) { const [group, direction] = groupKey.split("-"); const className = direction === "A" ? "advance" : "retreat";
// Create the area generator for the troop flow const areaGenerator = d3.area() .x(d => xScale(d.longitude)) .y0(d => yScale(d.latitude) - widthScale(d.survivors) / 2) .y1(d => yScale(d.latitude) + widthScale(d.survivors) / 2) .curve(d3.curveCatmullRom.alpha(0.5));
svg.append("path")
.datum(data)
.attr("class", army-path ${className})
.attr("d", areaGenerator)
.attr("stroke-width", 0.5)
.on("mouseover", function(event, d) {
d3.select(this).classed("highlight", true);
const survivors = d[0].survivors;
const pos = direction === "A" ? "Advance to Moscow" : "Retreat from Moscow";
const groupInfo = d[0].groupDisplay;
const troops = survivors === 1 ? "soldier" : "soldiers";
tooltip.transition()
.duration(200)
.style("opacity", 0.9);
tooltip.html(`<strong>${pos}</strong><br>${groupInfo}<br>${survivors.toLocaleString()} ${troops} remaining<br>Location: ${d[0].longitude.toFixed(1)}°E, ${d[0].latitude.toFixed(1)}°N`)
.style("left", (event.pageX + 10) + "px")
.style("top", (event.pageY - 28) + "px");
})
.on("mouseout", function() {
d3.select(this).classed("highlight", false);
tooltip.transition()
.duration(200)
.style("opacity", 0);
});
}
// Set dimensions used globally const width = 1100; const height = 400; const margin = { top: 20, right: 30, bottom: 20, left: 40 }; const tempChartHeight = 120; const tempMargin = { top: 5, right: 30, bottom: 25, left: 40 };
// Create main visualization function createVisualization() { const { troopData, temperatureData, cityData, riverData, events } = parseData(); const groupedTroopData = processData(troopData);
// Set up dimensions for main map const innerWidth = width - margin.left - margin.right; const innerHeight = height - margin.top - margin.bottom;
// Set up dimensions for temperature chart const tempInnerWidth = width - tempMargin.left - tempMargin.right; const tempInnerHeight = tempChartHeight - tempMargin.top - tempMargin.bottom;
// Create SVG for main map
const svg = d3.select("#visualization")
.append("svg")
.attr("width", width)
.attr("height", height)
.append("g")
.attr("transform", translate(${margin.left}, ${margin.top}));
// Create SVG for temperature chart
const tempSvg = d3.select(".temperature-chart")
.append("svg")
.attr("width", width)
.attr("height", tempChartHeight)
.append("g")
.attr("transform", translate(${tempMargin.left}, ${tempMargin.top}));
// Set up scales for main map const xScale = d3.scaleLinear() .domain([24, 38]) // Hardcode for better proportions .range([0, innerWidth]);
const yScale = d3.scaleLinear() .domain([54.2, 56]) // Hardcode for better proportions .range([innerHeight, 0]);
const widthScale = d3.scaleSqrt() // Use sqrt scale for better area perception .domain([0, d3.max(troopData, d => d.survivors)]) .range([0, 50]);
// Set up scales for temperature chart const tempXScale = d3.scaleLinear() .domain([24, 38]) // Match map scale .range([0, tempInnerWidth]);
const tempYScale = d3.scaleLinear() .domain([-35, 5]) // Extend range a bit .range([tempInnerHeight, 0]);
// Create a tooltip const tooltip = d3.select("body") .append("div") .attr("class", "tooltip") .style("opacity", 0);
// Draw rivers svg.selectAll(".city-river") .data(riverData) .enter() .append("line") .attr("class", "city-river") .attr("x1", d => xScale(d.x1)) .attr("y1", d => yScale(d.y1)) .attr("x2", d => xScale(d.x2)) .attr("y2", d => yScale(d.y2));
// Add river labels svg.selectAll(".river-label") .data(riverData) .enter() .append("text") .attr("class", "city-label") .attr("x", d => xScale((d.x1 + d.x2) / 2)) .attr("y", d => yScale((d.y1 + d.y2) / 2) - 5) .text(d => d.name);
// Draw all army paths groupedTroopData.forEach((data, key) => { createArmyPaths(svg, data, key, widthScale, xScale, yScale, tooltip); });
// Add city markers
svg.selectAll(".city-marker")
.data(cityData)
.enter()
.append("circle")
.attr("class", "city-marker")
.attr("cx", d => xScale(d.longitude))
.attr("cy", d => yScale(d.latitude))
.attr("r", 3)
.on("mouseover", function(event, d) {
d3.select(this).attr("r", 4.5);
tooltip.transition()
.duration(200)
.style("opacity", 0.9);
tooltip.html(<strong>${d.city}</strong><br>${d.date}<br>Coordinates: ${d.longitude}°E, ${d.latitude}°N)
.style("left", (event.pageX + 10) + "px")
.style("top", (event.pageY - 28) + "px");
})
.on("mouseout", function() {
d3.select(this).attr("r", 3);
tooltip.transition()
.duration(200)
.style("opacity", 0);
});
// Add city labels svg.selectAll(".city-label") .data(cityData) .enter() .append("text") .attr("class", "city-label") .attr("x", d => xScale(d.longitude)) .attr("y", d => yScale(d.latitude) - 6) .text(d => d.city);
// Create the temperature chart createTemperatureChart(tempSvg, temperatureData, tempXScale, tempYScale, tempInnerWidth, tempInnerHeight, tempMargin, tooltip);
// Add map axis
svg.append("g")
.attr("class", "axis")
.attr("transform", translate(0, ${innerHeight}))
.call(d3.axisBottom(xScale)
.ticks(10)
.tickFormat(d => ${d}°E));
svg.append("g")
.attr("class", "axis")
.call(d3.axisLeft(yScale)
.ticks(5)
.tickFormat(d => ${d}°N));
// Draw connecting lines between charts drawConnectingLines(svg, tempSvg, troopData, temperatureData, xScale, yScale, tempXScale, tempYScale, height, tempChartHeight);