Public
Edited
May 15
Insert cell
Insert cell
viewof chart = {
const width = 800;
const height = 900;
const outerRadius = width / 2 - 60;
const minInnerRadius = 0;

// Import and preprocess data:)
const svg = d3.create("svg")
.attr("viewBox", [0, 0, width, height])
.style("background", "black");

const g = svg.append("g")
.attr("transform", `translate(${width / 2}, ${height / 2+20})`);

const parseDate = d3.timeParse("%Y-%m-%d");
const parseTime = d3.timeParse("%H:%M:%S");

const rawData = await FileAttachment("sunrise_sunset_data.csv").csv();

const data = rawData.map(d => {
const date = parseDate(d.date);
const sunrise = parseTime(d.sunrise);
const sunset = parseTime(d.sunset);
if (!date || !sunrise || !sunset) return null;
const duration = (sunset - sunrise) / (1000 * 60 * 60); // in hours
return { date, duration };
}).filter(d => d !== null);

const groupedByMonth = d3.groups(data, d => d.date.getMonth());

const maxDuration = 7;
const minDuration = 15;

const scaleRadius = d3.scaleLinear()
.domain([0, 24])
.range([outerRadius, minInnerRadius]);

const totalDays = data.length;
const totalMonths = 12;
const gapPerMonth = 0.04;
const totalGapSpace = gapPerMonth * totalMonths;
const fullCircle = 2 * Math.PI;
const usableCircle = fullCircle - totalGapSpace;

let currentAngle = -Math.PI / 2;
let lastMonth = -1;

// Main draw loop
groupedByMonth.forEach(([month, days]) => {
const daysCount = days.length;
const monthAngleSpan = (daysCount / totalDays) * usableCircle;
const startAngle = currentAngle + gapPerMonth / 1.5;
days.forEach((d, i) => {
const dayOfYear = data.indexOf(d);
const angle = startAngle + (i / daysCount) * monthAngleSpan;
const safeDuration = Math.min(24, Math.max(0, d.duration));
const innerRadius = scaleRadius(safeDuration);
const x1 = Math.cos(angle) * innerRadius;
const y1 = Math.sin(angle) * innerRadius;
const x2 = Math.cos(angle) * outerRadius;
const y2 = Math.sin(angle) * outerRadius;
g.append("line")
.attr("x1", x1)
.attr("y1", y1)
.attr("x2", x2)
.attr("y2", y2)
.attr("stroke", "white")
.attr("stroke-width", 1)
.attr("opacity", 1);

const textRadius = outerRadius + 6;

// Month indexes
g.append("text")
.attr("x", Math.cos(angle) * textRadius)
.attr("y", Math.sin(angle) * textRadius)
.attr("fill", "white")
.attr("font-size", 4)
.attr("text-anchor", "middle")
.attr("alignment-baseline", "middle")
.attr("transform", `rotate(${angle * 180 / Math.PI + 90} , ${Math.cos(angle) * textRadius}, ${Math.sin(angle) * textRadius})`)
.text(i + 1);

const weekdays = ["Нд", "Пн", "Вт", "Ср", "Чт", "Пт", "Сб"];

const weekdayRadius = textRadius + 6;

const weekdayIndex = d.date.getDay();
const isWeekend = weekdayIndex === 0 || weekdayIndex === 6;
const weekdayText = weekdays[weekdayIndex];

// Week names
if (isWeekend) {
g.append("circle")
.attr("cx", Math.cos(angle) * weekdayRadius)
.attr("cy", Math.sin(angle) * weekdayRadius)
.attr("r", 2.3)
.attr("fill", "white")
.attr("opacity", 0.8);
g.append("text")
.attr("x", Math.cos(angle) * weekdayRadius)
.attr("y", Math.sin(angle) * weekdayRadius)
.attr("font-size", 3.3)
.attr("text-anchor", "middle")
.attr("alignment-baseline", "middle")
.attr("transform", `rotate(${angle * 180 / Math.PI + 90}, ${Math.cos(angle) * weekdayRadius}, ${Math.sin(angle) * weekdayRadius})`)
.attr("fill", "black")
.text(weekdayText);
} else {
g.append("text")
.attr("x", Math.cos(angle) * weekdayRadius)
.attr("y", Math.sin(angle) * weekdayRadius)
.attr("fill", "white")
.attr("font-size", 3.3)
.attr("text-anchor", "middle")
.attr("alignment-baseline", "middle")
.attr("transform", `rotate(${angle * 180 / Math.PI + 90}, ${Math.cos(angle) * weekdayRadius}, ${Math.sin(angle) * weekdayRadius})`)
.text(weekdayText);
}

// Month names
const monthNames = ["Січень", "Лютий", "Березень", "Квітень", "Травень", "Червень",
"Липень", "Серпень", "Вересень", "Жовтень", "Листопад", "Грудень"];
const currentMonth = d.date.getMonth();
if (currentMonth !== lastMonth) {
lastMonth = currentMonth;
const monthRadius = weekdayRadius + 12;
const mx = Math.cos(angle) * monthRadius;
const my = Math.sin(angle) * monthRadius;
g.append("text")
.attr("x", mx)
.attr("y", my)
.attr("font-size", 6)
.attr("text-anchor", "left")
.attr("alignment-baseline", "middle")
.attr("transform", `rotate(${angle * 180 / Math.PI+90+2}, ${mx}, ${my})`)
.style("fill", "white")
.text(monthNames[currentMonth]);
}
});
currentAngle += monthAngleSpan + gapPerMonth;
});



// Minimum light day duration line
const minDay = d3.min(data, d => d.duration);
const minDayData = data.find(d => d.duration === minDay);
const dayIndex = data.indexOf(minDayData);
const totalDaysInYear = data.length;
const totalMonthGaps = gapPerMonth * totalMonths;
const usableAngle = 2 * Math.PI - totalMonthGaps;
const anglePerDay = usableAngle / totalDaysInYear;
const preGapOffset = gapPerMonth / 2;
let runningAngle = -Math.PI / 2;
let found = false;
for (const [month, days] of groupedByMonth) {
const angleSpan = days.length * anglePerDay;
for (let i = 0; i < days.length; i++) {
if (data.indexOf(days[i]) === dayIndex) {
runningAngle += i * anglePerDay + preGapOffset;
found = true;
break;
}
}
if (found) break;
runningAngle += angleSpan + gapPerMonth;
}
const lineLength = 30;
const totalMinutes = Math.round(minDay * 60);
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
const seconds = Math.round((minDay - hours - minutes / 60) * 3600 % 60);
const label = `${hours}г ${minutes}х ${seconds}с`;
const baseRadius = outerRadius + 20;
const x11 = Math.cos(runningAngle) * baseRadius;
const y11 = Math.sin(runningAngle) * baseRadius;
const x2 = Math.cos(runningAngle) * (baseRadius + lineLength);
const y2 = Math.sin(runningAngle) * (baseRadius + lineLength);
g.append("line")
.attr("x1", x11)
.attr("y1", y11)
.attr("x2", x2)
.attr("y2", y2)
.attr("stroke", "white")
.attr("stroke-width", 1.2)
.attr("stroke-dasharray", "2,2");;
const textOffset = 4;
const tx = Math.cos(runningAngle) * (baseRadius + lineLength + textOffset);
const ty = Math.sin(runningAngle) * (baseRadius + lineLength + textOffset);

g.append("text")
.attr("x", tx)
.attr("y", ty)
.attr("text-anchor", "left")
.attr("font-size", 5)
.attr("fill", "white")
.attr("transform", `rotate(${runningAngle * 180 / Math.PI + 90}, ${tx}, ${ty})`)
.text(label);

const tx2 = Math.cos(runningAngle) * (baseRadius + lineLength + textOffset + 5);
const ty2 = Math.sin(runningAngle) * (baseRadius + lineLength + textOffset + 5);
g.append("text")
.attr("x", tx2)
.attr("y", ty2)
.attr("text-anchor", "left")
.attr("font-size", 5)
.attr("fill", "white")
.attr("transform", `rotate(${runningAngle * 180 / Math.PI + 90}, ${tx}, ${ty})`)
.text("min");

// The vertical reference line for 00:00 to 24:00 :D
const refAngle = -Math.PI / 2;
const centerX = 0;
const centerY = 0;
const startRadius = 0;
const endRadius = outerRadius - 10;
const x0 = Math.cos(refAngle) * startRadius;
const y0 = Math.sin(refAngle) * startRadius;
const x1 = Math.cos(refAngle) * endRadius;
const y1 = Math.sin(refAngle) * endRadius;
g.append("line")
.attr("x1", x0)
.attr("y1", y0)
.attr("x2", x1)
.attr("y2", y1)
.attr("stroke", "white")
.attr("stroke-width", 1.5)
.attr("stroke-dasharray", "2,2");
g.append("circle")
.attr("cx", x0)
.attr("cy", y0)
.attr("r", 2)
.attr("fill", "white");
g.append("circle")
.attr("cx", x1)
.attr("cy", y1)
.attr("r", 2)
.attr("fill", "white");
g.append("text")
.attr("x", x1)
.attr("y", y1 - 6)
.attr("text-anchor", "middle")
.attr("font-size", 5)
.attr("fill", "white")
.text("24:00");
g.append("text")
.attr("x", x0)
.attr("y", y0 + 8)
.attr("text-anchor", "middle")
.attr("font-size", 5)
.attr("fill", "white")
.text("00:00");

// Add rotated label along the vertical reference line:0
const midRadius = outerRadius / 3;
const offset = 8;
const labelX = Math.cos(refAngle) * midRadius - Math.sin(refAngle) * offset;
const labelY = Math.sin(refAngle) * midRadius + Math.cos(refAngle) * offset;
g.append("text")
.attr("x", labelX)
.attr("y", labelY)
.attr("text-anchor", "middle")
.attr("alignment-baseline", "middle")
.attr("font-size", 6)
.attr("fill", "white")
.attr("transform", `rotate(${-refAngle * 180 / Math.PI}, ${labelX}, ${labelY})`)
.text("кількість годин (одна доба)");

svg.append("text")
.attr("x", 40)
.attr("y", 12)
.attr("fill", "white")
.attr("font-size", 6)
.attr("opacity", 0.8)
.text("by Nadja Kelm, adapted by Ostap Trush");


// The plot title
const labelGroup = svg.append("g")
.attr("transform", `translate(40, 80)`);

labelGroup.append("text")
.attr("x", 0)
.attr("y", 0)
.attr("fill", "white")
.attr("font-size", 40)
.text("2025");
labelGroup.append("text")
.attr("x", 0)
.attr("y", 22)
.attr("fill", "white")
.attr("font-size", 7)
.text("тривалість світлового дня, Мангеттен");

labelGroup.append("line")
.attr("x1", 0)
.attr("y1", 32)
.attr("x2", 150)
.attr("y2", 32)
.attr("stroke", "white")
.attr("stroke-width", 1);

svg.append("text")
.attr("x", width / 2)
.attr("y", height - 10)
.attr("text-anchor", "middle")
.attr("font-size", 10)
.attr("fill", "white")
.text("дані: 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