Public
Edited
May 13
Insert cell
Insert cell
data = await FileAttachment("siverskodonetsk_2022.csv").csv()
Insert cell
Insert cell
Insert cell
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");

// Data preprocessing, sunrise/sunset to minutes, adjust for daylight saving time
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();

// Summer time offset between 26 March and 30 October
if (i >= 85 && i <= 301) {
sunriseMins -= 60;
sunsetMins -= 60;
}

return {
...d,
index: i,
sunriseMins,
sunsetMins
};
});

// Mapping time (in minutes) to radial distance
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();
}
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