viewof chart = {
const width = 650;
const height = 1000;
const outerRadius = 320;
const innerRadius = 80;
const centerX = width / 2;
const centerY = height / 2;
const parseTime = d3.timeParse("%H:%M:%S");
const cleaned = data.map((d, i) => {
const sunrise = parseTime(d.Sunrise);
const sunset = parseTime(d.Sunset);
let sunriseMins = sunrise.getHours() * 60 + sunrise.getMinutes();
let sunsetMins = sunset.getHours() * 60 + sunset.getMinutes();
if (i >= 85 && i <= 301) {
sunriseMins -= 60;
sunsetMins -= 60;
}
return {
...d,
index: i,
sunriseMins,
sunsetMins
};
});
const rScale = d3.scaleLinear()
.domain([0, 24 * 60])
.range([innerRadius, outerRadius]);
// Adjusted indices with small gaps between months
let adjustedIndices = [];
let offset = 0;
let prevMonth = cleaned[0].Month;
for (let i = 0; i < cleaned.length; i++) {
const d = cleaned[i];
if (d.Month !== prevMonth) {
offset += 0.8; // visual spacing between months
prevMonth = d.Month;
}
adjustedIndices.push(i + offset);
}
const totalDays = 364;
const totalOffset = offset / 2;
const angleRangeMax = 2 * Math.PI * totalDays / (totalDays + totalOffset);
const startAngle = Math.PI - angleRangeMax / 2;
// Adjusted day indices to angles in radians
const angleScale = d3.scaleLinear()
.domain([0, d3.max(adjustedIndices)])
.range([startAngle, startAngle + angleRangeMax]);
// SVG
const svg = d3.create("svg")
.attr("viewBox", [0, 0, width, height])
.attr("width", width)
.attr("height", height)
.attr("style", "background:black");
const g = svg.append("g")
.attr("transform", `translate(${centerX},${centerY})`);
// Lines from sunrise to sunset for each day
cleaned.forEach((d, i) => {
const angle = angleScale(adjustedIndices[i]);
const r1 = rScale(d.sunriseMins);
const r2 = rScale(d.sunsetMins);
const x1 = Math.cos(angle - Math.PI / 2) * r1;
const y1 = Math.sin(angle - Math.PI / 2) * r1;
const x2 = Math.cos(angle - Math.PI / 2) * r2;
const y2 = Math.sin(angle - Math.PI / 2) * r2;
g.append("line")
.attr("x1", x1)
.attr("y1", y1)
.attr("x2", x2)
.attr("y2", y2)
.attr("stroke", "white")
.attr("stroke-width", 0.6);
});
/// Month name anchors for rotated labels
const monthAnchors = {};
// Day labels near each line
cleaned.forEach((d, i) => {
const angle = angleScale(adjustedIndices[i]);
const r = rScale(d.sunsetMins) + 5;
const angleDeg = (angle * 180 / Math.PI);
const x = Math.cos(angle - Math.PI / 2) * r;
const y = Math.sin(angle - Math.PI / 2) * r;
// Save the position of the first day of each month for labeling
if (!monthAnchors[d.Month]) {
monthAnchors[d.Month] = {angle, angleDeg};
}
g.append("text")
.attr("x", x)
.attr("y", y)
.text(+d.Date.split(" ")[0])
.attr("fill", "white")
.attr("font-size", "4px")
.attr("text-anchor", "middle")
.attr("alignment-baseline", "middle")
.attr("transform", `rotate(${angleDeg},${x},${y})`);
});
// Month labels above the first day of each month
Object.entries(monthAnchors).forEach(([month, { angle, angleDeg }]) => {
const labelRadius = rScale(cleaned.find(d => d.Month === month).sunsetMins) + 15;
const x = Math.cos(angle - Math.PI / 2) * labelRadius;
const y = Math.sin(angle - Math.PI / 2) * labelRadius;
const adjustedAngleDeg = angleDeg + (angleDeg > 180 ? 7 : 0);
// Dashed vertical line from center to top
g.append("text")
.attr("x", x)
.attr("y", y)
.text(month)
.attr("fill", "white")
.attr("font-size", "7px")
.attr("font-family", "IBM Plex Mono")
.attr("text-anchor", "start")
.attr("alignment-baseline", "middle")
.attr("transform", `rotate(${adjustedAngleDeg},${x},${y})`);
});
// Arrow marker definition for pointing the top
g.append("line")
.attr("x1", 0)
.attr("y1", 0)
.attr("x2", 0)
.attr("y2", -(innerRadius*2))
.attr("stroke", "white")
.attr("stroke-width", 0.7)
.attr("stroke-dasharray", "4 4");
svg.append("defs")
.append("marker")
.attr("id", "arrowhead")
.attr("viewBox", "0 0 10 10")
.attr("refX", 5)
.attr("refY", 5)
.attr("markerWidth", 5)
.attr("markerHeight", 5)
.attr("orient", "auto")
.append("path")
.attr("d", "M 0 0 L 10 5 L 0 10 z")
.attr("fill", "white");
g.append("line")
.attr("x1", 0)
.attr("y1", -(innerRadius * 2))
.attr("x2", 0)
.attr("y2", -(innerRadius * 3))
.attr("stroke", "white")
.attr("stroke-width", 0.7)
.attr("marker-end", "url(#arrowhead)");
// Time labels
g.append("text")
.attr("x", 0)
.attr("y", 8)
.attr("text-anchor", "middle")
.attr("fill", "white")
.attr("font-size", "4px")
.text("00:00");
g.append("text")
.attr("x", 0)
.attr("y", -(innerRadius * 3) - 8)
.attr("text-anchor", "middle")
.attr("fill", "white")
.attr("font-size", "4px")
.text("24:00");
// Vertical label
g.append("text")
.attr("x", 14)
.attr("y", -innerRadius)
.attr("text-anchor", "middle")
.attr("fill", "white")
.attr("font-size", "5px")
.attr("font-family", "IBM Plex Mono")
.attr("transform", `rotate(-270, 7, ${-innerRadius})`)
.text("Number of hours (one day)");
// Top-left label group: year, subtitle, line, city name
const marginLeft = 20;
const marginTop = 100;
const labelGroup = svg.append("g")
.attr("transform", `translate(${marginLeft}, ${marginTop})`);
labelGroup.append("text")
.text("2022")
.attr("fill", "white")
.attr("font-size", "70px")
.attr("font-family", "Inter")
.attr("text-anchor", "start");
labelGroup.append("text")
.text("Duration of daylight")
.attr("dy", "1.2em")
.attr("fill", "white")
.attr("font-size", "18px")
.attr("font-family", "Inter")
.attr("text-anchor", "start");
labelGroup.append("line")
.attr("x1", 0)
.attr("x2", 170)
.attr("y1", 40)
.attr("y2", 40)
.attr("stroke", "white")
.attr("stroke-width", 0.7);
labelGroup.append("text")
.text("Sievierodonetsk")
.attr("y", 55)
.attr("fill", "white")
.attr("font-size", "11px")
.attr("font-family", "IBM Plex Mono")
.attr("text-anchor", "start");
// Bottom-right caption
svg.append("text")
.attr("x", width - 20)
.attr("y", height - 40)
.attr("fill", "white")
.attr("font-size", "11px")
.attr("font-family", "IBM Plex Mono")
.attr("text-anchor", "end")
.text("A city where you’ll still surely meet both sunrise and sunset, my friend.");
// White rectangle for data source caption (same width, fixed height)
svg.append("rect")
.attr("x", 0)
.attr("y", height - 25)
.attr("width", width)
.attr("height", 25)
.attr("fill", "white");
// Caption text on top of white box
svg.append("text")
.attr("x", width - 10)
.attr("y", height - 9)
.attr("text-anchor", "end")
.attr("fill", "black")
.attr("font-size", "9px")
.attr("font-family", "IBM Plex Mono")
.text("Data Source: https://www.sunearthtools.com/en/solar/sunrise-sunset-calendar.php");
return svg.node();
}