chart = {
const margin = {top: 100, right: 100, bottom: 50, left: 100},
totalSvgWidth = 1200,
totalSvgHeight = totalSvgWidth,
width = totalSvgWidth - margin.left - margin.right,
height = totalSvgHeight - margin.top - margin.bottom,
outerRadius = Math.min(width, height) * 0.4,
innerRadiusMax = outerRadius * 0.7,
innerRadiusMin = outerRadius * 0.4;
const svg = d3.create("svg")
.attr("width", totalSvgWidth)
.attr("height", totalSvgHeight)
.style("background-color", "#000000");
svg.append("text")
.attr("x", margin.right)
.attr("y", margin.top)
.attr("text-anchor", "start")
.style("font-size", "40px")
.style("font-weight", "bold")
.attr("font-family", "Roboto Condensed")
.attr("fill", "white")
.text("Kyiv");
svg.append("text")
.attr("x", margin.right)
.attr("y", margin.top + 40)
.attr("text-anchor", "start")
.style("font-size", "40px")
.style("font-weight", "bold")
.attr("font-family", "Roboto Condensed")
.attr("fill", "white")
.text("2025");
svg.append("text")
.attr("x", margin.right)
.attr("y", margin.top + 70)
.attr("text-anchor", "start")
.style("font-size", "20px")
.attr("font-family", "Roboto Condensed")
.attr("fill", "white")
.text("Daylight hours");
// Add source
svg.append("a")
.attr("href", "https://www.sunearthtools.com/en/solar/sunrise-sunset-calendar.php#contents")
.attr("target", "_blank")
.append("text")
.attr("x", totalSvgWidth - margin.right)
.attr("y", totalSvgHeight - margin.bottom / 2)
.attr("font-family", "Roboto Condensed")
.attr("text-anchor", "end")
.style("font-size", "12px")
.style("fill", "grey")
.text("Source: SunEarthTools");
// Create a group
const g = svg.append("g").attr("transform", `translate(${totalSvgWidth/2},${totalSvgHeight/2})`)
// Create arc for months
const arc = d3.arc()
.innerRadius(outerRadius)
.outerRadius(outerRadius);
const pie = d3.pie().sort(null).value((d) => d.freq).padAngle(0.02).startAngle(0.03).endAngle(2 * Math.PI - 0.03);
const monthArcs = pie(numDays)
// Plot month names
const path = g.selectAll("path.month")
.data(monthArcs)
.join("path")
.attr("id", d => d.data.name )
.attr("class", "month" )
.attr("d", arc)
numDays.forEach(d => {
g.append("text")
.attr("dy", -20)
.append("textPath")
.attr("xlink:href", `#${d.name}`)
.style("text-anchor","left")
.style("font-size", 14)
.attr("font-family", "Roboto Condensed")
.attr("fill", "white")
.text(d.name);
})
// Create map for days angles
const monthAngles = {};
monthArcs.forEach(arc => {
const monthName = arc.data.name;
const monthRows = processedData.filter(
d => d.FullDate.toLocaleString('en-US', { month: 'long' }) === monthName
);
if (monthRows.length > 0) {
const angleScale = d3.scalePoint()
.domain(monthRows.map(d => d.FullDate))
.range([arc.startAngle + 0.02, arc.endAngle - 0.02]);
monthRows.forEach(row => {
monthAngles[row.FullDate] = angleScale(row.FullDate);
});
}
});
// Plot day labels
const labelRadius = outerRadius + 10;
g.selectAll("text.day-label")
.data(processedData)
.join("text")
.attr("class", "day-label")
.attr("font-size", 8)
.attr("font-family", "Roboto Condensed")
.attr("fill", d => d.FullDate.getDate() % 2 === 0 ? "#aaaaaa" : "white")
.attr("text-anchor", "middle")
.attr("alignment-baseline", "middle")
.style("letter-spacing", "-0.8px")
.attr("transform", d => {
const angle = monthAngles[d.FullDate];
const coordinates = calculateCoordinates(angle, labelRadius)
const rotate = (angle * 180) / Math.PI;
return `translate(${coordinates.x},${coordinates.y}) rotate(${rotate})`;
})
.text(d => d.FullDate.getDate());
// Plot the lines for each day
const [minDay, maxDay] = d3.extent(processedData, d => d.DaylightDuration);
const innerRadiusScale = d3.scaleLinear()
.domain([minDay, maxDay])
.range([innerRadiusMax, innerRadiusMin]);
g.selectAll("line.day-line")
.data(processedData)
.join("line")
.attr("class", "day-line")
.attr("stroke", "white")
.attr("stroke-width", 0.8)
.attr("x1", d => {
return calculateCoordinates(monthAngles[d.FullDate], innerRadiusScale(d.DaylightDuration)).x
})
.attr("y1", d => {
return calculateCoordinates(monthAngles[d.FullDate], innerRadiusScale(d.DaylightDuration)).y
})
.attr("x2", d => {
return calculateCoordinates(monthAngles[d.FullDate], outerRadius).x
})
.attr("y2", d => {
return calculateCoordinates(monthAngles[d.FullDate], outerRadius).y
});
// Highlight the max and min day
// Find corresponding days
const shortestDay = processedData.find(d => d.DaylightDuration === minDay);
const longestDay = processedData.find(d => d.DaylightDuration === maxDay);
const shortAngle = monthAngles[shortestDay.FullDate];
const longAngle = monthAngles[longestDay.FullDate];
const circleShift = 1
const shortCoordinates = calculateCoordinates(shortAngle, (labelRadius + circleShift))
const shortRotate = (shortAngle * 180) / Math.PI
const longCoordinates = calculateCoordinates(longAngle, (labelRadius + circleShift))
const longRotate = (longAngle * 180) / Math.PI
const rectWidth = 7;
const rectHeight = 12;
// Draw circles around them
g.append("rect")
.attr("x", -rectWidth / 2)
.attr("y", -rectHeight / 2)
.attr("width", rectWidth)
.attr("height", rectHeight)
.attr("rx", 4)
.attr("ry", 4)
.attr("fill", "none")
.attr("stroke", "#aaaaaa")
.attr("stroke-width", 0.5)
.attr("transform", `translate(${shortCoordinates.x},${shortCoordinates.y}) rotate(${shortRotate})`);
g.append("rect")
.attr("x", -rectWidth / 2)
.attr("y", -rectHeight / 2)
.attr("width", rectWidth)
.attr("height", rectHeight)
.attr("rx", 4)
.attr("ry", 4)
.attr("fill", "none")
.attr("stroke", "#aaaaaa")
.attr("stroke-width", 0.5)
.attr("transform", `translate(${longCoordinates.x},${longCoordinates.y}) rotate(${longRotate})`);
// Add max and min labels
const labelLength = 30
const shortLabelStart = calculateCoordinates(shortAngle, (labelRadius + circleShift + rectWidth))
const shortLabelEnd = calculateCoordinates(shortAngle, (labelRadius + circleShift + rectWidth + labelLength))
const longLabelStart = calculateCoordinates(longAngle, (labelRadius + circleShift + rectWidth))
const longLabelEnd = calculateCoordinates(longAngle, (labelRadius + circleShift + rectWidth + labelLength))
// Shortest day label
g.append("line")
.attr("x1", shortLabelStart.x)
.attr("y1", shortLabelStart.y)
.attr("x2", shortLabelEnd.x)
.attr("y2", shortLabelEnd.y)
.attr("stroke", "#aaaaaa")
.attr("stroke-dasharray", "3,2")
.attr("stroke-width", 0.5);
g.append("text")
.attr("x", shortLabelEnd.x)
.attr("y", shortLabelEnd.y)
.attr("dy", "-0.4em")
.attr("text-anchor", "start")
.attr("font-family", "Roboto Condensed")
.attr("font-size", 10)
.attr("fill", "white")
.attr("transform", `rotate(${shortRotate}, ${shortLabelEnd.x}, ${shortLabelEnd.y})`)
.text(formatDuration(shortestDay.DaylightDuration))
.append("tspan")
.attr("x", shortLabelEnd.x)
.attr("dy", "-1em")
.text("min")
// Longest day label
g.append("line")
.attr("x1", longLabelStart.x)
.attr("y1", longLabelStart.y)
.attr("x2", longLabelEnd.x)
.attr("y2", longLabelEnd.y)
.attr("stroke", "#aaaaaa")
.attr("stroke-dasharray", "3,2")
.attr("stroke-width", 0.5);
g.append("text")
.attr("x", longLabelEnd.x)
.attr("y", longLabelEnd.y)
.attr("dy", "-0.4em")
.attr("text-anchor", "start")
.attr("font-family", "Roboto Condensed")
.attr("font-size", 10)
.attr("fill", "white")
.attr("transform", `rotate(${longRotate}, ${longLabelEnd.x}, ${longLabelEnd.y})`)
.text(formatDuration(longestDay.DaylightDuration))
.append("tspan")
.attr("x", longLabelEnd.x)
.attr("dy", "-1em")
.text("max")
// Define arrow marker
svg.append("defs")
.append("marker")
.attr("id", "arrow")
.attr("viewBox", "0 0 10 10")
.attr("refX", 5)
.attr("refY", 5)
.attr("markerWidth", 8)
.attr("markerHeight", 8)
.attr("orient", "auto")
.append("path")
.attr("d", "M 0 0 L 10 5 L 0 10 z")
.attr("fill", "white");
// Add vertical line from the center
const firstDayRadius = innerRadiusScale(processedData[0].DaylightDuration)
const lastDayRadius = innerRadiusScale(processedData[processedData.length - 1].DaylightDuration)
const zeroDayRadius = (firstDayRadius + lastDayRadius) / 2
g.append("line")
.attr("x1", 0)
.attr("y1", 0)
.attr("x2", calculateCoordinates(0, zeroDayRadius).x)
.attr("y2", calculateCoordinates(0, zeroDayRadius).y)
.attr("stroke", "white")
.attr("stroke-dasharray", "3,2")
.attr("stroke-width", 0.5);
g.append("line")
.attr("x1", calculateCoordinates(0, zeroDayRadius).x)
.attr("y1", calculateCoordinates(0, zeroDayRadius).y)
.attr("x2", calculateCoordinates(0, outerRadius - 10).x)
.attr("y2", calculateCoordinates(0, outerRadius - 10).y)
.attr("stroke", "white")
.attr("stroke-width", 0.5)
.attr("marker-end", "url(#arrow)");
// Add text
g.append("text")
.attr("font-size", 8)
.attr("dy", "0.5em")
.attr("font-family", "Roboto Condensed")
.attr("fill", "white")
.attr("text-anchor", "middle")
.attr("alignment-baseline", "middle")
.style("letter-spacing", "-0.8px")
.attr("transform", () => {
const coordinates = calculateCoordinates(0, outerRadius)
return `translate(${coordinates.x},${coordinates.y})`;
})
.text("24:00");
g.append("text")
.attr("font-size", 8)
.attr("dy", "-1em")
.attr("font-family", "Roboto Condensed")
.attr("fill", "white")
.attr("text-anchor", "middle")
.attr("alignment-baseline", "middle")
.style("letter-spacing", "2px")
.attr("transform", () => {
const coordinates = calculateCoordinates(0, zeroDayRadius/2)
return `translate(${coordinates.x},${coordinates.y}) rotate(90)`;
})
.text("number of hours (one day)");
g.append("text")
.attr("font-size", 8)
.attr("dy", "1em")
.attr("font-family", "Roboto Condensed")
.attr("fill", "white")
.attr("text-anchor", "middle")
.attr("alignment-baseline", "middle")
.style("letter-spacing", "-0.8px")
.text("00:00");
// Highlight the center
g.append("circle")
.attr("cx", 0)
.attr("cy", 0)
.attr("r", 2)
.attr("fill", "white");
return svg.node()
}