Public
Edited
May 6
Insert cell
Insert cell
// Import the data
data = {
const fileContent = await FileAttachment("sunrise_sunset_kyiv@2.csv").text();
const parseSemicolonData = d3.dsvFormat(";").parse;
const data = parseSemicolonData(fileContent, d3.autoType);
const validData = data.filter(d => d.FullDate);
return validData
}
Insert cell
// Process the data
processedData = data.map(d => {
// 1. Parse the FullDate string (DD.MM.YYYY) into components
const dateParts = d.FullDate.split('.');
const day = parseInt(dateParts[0], 10);
const monthIndex = parseInt(dateParts[1], 10) - 1;
const year = parseInt(dateParts[2], 10);

// Create a JavaScript Date object for the specific day
const dateObject = new Date(year, monthIndex, day);

// 2. Parse the Sunrise time string (HH:MM:SS)
const sunriseParts = d.Sunrise.split(':');
const sunriseHours = parseInt(sunriseParts[0], 10);
const sunriseMinutes = parseInt(sunriseParts[1], 10);
const sunriseSeconds = parseInt(sunriseParts[2], 10);

// Create a full Date object
const sunriseDateTime = new Date(year, monthIndex, day, sunriseHours, sunriseMinutes, sunriseSeconds);

// 3. Parse the Sunset time string (HH:MM:SS)
const sunsetParts = d.Sunset.split(':');
const sunsetHours = parseInt(sunsetParts[0], 10);
const sunsetMinutes = parseInt(sunsetParts[1], 10);
const sunsetSeconds = parseInt(sunsetParts[2], 10);

// Create a full Date object
const sunsetDateTime = new Date(year, monthIndex, day, sunsetHours, sunsetMinutes, sunsetSeconds);

// 4. Calculate the Daylight Duration
const daylightDurationMs = sunsetDateTime.getTime() - sunriseDateTime.getTime();

return {
FullDate: dateObject,
DaylightDuration: daylightDurationMs,
};
});
Insert cell
// Chech number of days for each month
numDays = {
const monthlyDayCounts = {};

processedData.forEach(d => {
if (d.FullDate instanceof Date) {
const month = d.FullDate.toLocaleString('en-US', { month: 'long' }); // Get full month name
const day = d.FullDate.getDate();

if (!monthlyDayCounts[month]) {
monthlyDayCounts[month] = new Set(); // Use a Set to store unique days
}
monthlyDayCounts[month].add(day);
}
});

const numDaysArray = Object.entries(monthlyDayCounts).map(([month, daysSet]) => ({
name: month,
freq: daysSet.size
}));
return numDaysArray
}
Insert cell
html`<link href="https://fonts.googleapis.com/css2?family=Roboto+Condensed&display=swap" rel="stylesheet">`
Insert cell
function calculateCoordinates(angle, radius) {
return {
x: Math.sin(angle) * radius,
y: -Math.cos(angle) * radius
};
};
Insert cell
function formatDuration(milliseconds) {
const totalSeconds = Math.floor(milliseconds / 1000);
const hrs = Math.floor(totalSeconds / 3600);
const mins = Math.floor((totalSeconds % 3600) / 60);
const secs = totalSeconds % 60;
return `${hrs}h ${mins}m ${secs}s`;
};
Insert cell
chart = {
// Set dimensions
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;

// Create SVG
const svg = d3.create("svg")
.attr("width", totalSvgWidth)
.attr("height", totalSvgHeight)
.style("background-color", "#000000");

// Add title
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()
}
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