chart = {
const config = {
width: 928,
height: 1128,
backgroundColor: "black",
fontFamily: "Arial, sans-serif",
defaultTextColor: "white",
radiusRatios: {
daylightLines: {
referenceInner: 0.10,
referenceOuter: 0.90,
dynamicStartMin: 0.2,
dynamicStartMax: 0.55,
},
dayNumberLabel: 0.9,
dayOfWeekLabel: 0.92,
monthLabel: 0.94,
annotationText: 1.04,
},
fontSizes: {
dayNumber: "5px",
dayWeek: "3px",
monthName: "5px",
annotation: "8px",
centralScaleTick: "5px",
centralScaleAxisTitle: "5px",
mainTitle: "48px",
subTitle: "14px",
credit: "10px",
},
colors: {
lineStroke: "white",
alternatingGray: "#B0B0B0",
weekendCircleFill: "white",
weekendText: "black",
arrowheadFill: "white",
},
angles: {
startOffsetRad: -Math.PI / 2,
monthGapEquivalentDays: 2,
monthGapSpecialFactor: 2,
},
spacings: {
centerNudgePx: 10,
centralScaleLabelOffsetPx: 5,
centralScaleLineLabelGapPx: 5,
centralAxisTitleXOffsetPx: 10,
centralAxisTitleRadialOffsetPx: -70,
annotationConnectorLengthReductionPx: 10,
titleAreaPadding: { top: 20, left: 20 },
creditYOffset: 10,
mainTitleYOffset: 60,
subTitleYOffset: 85,
subTitleLineYOffset: 90,
subTitleLineWidth: 255,
},
elements: {
weekendCircleRadius: 2.5,
daylightLineWidth: 1,
daylightLineMargin: 5,
centralScaleLineWidth: 0.7,
centralScaleDashArray: "2,2",
annotationLineWidth: 0.5,
annotationDashArray: "2,2",
centralDotRadius: "2",
arrowheadMarker: {
id: "arrowhead",
viewBox: "0 -5 10 10",
refX: 9,
refY: 0,
markerWidth: 5,
markerHeight: 5,
path: "M0,-5L10,0L0,5",
},
titleUnderlineStrokeWidth: 0.5,
},
};
const data = await d3.csv(await FileAttachment("sunset-sunrise_2025_cape-roca.csv").url(), (d, i) => {
const parseDuration = (durationStr) => {
if (!durationStr) return 0;
const parts = durationStr.split(':').map(Number);
return parts[0] * 3600 + parts[1] * 60 + parts[2];
};
return {
dayOfYearSeq: +d[""] || (i + 1),
dayNumberInMonth: +d.DayNumber,
dayWeek: d.DayWeek,
month: d.Month,
durationSeconds: parseDuration(d.Duration),
idx: i,
};
});
const svg = d3.create("svg")
.attr("width", config.width)
.attr("height", config.height)
.attr("viewBox", [-config.width / 2, -config.height / 2, config.width, config.height])
.style("background-color", config.backgroundColor)
.style("font-family", config.fontFamily)
.style("color", config.defaultTextColor);
const chartArea = svg.append("g").attr("class", "chart-render-area");
const overallRadius = Math.min(config.width, config.height) / 2;
const centerX = 0;
const centerY = 0 + 60;
const absoluteRadii = {
daylightLines: {
referenceInner: config.radiusRatios.daylightLines.referenceInner * overallRadius,
referenceOuter: config.radiusRatios.daylightLines.referenceOuter * overallRadius,
dynamicStartMin: config.radiusRatios.daylightLines.dynamicStartMin * overallRadius,
dynamicStartMax: config.radiusRatios.daylightLines.dynamicStartMax * overallRadius,
},
dayNumberLabel: config.radiusRatios.dayNumberLabel * overallRadius,
dayOfWeekLabel: config.radiusRatios.dayOfWeekLabel * overallRadius,
monthLabel: config.radiusRatios.monthLabel * overallRadius,
annotationText: config.radiusRatios.annotationText * overallRadius,
};
const maxDataDuration = d3.max(data, d => d.durationSeconds);
const minDataDuration = d3.min(data, d => d.durationSeconds);
const lengthScale = d3.scaleLinear()
.domain([0, 24 * 3600])
.range([0, absoluteRadii.daylightLines.referenceOuter - absoluteRadii.daylightLines.referenceInner]);
const inverseStartRadiusScale = d3.scaleLinear()
.domain([minDataDuration, maxDataDuration])
.range([absoluteRadii.daylightLines.dynamicStartMax, absoluteRadii.daylightLines.dynamicStartMin]);
data.forEach(d => {
d.dynamicStartRadius = inverseStartRadiusScale(d.durationSeconds);
d.lineLength = lengthScale(d.durationSeconds);
});
const uniqueMonthsInOrder = [];
const seenMonths = new Set();
data.forEach(d => {
if (!seenMonths.has(d.month)) {
uniqueMonthsInOrder.push(d.month);
seenMonths.add(d.month);
}
});
const numberOfMonths = uniqueMonthsInOrder.length;
const totalDays = data.length;
const baseAnglePerDay = (2 * Math.PI) / totalDays;
const monthGapAngle = baseAnglePerDay * config.angles.monthGapEquivalentDays;
const decToJanGapAngle = monthGapAngle * config.angles.monthGapSpecialFactor;
const totalNormalMonthGaps = (numberOfMonths - 1) * monthGapAngle;
const totalGapAngle = totalNormalMonthGaps + decToJanGapAngle;
const totalAngleForDays = (2 * Math.PI) - totalGapAngle;
const anglePerDayWithGaps = totalAngleForDays / totalDays;
let currentGlobalRad = config.angles.startOffsetRad + decToJanGapAngle;
currentGlobalRad -= monthGapAngle;
data.forEach((d, i) => {
if (i > 0 && data[i - 1].month !== d.month) {
const isDecToJan = (data[i - 1].month === "Dec" && d.month === "Jan");
currentGlobalRad += isDecToJan ? decToJanGapAngle : monthGapAngle;
}
d.angleRad = currentGlobalRad + anglePerDayWithGaps / 2;
currentGlobalRad += anglePerDayWithGaps;
});
const ukrainianMonths = {
"Jan": "Січень", "Feb": "Лютий", "Mar": "Березень", "Apr": "Квітень",
"May": "Травень", "Jun": "Червень", "Jul": "Липень", "Aug": "Серпень",
"Sep": "Вересень", "Oct": "Жовтень", "Nov": "Листопад", "Dec": "Грудень"
};
const ukrainianDaysAbbrev = {
"Mon": "ПН", "Tue": "ВТ", "Wed": "СР", "Thu": "ЧТ",
"Fri": "ПТ", "Sat": "СБ", "Sun": "НД"
};
function formatDurationUkrainian(totalSeconds) {
const h = Math.floor(totalSeconds / 3600);
const m = Math.floor((totalSeconds % 3600) / 60);
const s = totalSeconds % 60;
return `${h}г ${m}хв ${s}с`;
}
const ah = config.elements.arrowheadMarker;
svg.append("defs").append("marker")
.attr("id", ah.id)
.attr("viewBox", ah.viewBox)
.attr("refX", ah.refX)
.attr("refY", ah.refY)
.attr("markerWidth", ah.markerWidth)
.attr("markerHeight", ah.markerHeight)
.attr("orient", "auto")
.append("path")
.attr("d", ah.path)
.attr("fill", config.colors.arrowheadFill);
const daylightLineEndRadius = absoluteRadii.dayNumberLabel - config.elements.daylightLineMargin;
chartArea.selectAll(".daylight-line")
.data(data)
.join("line")
.attr("class", "daylight-line")
.attr("x1", d => centerX + d.dynamicStartRadius * Math.cos(d.angleRad))
.attr("y1", d => centerY + d.dynamicStartRadius * Math.sin(d.angleRad))
.attr("x2", d => centerX + daylightLineEndRadius * Math.cos(d.angleRad))
.attr("y2", d => centerY + daylightLineEndRadius * Math.sin(d.angleRad))
.attr("stroke", config.colors.lineStroke)
.attr("stroke-width", config.elements.daylightLineWidth);
chartArea.selectAll(".day-number-label")
.data(data)
.join("text")
.attr("class", "day-number-label")
.each(function (d) {
const x = centerX + absoluteRadii.dayNumberLabel * Math.cos(d.angleRad);
const y = centerY + absoluteRadii.dayNumberLabel * Math.sin(d.angleRad);
const angleDeg = (d.angleRad * 180 / Math.PI) + 90;
d3.select(this).attr("transform", `translate(${x}, ${y}) rotate(${angleDeg})`);
})
.attr("text-anchor", "middle")
.attr("dominant-baseline", "middle")
.style("font-size", config.fontSizes.dayNumber)
.attr("fill", (d, i) => i % 2 === 1 ? config.colors.alternatingGray : config.defaultTextColor)
.text(d => d.dayNumberInMonth);
const dayOfWeekGroup = chartArea.selectAll(".day-week-element")
.data(data)
.join("g")
.attr("class", "day-week-element")
.attr("transform", d => {
const x = centerX + absoluteRadii.dayOfWeekLabel * Math.cos(d.angleRad);
const y = centerY + absoluteRadii.dayOfWeekLabel * Math.sin(d.angleRad);
const rotationAngleDeg = (d.angleRad * 180 / Math.PI) + 90;
return `translate(${x}, ${y}) rotate(${rotationAngleDeg})`;
});
dayOfWeekGroup.each(function (d) {
const group = d3.select(this);
const isAlternatingColor = d.idx % 2 === 1;
const currentFillColor = isAlternatingColor ? config.colors.alternatingGray : config.defaultTextColor;
if (d.dayWeek === "Sat" || d.dayWeek === "Sun") {
group.append("circle")
.attr("r", config.elements.weekendCircleRadius)
.attr("fill", isAlternatingColor ? config.colors.alternatingGray : config.colors.weekendCircleFill)
.attr("cx", 0).attr("cy", 0);
group.append("text")
.attr("text-anchor", "middle").attr("dominant-baseline", "middle")
.style("font-size", config.fontSizes.dayWeek)
.attr("fill", config.colors.weekendText)
.text(ukrainianDaysAbbrev[d.dayWeek]);
} else {
group.append("text")
.attr("text-anchor", "middle").attr("dominant-baseline", "middle")
.style("font-size", config.fontSizes.dayWeek)
.attr("fill", currentFillColor)
.text(ukrainianDaysAbbrev[d.dayWeek]);
}
});
const firstDayOfEachMonth = [];
let currentMonthForLabel = null;
data.forEach((d) => {
if (d.month !== currentMonthForLabel) {
firstDayOfEachMonth.push(d);
currentMonthForLabel = d.month;
}
});
chartArea.selectAll(".month-label")
.data(firstDayOfEachMonth)
.join("text")
.attr("class", "month-label")
.each(function (d) {
const angleRad = d.angleRad + baseAnglePerDay / 2 + 0.01;
const x = centerX + absoluteRadii.monthLabel * Math.cos(angleRad);
const y = centerY + absoluteRadii.monthLabel * Math.sin(angleRad);
let angleDeg = (angleRad * 180 / Math.PI) + 90;
let textAnchor = "middle";
d3.select(this)
.attr("x", x)
.attr("y", y)
.attr("transform", `rotate(${angleDeg}, ${x}, ${y})`)
.attr("text-anchor", textAnchor);
})
.attr("dominant-baseline", "middle")
.style("font-size", config.fontSizes.monthName)
.attr("letter-spacing", "1px")
.attr("fill", config.defaultTextColor)
.text(d => ukrainianMonths[d.month]);
const centralScaleGroup = chartArea.append("g").attr("class", "central-scale");
const centralScaleLineEndY = centerY - absoluteRadii.daylightLines.referenceOuter + config.spacings.centralScaleLineLabelGapPx;
centralScaleGroup.append("line")
.attr("x1", centerX).attr("y1", centerY)
.attr("x2", centerX).attr("y2", centralScaleLineEndY)
.attr("stroke", config.defaultTextColor)
.attr("stroke-width", config.elements.centralScaleLineWidth)
.attr("stroke-dasharray", config.elements.centralScaleDashArray)
.attr("marker-end", `url(#${config.elements.arrowheadMarker.id})`);
centralScaleGroup.append("text")
.attr("x", centerX).attr("y", centerY + config.spacings.centerNudgePx)
.attr("text-anchor", "middle")
.style("font-size", config.fontSizes.centralScaleTick)
.attr("fill", config.defaultTextColor)
.text("00:00");
centralScaleGroup.append("text")
.attr("x", centerX)
.attr("y", centralScaleLineEndY - config.spacings.centralScaleLabelOffsetPx)
.attr("text-anchor", "middle")
.style("font-size", config.fontSizes.centralScaleTick)
.attr("fill", config.defaultTextColor)
.text("24:00");
centralScaleGroup.append("circle")
.attr("cx", centerX)
.attr("cy", centerY)
.attr("r", config.elements.centralDotRadius)
.attr("fill", config.defaultTextColor);
const centralLabelX = centerX + config.spacings.centralAxisTitleXOffsetPx;
const centralLabelY = centerY - absoluteRadii.daylightLines.referenceInner + config.spacings.centralAxisTitleRadialOffsetPx;
centralScaleGroup.append("text")
.attr("x", centralLabelX).attr("y", centralLabelY)
.attr("transform", `rotate(90, ${centralLabelX}, ${centralLabelY})`)
.attr("text-anchor", "middle").attr("dominant-baseline", "middle")
.attr("letter-spacing", "2px")
.style("font-size", config.fontSizes.centralScaleAxisTitle)
.attr("fill", config.defaultTextColor)
.text("кількість годин (одна доба)");
const minDurationDay = data.reduce((min, d) => d.durationSeconds < min.durationSeconds ? d : min, data[0]);
const maxDurationDay = data.reduce((max, d) => d.durationSeconds > max.durationSeconds ? d : max, data[0]);
const annotationData = [
{ day: minDurationDay, type: "min", labelLine1: "min", labelLine2: formatDurationUkrainian(minDurationDay.durationSeconds) },
{ day: maxDurationDay, type: "max", labelLine1: "max", labelLine2: formatDurationUkrainian(maxDurationDay.durationSeconds) },
];
annotationData.forEach(item => {
const d = item.day;
const angleRad = d.angleRad;
const rLineStart = absoluteRadii.dayOfWeekLabel + 5;
const x1_line = centerX + rLineStart * Math.cos(angleRad);
const y1_line = centerY + rLineStart * Math.sin(angleRad);
const rAnnotationEnd = absoluteRadii.annotationText - config.spacings.annotationConnectorLengthReductionPx;
const annotationTextOffsetPx = 10;
const x_text_end = centerX + (rAnnotationEnd - annotationTextOffsetPx) * Math.cos(angleRad);
const y_text_end = centerY + (rAnnotationEnd - annotationTextOffsetPx) * Math.sin(angleRad);
chartArea.append("line")
.attr("class", `annotation-line ${item.type}`)
.attr("x1", x1_line).attr("y1", y1_line)
.attr("x2", x_text_end).attr("y2", y_text_end)
.attr("stroke", config.defaultTextColor)
.attr("stroke-width", config.elements.annotationLineWidth)
.attr("stroke-dasharray", config.elements.annotationDashArray);
const annotationTextElement = chartArea.append("text")
.attr("class", `annotation-text radial ${item.type}`)
.attr("x", x_text_end)
.attr("y", y_text_end)
.style("font-size", config.fontSizes.annotation)
.attr("fill", config.defaultTextColor);
let angleDeg;
let textAnchor;
let dominantBaseline = "middle";
const angleThreshold = 0.05;
const cosAngle = Math.cos(angleRad);
const sinAngle = Math.sin(angleRad);
angleDeg = (angleRad * 180 / Math.PI) + 90;
textAnchor = "start";
if (Math.abs(sinAngle) > 1 - angleThreshold) {
angleDeg = 0;
textAnchor = "middle";
dominantBaseline = sinAngle > angleThreshold ? "hanging" : "auto";
} else if (cosAngle < -angleThreshold) {
angleDeg += 180;
textAnchor = "end";
}
annotationTextElement
.attr("transform", `rotate(${angleDeg}, ${x_text_end}, ${y_text_end})`)
.attr("text-anchor", textAnchor)
.attr("dominant-baseline", dominantBaseline);
const standardLineHeightEm = 1.2;
let firstLineDyValue;
let firstLineDyUnit = "px";
if (textAnchor === "middle") {
firstLineDyValue = (dominantBaseline === "hanging") ? 2 : -2;
firstLineDyUnit = "px";
} else {
firstLineDyValue = (textAnchor === "start") ? -5 : 5;
firstLineDyUnit = "px";
}
annotationTextElement.append("tspan")
.attr("x", x_text_end)
.attr("dy", `${firstLineDyValue}${firstLineDyUnit}`)
.text(item.labelLine1);
annotationTextElement.append("tspan")
.attr("x", x_text_end)
.attr("dy", `${standardLineHeightEm}em`)
.text(item.labelLine2);
});
const titleGroup = svg.append("g").attr("class", "titles");
const titleBaseX = -config.width / 2 + config.spacings.titleAreaPadding.left;
const titleBaseY = -config.height / 2 + config.spacings.titleAreaPadding.top;
titleGroup.append("text")
.attr("x", titleBaseX)
.attr("y", titleBaseY + config.spacings.creditYOffset)
.style("font-size", config.fontSizes.credit)
.attr("fill", config.defaultTextColor)
.text("дані взято з sunearthtools.com");
titleGroup.append("text")
.attr("x", titleBaseX)
.attr("y", titleBaseY + config.spacings.mainTitleYOffset)
.style("font-size", config.fontSizes.mainTitle)
.style("font-weight", "normal")
.attr("fill", config.defaultTextColor)
.text("2025");
titleGroup.append("text")
.attr("x", titleBaseX)
.attr("y", titleBaseY + config.spacings.subTitleYOffset)
.style("font-size", config.fontSizes.subTitle)
.attr("fill", config.defaultTextColor)
.text("тривалість світлового дня на мисі Рока");
titleGroup.append("line")
.attr("x1", titleBaseX)
.attr("y1", titleBaseY + config.spacings.subTitleLineYOffset)
.attr("x2", titleBaseX + config.spacings.subTitleLineWidth)
.attr("y2", titleBaseY + config.spacings.subTitleLineYOffset)
.attr("stroke", config.defaultTextColor)
.attr("stroke-width", config.elements.titleUnderlineStrokeWidth);
return svg.node();
}