Created
March 24, 2016 20:53
-
-
Save mwdchang/6bc303efc1feb7afcf7c to your computer and use it in GitHub Desktop.
Zoomable Calendar
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <html> | |
| <head> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/d3/3.5.12/d3.js"></script> | |
| <style> | |
| body { | |
| font-family: Tahoma; | |
| font-size: 0.7rem; | |
| } | |
| .month { | |
| fill: none; | |
| stroke: #000; | |
| stroke-opacity: 0.6; | |
| stroke-width: 1px; | |
| } | |
| .year { | |
| fill: none; | |
| fill-opacity: 0.3; | |
| stroke: #F00; | |
| stroke-width: 1.5px; | |
| } | |
| .day { | |
| fill: #fff; | |
| stroke: #BBB; | |
| stroke-width: 0.5px; | |
| } | |
| .axis { | |
| font-size: 0.7rem; | |
| stroke-width: 1px; | |
| stroke: #000; | |
| } | |
| .axis path, | |
| .axis line { | |
| fill:none; | |
| stroke-width: 2px; | |
| stroke: #000; | |
| } | |
| .axis text { | |
| stroke: none; | |
| font-size: 0.8rem; | |
| } | |
| body > p { | |
| font-size: 1.0rem; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <p>Zoomable calendar. Pan and Zoom with the mouse, each calendar year is aligned with the time-scale. Note however the individual weeks | |
| are not necessarily aligned as the months have different number of days. | |
| </p> | |
| <svg id="canvas" width="1600" height="500"></svg> | |
| </body> | |
| <script> | |
| var svg = d3.select('svg'); | |
| var domainExtent = [new Date(2014, 0, 1), new Date(2018, 0, 1)]; | |
| var timeScale = d3.time.scale().domain(domainExtent).range([30, 1500]); | |
| var yearOffsets = _getYearOffSet(2014, 2018); | |
| var boundary = _computeBoundary(2014, 2018); | |
| var lookup = {}; | |
| var dateRange = d3.time.days(new Date(2014, 0, 1), new Date(2019, 0, 1)); | |
| dateRange.forEach(function(d, i) { | |
| lookup[d] = 0.8*Math.abs( | |
| Math.cos(i * Math.PI / 180) | |
| ) + 0.2*Math.random(); | |
| }); | |
| var timeAxis = d3.svg.axis() | |
| .scale(timeScale) | |
| .orient('top') | |
| .ticks(4) | |
| .tickSize(5) | |
| .tickPadding(5) | |
| .outerTickSize(0); | |
| var topAxis = svg.append('g') | |
| .attr('class', 'axis') | |
| .attr('transform', 'translate(0, 30)') | |
| .call(timeAxis.orient('top')); | |
| var bottomAxis = svg.append('g') | |
| .attr('class', 'axis') | |
| .attr('transform', 'translate(0, 230)') | |
| .call(timeAxis.orient('bottom')); | |
| var zoom = d3.behavior.zoom() | |
| .scaleExtent([1, 105]) | |
| .x(this.timeScale) | |
| .on('zoom', () => { | |
| if (! d3.event['sourceEvent']) return; | |
| d3.selectAll('.day, .month, .year').remove(); | |
| topAxis.call(timeAxis.orient('top')); | |
| bottomAxis.call(timeAxis.orient('bottom')); | |
| yearOffsets = _getYearOffSet(2014, 2018); | |
| boundary = _computeBoundary(2014, 2018); | |
| renderCalendarMonthDays(2014); | |
| renderCalendarMonthDays(2015); | |
| renderCalendarMonthDays(2016); | |
| renderCalendarMonthDays(2017); | |
| renderCalendarMonthOutline(2014); | |
| renderCalendarMonthOutline(2015); | |
| renderCalendarMonthOutline(2016); | |
| renderCalendarMonthOutline(2017); | |
| renderYearOutline(2014, 2018); | |
| d3.selectAll('.day, .month, .year').style('pointer-events', 'none'); | |
| }); | |
| svg.append('rect') | |
| .attr('transform', 'translate(0, 0)') | |
| .attr('x', 0) | |
| .attr('y', 0) | |
| .attr('height', 250) | |
| .attr('width', (1500)) | |
| .style('fill', '#FFF') | |
| .style('fill-opacity', 0.4) | |
| .style({ | |
| 'stroke': 'none', | |
| 'touch-action': 'none', | |
| '-ms-touch-action': 'none' | |
| }) | |
| .call(zoom); | |
| /** See: http://bl.ocks.org/mbostock/4063318 **/ | |
| var cellH = 25; | |
| renderCalendarMonthDays(2014); | |
| renderCalendarMonthDays(2015); | |
| renderCalendarMonthDays(2016); | |
| renderCalendarMonthDays(2017); | |
| renderCalendarMonthOutline(2014); | |
| renderCalendarMonthOutline(2015); | |
| renderCalendarMonthOutline(2016); | |
| renderCalendarMonthOutline(2017); | |
| renderYearOutline(2014, 2018); | |
| d3.selectAll('.day, .month, .year').style('pointer-events', 'none'); | |
| function renderYearOutline(yearStart, yearEnd) { | |
| svg.selectAll('.year') | |
| .data( | |
| d3.time.years( | |
| new Date(yearStart, 0, 1), | |
| new Date(yearEnd, 0, 1) | |
| ) | |
| ) | |
| .enter() | |
| .append('path') | |
| .attr('transform', function(d) { | |
| var bound = boundary[d.getFullYear()]; | |
| return 'translate(' + bound.start + ', 40)'; | |
| }) | |
| .classed('year', true) | |
| .attr('d', function(d) { | |
| var bound = boundary[d.getFullYear()]; | |
| cellW = bound.cellW; | |
| return yearPath(d); | |
| }); | |
| } | |
| function renderCalendarMonthOutline(year) { | |
| var bound = boundary[year]; | |
| cellW = bound.cellW; | |
| svg.selectAll('.month' + year) | |
| .data( | |
| d3.time.months( | |
| new Date(year, 0, 1), | |
| new Date(year+1, 0, 1) | |
| ) | |
| ) | |
| .enter() | |
| .append('path') | |
| .attr('transform', 'translate(' + bound.start + ', 40)') | |
| .classed('month', true) | |
| .classed('month'+year, true) | |
| .attr('d', monthPath); | |
| } | |
| function renderCalendarMonthDays(year) { | |
| var bound = boundary[year]; | |
| cellW = bound.cellW; | |
| var rect = svg.selectAll(".day" + year) | |
| .data(function(d) { | |
| return d3.time.days(new Date(year, 0, 1), new Date(year+1, 0, 1)); | |
| }) | |
| .enter() | |
| .append("rect") | |
| .attr('transform', function(d) { | |
| return 'translate(' + bound.start + ', 40)'; | |
| }) | |
| .attr("class", "day " + "day"+year) | |
| .attr("width", cellW) | |
| .attr("height", cellH) | |
| .attr("x", function(d) { return d3.time.weekOfYear(d) * cellW; }) | |
| .attr("y", function(d) { return d.getDay() * cellH; }); | |
| rect.style('fill-opacity', 0.75).style('fill', function(d, i) { | |
| var val = lookup[d]; | |
| if (d.getMonth() === 2 && d.getDate() === 14) { | |
| return d3.rgb(155, 28, 45); | |
| } else { | |
| return d3.rgb(255-(val*124), 120+(val*115), 50+val*65); | |
| } | |
| }); | |
| } | |
| function _computeBoundary(startYear, endYear) { | |
| var result = {}; | |
| for (var year = startYear; year <= endYear; year++) { | |
| var _startYear = new Date(year, 0, 1); | |
| var start = timeScale(new Date(year, 0, 1)); | |
| var end = timeScale(new Date(year+1, 0, 1)); | |
| var endWeek = d3.time.weekOfYear(new Date(year, 11, 31)); | |
| var endDay = (new Date(year, 11, 31)).getDay(); | |
| var cellW = (end - start) / endWeek; | |
| var gap = +(timeScale(new Date(year, 0 ,2)) - timeScale(new Date(year, 0, 1))); | |
| if (endDay === 6 && year > startYear) { | |
| end -= cellW; | |
| cellW = (end - start) / endWeek; | |
| } | |
| result[year] = { | |
| cellW: cellW, | |
| start: start | |
| }; | |
| } | |
| return result; | |
| } | |
| /** | |
| * In a calendar view, because a year can end on any day of the week, | |
| * we need to calculate and carry on any offsets to map it to the continuous scale | |
| */ | |
| function _getYearOffSet(startYear, endYear) { | |
| var result = {}; | |
| var offset = 0; | |
| for (var year = startYear; year <= endYear; year++) { | |
| var _startYear = new Date(year, 0, 1); | |
| var _endYear = new Date(year, 11, 31); | |
| var start = timeScale(_startYear); | |
| var end = timeScale(_endYear); | |
| var endWeek = d3.time.weekOfYear(_endYear); | |
| var startDay = _startYear.getDay(); | |
| var cellW = (end - start) / endWeek; | |
| var gap = +(timeScale(new Date(year, 0 ,2)) - timeScale(new Date(year, 0, 1))); | |
| if (startDay === 0 && year > startYear) { | |
| //offset += cellW; | |
| } else if (startDay !== 0 && year > startYear) { | |
| // console.log('gap', year, gap); | |
| offset -= cellW; | |
| } | |
| result[year] = offset; | |
| } | |
| return result; | |
| } | |
| /* | |
| svg.selectAll('.month-label') | |
| .data( | |
| d3.time.months( | |
| new Date(2015, 0, 1), | |
| new Date(2016, 0, 1) | |
| ) | |
| ) | |
| .enter() | |
| .append('text') | |
| .classed('month-label', true) | |
| .attr('y', 35) | |
| .attr('x', function(d) { | |
| return 10 + d3.time.weekOfYear(d) * cellW + cellW; | |
| }) | |
| .text(d3.time.format('%b-%y')); | |
| */ | |
| function yearPath(t0) { | |
| var t1 = new Date(t0.getFullYear()+1, 0, 0), | |
| d0 = t0.getDay(), w0 = d3.time.weekOfYear(t0), | |
| d1 = t1.getDay(), w1 = d3.time.weekOfYear(t1); | |
| return "M" + (w0 + 1) * cellW + "," + d0 * cellH | |
| + "H" + w0 * cellW + "V" + 7 * cellH | |
| + "H" + w1 * cellW + "V" + (d1 + 1) * cellH | |
| + "H" + (w1 + 1) * cellW + "V" + 0 | |
| + "H" + (w0 + 1) * cellW + "Z"; | |
| } | |
| function monthPath(t0) { | |
| var t1 = new Date(t0.getFullYear(), t0.getMonth() + 1, 0), | |
| d0 = t0.getDay(), w0 = d3.time.weekOfYear(t0), | |
| d1 = t1.getDay(), w1 = d3.time.weekOfYear(t1); | |
| return "M" + (w0 + 1) * cellW + "," + d0 * cellH | |
| + "H" + w0 * cellW + "V" + 7 * cellH | |
| + "H" + w1 * cellW + "V" + (d1 + 1) * cellH | |
| + "H" + (w1 + 1) * cellW + "V" + 0 | |
| + "H" + (w0 + 1) * cellW + "Z"; | |
| } | |
| </script> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment