Published
Edited
Dec 9, 2020
3 stars
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
dragObject = ({
from: '',
to: '',
track: ''
})
Insert cell
drag = ({
started: (e) => {
const radians = (Math.atan2(e.x, -e.y) + Tau) % Tau
const distance = Math.hypot(e.x, -e.y)
const {date, track} = getTrack(radians, distance)
dragObject.from = date
dragObject.to = d3.timeWeek.offset(date, 1)
dragObject.track = track
console.log(dragObject)
console.log(d3.select('g#wheel'))
d3.select('g#wheel').append('path')
.attr('d', d => arc(dragObject.from, dragObject.to, dragObject.track))
.attr("fill", (d,i) => ntnuSupportColorsLight[dragObject.track])
.attr("stroke", "#666")
},
dragged: e => {
const radians = (Math.atan2(e.x, -e.y) + Tau) % Tau
const distance = Math.hypot(e.x, -e.y)
const {date, track} = getTrack(radians, distance)
dragObject.to = date
},
ended: e => {}
})
Insert cell
width = 900
Insert cell
height = 900
Insert cell
radiusMargin = 40
Insert cell
html`
<script src="https://use.fontawesome.com/a156b4dc96.js"></script>

<style>

@import url('https://fonts.googleapis.com/css2?family=Open+Sans:ital,wght@0,300;0,400;0,600;0,700;1,300;1,400;1,600;1,700&display=swap');

body {
font-family: 'Open Sans', sans-serif;
font-weight: 400;
}

text {
font-family: 'Open Sans', sans-serif;
font-weight: 400;
}

</style>`
Insert cell
Insert cell
monthFormatter = new Intl.DateTimeFormat('nb', { month: 'long' })
Insert cell
months = d3.pairs(d3.timeMonth.range(new Date(2021,0,1), new Date(2022,1,1))).map(d => {
const name = monthFormatter.format(d[0])
return {
month: name.charAt(0).toUpperCase() + name.slice(1),
from: d[0],
to: d[1]
}
})
Insert cell
trackArcs = d3.range(tracks).map(d => arc(startContent, endContent, d))
Insert cell
monthLines = monthData.map(d => radialLine(d, 0, 0, divisionPoint))
Insert cell
monthLines2 = monthData.map(d => radialLine(d, 1, 0, 1))
Insert cell
weekLines = weekData.map(d => radialLine(d.date, 0, divisionPoint, 1))
Insert cell
weekLines2 = weekData.map(d => radialLine(d.date, 1, divisionPoint, 1))
Insert cell
divisionPoint = 0.75
Insert cell
divider = angularLine(startContent, endContent, 0, divisionPoint)
Insert cell
divider2 = angularLine(startContent, endContent, 1, divisionPoint)
Insert cell
monthData = d3.timeMonth.range(startContent, endContent)
Insert cell
yearSegments = d3.pairs([startContent, ...d3.timeYears(d3.timeDay.offset(startContent, 1), endContent), endContent])
Insert cell
weekData = { // TODO: Double check that week numbers are correct
return yearSegments.reduce((acc, curr) => {
const mondays = d3.timeMondays(curr[0], curr[1])
const firstWeekNo = getWeekNumber(mondays[0])
const weekNumbers = d3.range(firstWeekNo, firstWeekNo + mondays.length).map(d => {
if (d == 53) {
return getWeekNumber(mondays[mondays.length-1])
} else return d
})
return acc.concat(d3.zip(mondays, weekNumbers))
}, []).map(d => ({number: d[1], date: d[0]}))
}
Insert cell
getWeekNumber = date => {
// https://no.wikipedia.org/wiki/Ukenummer
const dayN = d3.timeDay.count(d3.timeYear(date), date) + 1
const weekDayN = date.getDay() > 0 ? date.getDay() : 7
const prelim = Math.floor((10 + dayN - weekDayN) / 7)
if (prelim == 53 && d3.timeDay.count(date, d3.timeYear.ceil(date)) < 3) {
return 1
} else return prelim
}
Insert cell
bandwidth = {
if (spiralOrCircle == 'circle') {
return (height-radiusMargin)/2 - innerRadius
} else {
const startYearStart = new Date(start.getTime()).setMonth(0,1,0)
const endYearStart = new Date(end.getTime()).setMonth(0,1,0)

const startAngle = d3.timeDay.count(startYearStart, start) / daysInYear(start.getYear())
let endAngle = d3.timeDay.count(endYearStart, end) / daysInYear(end.getYear())
endAngle += d3.timeYear.count(startYearStart, endYearStart)

const spirals = endAngle - startAngle

return ((height-radiusMargin)/2 - innerRadius) / (spirals + 1 + spiralSpacing)
}
}
Insert cell
startContent = new Date(startDate)
Insert cell
start = d3.timeMonth.offset(startContent, -6)
Insert cell
endContent = spiralOrCircle == 'spiral' ? new Date(endDate) : d3.timeYear.offset(startContent, 1)
Insert cell
end = d3.timeMonth.offset(endContent, 3)
Insert cell
point = (date, layer, pos) => {
return d3.pointRadial(dateToAngle(date), radius(layer, pos)(date))
}
Insert cell
radialLine = (date, layer, posInner=0, posOuter=1) => {
const dateAngle = dateToAngle(date)
return d3.lineRadial()([[dateAngle, radius(layer, posInner)(date)],[dateAngle, radius(layer, posOuter)(date)]])
}
Insert cell
angularLine = (startDate, endDate, layer, pos) => {
const steps = startDate < endDate ?
d3.timeDay.every(4).range(d3.timeDay.offset(startDate,1), d3.timeDay.offset(endDate, -2)) :
d3.timeDay.every(4).range(d3.timeDay.offset(endDate,1), d3.timeDay.offset(startDate, -2)).reverse()
const data = [startDate, ...steps, endDate]
return d3.lineRadial()
.curve(d3.curveCardinal)
.angle(dateToAngle) // TODO: Interpolate between two angles instead of calling this 100x
.radius(radius(layer, pos))(data)
}
Insert cell
arc = (startDate, endDate, layer, posInner=0, posOuter=1) => {
return angularLine(startDate, endDate, layer, posOuter) + angularLine(endDate, startDate, layer, posInner).replace('M', 'L') + 'Z'
}
Insert cell
d3.timeMonth.offset(d3.timeYear(start), 12)
Insert cell
d3.timeYear.ceil(start)
Insert cell
dateToAngle = d => {
const yearStart = d3.timeYear(d)
const secondQuarter = d3.timeMonth.offset(yearStart, 3)
const thirdQuarter = d3.timeMonth.offset(yearStart, 6)
const fourthQuarter = d3.timeMonth.offset(yearStart, 9)
if (d > fourthQuarter) {
return d3.scaleLinear()
.domain([fourthQuarter, d3.timeYear.ceil(d)])
.range([3*Tau/4, Tau])(d)
} else if (d > thirdQuarter) {
return d3.scaleLinear()
.domain([thirdQuarter, fourthQuarter])
.range([Tau/2, 3*Tau/4])(d)
} else if (d > secondQuarter) {
return d3.scaleLinear()
.domain([secondQuarter, thirdQuarter])
.range([Tau/4, Tau/2])(d)
} else {
return d3.scaleLinear()
.domain([yearStart, secondQuarter])
.range([0, Tau/4])(d)
}
}
Insert cell
angleToDate = angle => {
const yearStart = new Date(2021, 0, 1)
const secondQuarter = d3.timeMonth.offset(yearStart, 3)
const thirdQuarter = d3.timeMonth.offset(yearStart, 6)
const fourthQuarter = d3.timeMonth.offset(yearStart, 9)
if (angle > 3*Tau/4) {
return d3.scaleLinear()
.domain([3*Tau/4, Tau])
.range([fourthQuarter, d3.timeYear.offset(yearStart, 1)])(angle)
} else if (angle > Tau/2) {
return d3.scaleLinear()
.domain([Tau/2, 3*Tau/4])
.range([thirdQuarter, fourthQuarter, 1])(angle)
} else if (angle > Tau/4) {
return d3.scaleLinear()
.domain([Tau/4, Tau/2])
.range([secondQuarter, thirdQuarter, 1])(angle)
} else {
return d3.scaleLinear()
.domain([0, Tau/4])
.range([yearStart, secondQuarter, 1])(angle)
}
}
Insert cell
// quarters = {
// const edgeRadius = d3.scaleLinear()
// .domain([start, end])
// .range([innerRadius + trackStart[0], width/2])
// const canonicalYear = new Date(2021, 0, 1)
// const endMonth = d3.timeMonth(endContent)
// const firstEndQuarter = d3.timeMonth.offset(endMonth, -endMonth.getMonth()%3 -9)
// const startMonth = d3.timeMonth.offset(d3.timeMonth(startContent))
// const firstStartQuarter = d3.timeMonth.offset(startMonth, startMonth.getMonth()%3)
// return d3.range(0,12,3)
// .map(d => dateToAngle(d3.timeMonth.offset(canonicalYear, d)))
// .map((d,i) => d3.lineRadial()([
// [d, radius(0,0)(d3.timeMonth.offset(firstStartQuarter, i*3))],
// [d, radius(tracks-1, 1)(d3.timeMonth.offset(firstEndQuarter, i*3))]
// ]))
// }
Insert cell
radius = (layer, pos) => {
const trackPos = trackStart[layer] + trackWidth * pos
let range
if (spiralOrCircle == 'spiral') {
range = spiralOut == "out" ?
[innerRadius + trackPos, (height-radiusMargin)/2 - bandwidth + trackPos] :
[(height-radiusMargin)/2 - bandwidth + trackPos, innerRadius + trackPos]
} else {
range = [innerRadius + trackPos, innerRadius + trackPos]
}
return d3.scaleLinear()
.domain([start, end])
.range(range)
}
Insert cell
new Date().setMonth(0,1,0) < new Date() < new Date().setMonth(0,1,0)
Insert cell
getTrack(Math.PI, 380)
Insert cell
getTrack = (radians, distance) => {
const date = angleToDate(radians)
const month = date.getMonth()
const day = date.getDate()
const r = radius(0,0)
const bandStarts = yearSegments
.filter(d => {
const thisDate = new Date(d[0]).setMonth(month, day, 0)
return d[0] < thisDate && thisDate < d[1]
})
.map(d => {
const thisDate = new Date(d[0]).setMonth(month, day, 0)
return {
r: r(thisDate),
date: thisDate
}
})
// compare radialDistance with bandStarts and trackWidths etc
let band
let track
for (let i = bandStarts.length -1; i >= 0; i--) {
if (distance >= bandStarts[i].r) {
band = i
break
}
}
for (let i = tracks -1; i >= 0; i--) {
if (distance >= bandStarts[band].r + i * (trackWidth + trackSpacing)) {
track = i
break
}
}
return {
date: new Date(bandStarts[band].date),
track: track
}
}
Insert cell
trackWidth = spiralOrCircle == 'spiral' ?
bandwidth/((tracks) + trackSpacing * (tracks -1)) :
bandwidth/((tracks) + trackSpacing * tracks)
Insert cell
trackStart = d3.range(tracks).map(d => (trackWidth * (1 + trackSpacing)) * d)
Insert cell
Insert cell
Insert cell
{
const svg = d3.create('svg')
.attr('width', width)
.attr('height', width)
const wheel = svg.append('g')
.attr('transform', `translate(${width/2}, ${width/2})`)
wheel.classed('wheel')
data.rings.forEach((ring, i) => {
wheel.selectAll(`path .month-arcs`)
.data(data.years[0].monthAngles) // TODO: Make responsive to timeSlicer pos
.enter().append('path')
.classed(`month-arcs`, true)
.attr('fill', (d,i) => colorScale(i%3, ring.color))
.attr('d', d => arcMaker({
startAngle: d.startAngle,
endAngle: d.endAngle,
innerRadius: ring.innerRadius,
outerRadius: ring.outerRadius
}))
if (!ring.simplified) {
wheel.selectAll(`path .week-lines`)
.data(data.years[0].weekAngles) // TODO: Make responsive to timeSlicer pos
.enter().append('path')
.classed(`week-lines`, true)
.attr('stroke', 'white')
.attr('stroke-width', 1)
.attr('d', d => lineRadial([[d.startAngle, ring.innerRadius], [d.startAngle, ring.outerRadius]]))
}
})
data.rings.forEach((ring, i) => {
wheel.selectAll('path .periods')
.data(ring.periods)
.enter()
.append('path')
.classed('periods', true)
.attr('fill', ntnuSupportColors[ring.color])
.attr('stroke', ntnuSupportColorsDark[ring.color])
.attr('stroke-width', 2)
.attr('d', d => periodMaker({
startAngle: angleForDate(d.fromDate, data),
endAngle: angleForDate(d.toDate, data),
innerRadius: ring.innerRadius + 25, // Hand coded for design testing only
outerRadius: ring.outerRadius + 10
}))
.style('opacity', 0.7)
const tempInsetVariable = 25 // Shameful;y bad coding practice
wheel.selectAll('circle .events')
.data(ring.events)
.enter()
.append('circle')
.classed('events', true)
.attr('fill', ntnuSupportColors[ring.color])
.attr('stroke', ntnuSupportColorsDark[ring.color])
.attr('stroke-width', 2)
.attr('cx', d => d3.pointRadial(angleForDate(d.date, data), ring.outerRadius - tempInsetVariable)[0])
.attr('cy', d => d3.pointRadial(angleForDate(d.date, data), ring.outerRadius - tempInsetVariable)[1])
.attr('r', 20)
.style('opacity', 1)
wheel.selectAll('text .icon')
.data(ring.events)
.enter()
.append('text')
.classed('icon', true)
.attr('x', d => d3.pointRadial(angleForDate(d.date, data), ring.outerRadius - tempInsetVariable)[0])
.attr('y', d => d3.pointRadial(angleForDate(d.date, data), ring.outerRadius - tempInsetVariable)[1])
.attr('dy', 6)
.attr('text-anchor', 'middle')
.text(d => d.icon)
})
///////////////////////////////////////////////
// timeSlicer
///////////////////////////////////////////////
if (data.timeSlicer.show) {
const rotation = angleForDate(timeSlicerDate, data, 'start') * 360 / Tau
const timeSlicer = svg.append('g')
.attr('transform', `translate(${width/2}, ${width/2}) rotate(${rotation})`)
timeSlicer.classed('time-slicer')
timeSlicer.append('path')
.classed('time-slicer', true)
.attr('stroke', '#333')
.attr('stroke-width', 1.5)
.attr('d', lineRadial([[0, margin.linesInner], [0, width/2 - margin.linesOuter]]))
timeSlicer.append('circle')
.classed('time-slicer', true)
.attr('fill', '#333')
.attr('cx', 0)
.attr('cy', -margin.linesInner)
.attr('r', 3)
timeSlicer.append('circle')
.classed('time-slicer', true)
.attr('fill', '#333')
.attr('cx', 0)
.attr('cy', - width/2 + margin.linesOuter)
.attr('r', 3)
const arrowheadPos = {
x: 25,
y: -width/2 + margin.linesOuter + 12
}
// TODO: Add drop shadow to the left. Using drop shadow and clipping area?
timeSlicer.append('line')
.attr('stroke', '#333')
.attr('stroke-width', 1.5)
.attr('x1', 0)
.attr('y1', arrowheadPos.y)
.attr('x2', arrowheadPos.x)
.attr('y2', arrowheadPos.y)
const arrowhead = timeSlicer.append('g')
.attr('transform', `translate(${arrowheadPos.x}, ${arrowheadPos.y})`)
arrowhead.append('line')
.attr('stroke', '#333')
.attr('stroke-width', 2.5)
.attr('x1', 0)
.attr('y1', 0)
.attr('x2', -10)
.attr('y2', -7)
arrowhead.append('line')
.attr('stroke', '#333')
.attr('stroke-width', 2.5)
.attr('x1', 0)
.attr('y1', 0)
.attr('x2', -10)
.attr('y2', 7)
}
return svg.node()
}
Insert cell
colorScale = (month, color) => d3.scaleLinear([0,2], [ntnuSupportColorsLighter[color], ntnuSupportColorsLight[color]])(month)
Insert cell
data = {
const thisYear = new Date().getFullYear()
let raw = {
timeSlicer: {
date: new Date(thisYear, 0, 1),
show: true
},
title: {
title: "NTNU",
subtitle: 'Avdelingsnavn',
show: true
},
years: [thisYear, thisYear + 1].map(y => generateYearData(y)),
rings: ringsData()
}
raw.timeSlicer.angle = angleForDate(raw.timeSlicer.date, raw, 'start')
return raw
}
Insert cell
ringsData = () => {
let rings = [
{
title: "Indre planleggingsring",
color: 0,
simplified: true,
periods: [
{
label: "Gruppe A",
fromDate: new Date('March 1, 2020'),
toDate: new Date('April 1, 2020')
},
{
label: "Gruppe B",
fromDate: new Date('April 1, 2020'),
toDate: new Date('May 1, 2020')
},
{
label: "Gruppe C",
fromDate: new Date('May 1, 2020'),
toDate: new Date('June 1, 2020')
},
{
label: "Overgang til nye systemer",
fromDate: new Date('August 1, 2020'),
toDate: new Date('November 1, 2020')
}
],
events: [
{
label: "Deadline!",
icon: 'D',
date: new Date("May 30, 2020")
}
],
},
{
title: "Ytre planleggingsring",
color: 1,
simplified: false,
periods: [
{
label: "Søknadsperiode",
fromDate: new Date('April 3, 2020'),
toDate: new Date('May 15, 2020')
},
{
label: "Reorganisering",
fromDate: new Date('June 3, 2020'),
toDate: new Date('August 15, 2020')
},
{
label: "Budsjettering",
fromDate: new Date('October 1, 2020'),
toDate: new Date('December 15, 2020')
}
],
events: [
{
label: "Apply for grants",
icon: "F",
date: new Date("June 15, 2020")
},
{
label: "Apply for grants",
icon: 'G',
date: new Date("November 1, 2020")
}
],
},
]
for (let i of d3.range(rings.length)) {
Object.assign(rings[i], ringMaker(rings.length)[i])
}
return rings
}
Insert cell
generateYearData = year => ({
year: year,
months: months2(year),
monthAngles: angleMaker(Object.values(months2(year))),
weeks: weeks(year),
weekAngles: angleMaker(weeks(year)),
daysInYear: daysInYear(year),
firstWeekday: firstWeekday(year)
})
Insert cell
periodMaker = d3.arc().cornerRadius(5).padAngle(0.01)
Insert cell
angleMaker = d3.pie().sort(null)
Insert cell
ringMaker = rings => {
const innerMostRadius = margin.ringsInner - 20 // Can't be bothered to fix padding between rings
const outerMostRadius = width/2 - margin.ringsOuter
const radiusPadding = 20
const ringWidth = (outerMostRadius - margin.ringsInner - radiusPadding) / rings
return d3.range(rings).map((d,i) => ({
innerRadius: innerMostRadius + ringWidth * i + radiusPadding,
outerRadius: innerMostRadius + ringWidth * (i+1)
}))
}
Insert cell
margin = ({ ringsOuter: 20, linesOuter: 10, ringsInner: width/6, linesInner: width/6 - 10 })
Insert cell
Insert cell
// Algorithm from https://en.wikipedia.org/wiki/Leap_year#Algorithm
isLeapYear = year => {
if (year % 4 != 0) { return false }
else if (year % 25 != 0) { return true }
else if (year % 16 != 0) { return false }
else { return true }
}
Insert cell
daysInYear = year => isLeapYear(year) ? 366 : 365
Insert cell
// Sakamoto's method https://en.wikipedia.org/wiki/Determination_of_the_day_of_the_week#Sakamoto's_methods
// month number is 1-based, returns 0-based weekday
dayOfWeek = (year, month, day) => {
const t = [0,3,2,5,0,3,5,1,4,6,2,4]
year = (month < 3) ? year-1 : year
return (year + Math.floor(year/4) - Math.floor(year/100) + Math.floor(year/400) + t[month-1] + day -1 ) % 7
}
Insert cell
firstWeekday = year => dayOfWeek(year, 1, 1)
Insert cell
weekdays = ['Mandag', 'Tirsdag', 'Onsdag', 'Torsdag', 'Fredag', 'Lørdag', 'Søndag']
Insert cell
months2 = year => ({
"Januar": 31,
"Februar": isLeapYear(year) ? 29: 28,
"Mars": 31,
"April": 30,
"Mai": 31,
"Juni": 30,
"Juli": 31,
"August": 31,
"September": 30,
"Oktober": 31,
"November": 30,
"Desember": 31
})
Insert cell
// Takes a year and returns an array of week lengths
weeks = year => {
const firstWeekLength = 7-firstWeekday(year)
const fullWeeks = Math.floor((daysInYear(year) - firstWeekLength) / 7)
const lastWeekLength = (daysInYear - firstWeekLength) % 7
let weekArray = Array(fullWeeks).fill(7)
if (firstWeekLength >= 0) { weekArray.unshift(firstWeekLength) }
if (lastWeekLength >= 0) { weekArray.push(lastWeekLength) }
return weekArray
}
Insert cell
Tau = Math.PI * 2
Insert cell
angleForDate = (date, data, pos = 'mid') => {
const year = (date >= data.timeSlicer.date) ? data.years[0] : data.years[1]
date = (pos == 'mid') ? new Date(date.getTime()).setHours(12,0,0) : new Date(date.getTime()).setHours(0,0,0)
return d3.scaleTime()
.domain([new Date(year.year, 0, 1), new Date(year.year + 1, 0, 1)])
.range([0, Tau])(date)
}
Insert cell
lineRadial = d3.lineRadial()
Insert cell
arcMaker = d3.arc()
Insert cell
Insert cell
d3 = require('d3@6')
Insert cell
dayjs = {
const dayjs = await require('dayjs')
await require('dayjs/locale/nb')
var relativeTime = await require('dayjs/plugin/relativeTime')
dayjs.locale('nb')
dayjs.extend(relativeTime)
return dayjs
}
Insert cell
dayjs('2018-05-05').format('D MMMM')
Insert cell
import {ntnuBlue, ntnuSupportColors, ntnuSupportColorsLight, ntnuSupportColorsLighter, ntnuSupportColorsDark, ntnuSupportColorsDarker } from "@gorm/ntnu-standards"
Insert cell
import {Scrubber} from "@mbostock/scrubber"
Insert cell
new Date('14 oct 2020')
Insert cell
import {slider, date, radio} from "@jashkenas/inputs"
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