Public
Edited
May 15
Insert cell
Insert cell
data = FileAttachment("sunrise_sunset_data.csv").csv({typed: true})
Insert cell
data
Type Table, then Shift-Enter. Ctrl-space for more options.

Insert cell
viewof chart_27 = {
const width = 900;
const height = 900;
const maxRadius = Math.min(width, height) / 2 - 50;
const fullDaySeconds = 24 * 60 * 60;
const monthSpacing = 0.13;
const lineOffset = 0.09;

const svg = d3.select(DOM.svg(width, height))
.attr("viewBox", [-width / 1.8, -height / 1.9, width/0.9, height])
.style("background", "black");

function timeToSeconds(t) {
const [h, m, s] = t.trim().split(":").map(Number);
return h * 3600 + m * 60 + s;
}

const processed = data.map(d => {
const sunriseSec = timeToSeconds(d.sunrise);
const sunsetSec = timeToSeconds(d.sunset);
const duration = sunsetSec - sunriseSec;
const date = new Date(d.date);
const month = date.getMonth();
return { date, duration, month };
});

const daysByMonth = Array.from(d3.group(processed, d => d.date.getMonth()), ([month, days]) => ({ month, days }));
const totalDays = processed.length;
const numMonths = daysByMonth.length;

const lengthScale = d3.scaleLinear()
.domain([0, fullDaySeconds])
.range([maxRadius, 0]);

const arrowAngle = 3 * Math.PI / 2;
const startAngle = arrowAngle + 0.13;
const shiftAngle = -0.1;

const totalMonthSpacing = (numMonths - 1) * monthSpacing;
const totalLineSpacing = (totalDays - numMonths) * lineOffset;
const totalAngleForDays = 2 * Math.PI - (startAngle - arrowAngle) - totalMonthSpacing - totalLineSpacing;
const anglePerDay = totalAngleForDays / totalDays;

const arrowX = Math.cos(arrowAngle) * maxRadius;
const arrowY = Math.sin(arrowAngle) * maxRadius;

svg.append("defs").append("marker")
.attr("id", "arrow")
.attr("viewBox", "0 -5 10 10")
.attr("refX", 5)
.attr("refY", 0)
.attr("markerWidth", 5)
.attr("markerHeight", 5)
.attr("orient", "auto")
.append("path")
.attr("d", "M0,-5L10,0L0,5")
.attr("fill", "white");

svg.append("line")
.attr("x1", 0)
.attr("y1", 0)
.attr("x2", arrowX)
.attr("y2", arrowY)
.attr("stroke", "white")
.attr("stroke-width", 0.8)
.attr("stroke-dasharray", "4,2")
.attr("marker-end", "url(#arrow)");

const arrowLabelAngle = Math.atan2(arrowY, arrowX) * 180 / Math.PI;

const labelOffset = 220;
const labelX = Math.cos(arrowAngle) * labelOffset + 12;
const labelY = Math.sin(arrowAngle) * labelOffset;
svg.append("text")
.attr("x", labelX)
.attr("y", labelY)
.attr("text-anchor", "end")
.attr("fill", "white")
.style("font-size", "9.5px")
.attr("transform", `rotate(${arrowLabelAngle}, ${labelX}, ${labelY})`)
.text("кількість годин у добі (24 — одна доба)");

svg.append("circle")
.attr("cx", 0)
.attr("cy", 0)
.attr("r", 2.5)
.attr("fill", "white");
// Label "00:00" near the center dot
svg.append("text")
.attr("x", 6)
.attr("y", -6)
.attr("fill", "white")
.style("font-size", "10px")
.attr("text-anchor", "start")
.text("00:00");


const monthNames = [
"Січень", "Лютий", "Березень", "Квітень",
"Травень", "Червень", "Липень", "Серпень",
"Вересень", "Жовтень", "Листопад", "Грудень"
];

let currentAngle = startAngle - 0.1;

for (let monthIndex = 0; monthIndex < daysByMonth.length; monthIndex++) {
const { month, days } = daysByMonth[monthIndex];
const numDays = days.length;
const startMonthAngle = currentAngle - 0.21;
for (let day = 0; day < numDays; day++) {
const d = days[day];
const r = lengthScale(d.duration);
const x1 = Math.cos(currentAngle) * maxRadius;
const y1 = Math.sin(currentAngle) * maxRadius;
const x2 = Math.cos(currentAngle) * r;
const y2 = Math.sin(currentAngle) * r;
svg.append("line")
.attr("x1", x1)
.attr("y1", y1)
.attr("x2", x2)
.attr("y2", y2)
.attr("stroke", "white")
.attr("stroke-width", 1)
.attr("stroke-opacity", 0.9);
const labelRadius = maxRadius + 15;
const lx = Math.cos(currentAngle) * labelRadius;
const ly = Math.sin(currentAngle) * labelRadius;
const angleDeg = (currentAngle * 180 / Math.PI) + 90;
svg.append("text")
.attr("x", lx)
.attr("y", ly + 2)
.attr("fill", "white")
.style("font-size", "5px")
.attr("text-anchor", "middle")
.attr("dominant-baseline", "middle")
.attr("transform", `rotate(${angleDeg}, ${lx}, ${ly + 2})`)
.text(day + 1);
currentAngle += anglePerDay;
if (day < numDays - 1) currentAngle += lineOffset;
}

const endMonthAngle = currentAngle - (anglePerDay + lineOffset);
const midMonthAngle = (startMonthAngle + endMonthAngle) / 2;
const labelRadius = maxRadius + 35;
const mx = Math.cos(midMonthAngle) * labelRadius;
const my = Math.sin(midMonthAngle) * labelRadius;
const rotation = (midMonthAngle * 180 / Math.PI) - 90 + 180;

svg.append("text")
.attr("x", mx)
.attr("y", my)
.attr("fill", "white")
.style("font-size", "10px")
.attr("text-anchor", "middle")
.attr("dominant-baseline", "middle")
.attr("transform", `rotate(${rotation}, ${mx}, ${my})`)
.text(monthNames[month]);
if (monthIndex < numMonths - 1) currentAngle += monthSpacing;
}


svg.append("circle")
.attr("r", maxRadius)
.attr("stroke", "white")
.attr("stroke-opacity", 0.1)
.attr("fill", "none");

svg.append("text")
.attr("y", -maxRadius - 70)
.attr("text-anchor", "middle")
.attr("fill", "white")
.style("font-size", "18px")
.text("2024 · Київ");

svg.append("text")
.attr("x", -470)
.attr("y", height - 445)
.attr("text-anchor", "start")
.attr("fill", "white")
.style("font-size", "10px")
.text("Джерело даних: open-meteo.com або sunrise-sunset.org");

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