Right, now we come to the meat of this whole project. In the basic CSS presented in the last episode, I opted for fixed cell measurements and completely flexible page measurements. This is exactly the opposite of what we actually want: the paper size for the calendar is fixed (even if we offer multiple options) and we want the cell size to vary to fill the available space.
Rather than deal with the complexities of the printer right up front (I hope I don't regret this decision…), I'm going to spend this episode looking at doing that on the screen in the available space (where it is easy to debug everything) and then concern myself with the complexities of printing next time.
Before we go any further, yes, I am aware of
calc in CSS, and use it frequently. However, I have convinced myself that what I want to do here goes beyond
calc because of the multiple constraints that apply to any given element. If you can figure out how to do all of this just with
calc, do let me know.
Contrariwise, I believe it is possible to do everything I want to do here with JavaScript and hacking at elements in the DOM. But that is painful and error-prone and, quite frankly, not as exciting as what I'm going to try and do here, which is to create a new stylesheet at run-time whose values are calculated dynamically. I'm then going to split the styling between true "styling" (which will stay in the static stylesheets) and the measuring (which will be calculated dynamically).
I've chosen to put this code in a separate JS file, mainly for clarity, but not to do all the work that I should do to make everything modular (also for clarity). So I've defined a separate
initStyling method, and called that from the
init method in
controls.js, passing through the
fbdiv which I'm then storing as
styledDiv. The more important thing, though, is that I create a new
CSSStyleSheet and assign it to the list of
adoptedStyleSheets in the
document. Note that this only "works" if you are in complete control of the set of sheets to be adopted; if you're not, you will need to do the work to merge your sheets with everyone else's.
<link rel="stylesheet" href="css/controls.css">
<link rel="stylesheet" href="css/feedback.css">
<script src="js/controls.js"></script>
<script src="js/styling.js"></script>
</head>
<body onload="init()">
<div class="controls">
start.valueAsDate = new Date();
end.valueAsDate = new Date();
initStyling(fbdiv);
redraw();
var styledDiv;
var sheet;
function initStyling(fbdiv) {
styledDiv = fbdiv;
sheet = new CSSStyleSheet();
document.adoptedStyleSheets = [sheet];
}
Setting the rules depends on figuring out the size of the area to be filled and then updating the rules. This is information which should be available in
redraw, so we'll call the function to set the dimension rules from there. At the end of
redraw, I've added a call to
fitToPageSize, which is a method to be defined in
styling.js.
fitToPageSize();
}
function fitToPageSize() {
while (sheet.cssRules.length > 0)
sheet.deleteRule(0);
sheet.insertRule(".body-day { width: 25mm; height: 25mm; margin: 2mm }");
sheet.insertRule(".body-day-date { top: 3mm; left: 3mm; font-size: 5mm }");
}
Before we go any further, I'm going to remove the sizing properties from the static CSS, so that there is no conflict and it is clear whether the dynamic stylesheet is working. And my first pass in getting the dynamic sheet working is just to try and add the same (fixed) properties to it. If nothing changes, we have a working dynamic spreadsheet.
And thus we have what we wanted: a dynamic stylesheet that can be continually updated. We are just left with the task of calculating the correct values.
This is tagged
PLANNING_CALENDAR_DYNAMIC_STYLESHEET.
This next bit is something I never like doing. I think the root of the problem is just that it completely violates everything that is innate to HTML which is that you have an infinite scroll of paper, and thus there is no such notion as the "height" of the page. So you have to take the window size, and then go around subtracting all sorts of random measurements (body margin, other divs, etc) until it seems to fit. And then you try it on a mobile device only to find …
So bear with me if this doesn't "quite" work for you, or if the numbers (such as 16, double the "default" body margin of 8) seem to have been plucked out of nowhere. Imagine, if you will, that this code is in fact just going to return the available size for the feedback div. To help you, I will bury it in a function with that name and put all the nastiness in that, and then delegate the layout work to another function which takes the result.
Now, having done all that, I have a size in pixels. Up until now, I've been using mm, but there's no real difference between them for what follows, as long as you are consistent about it.
Let's tackle the horizontal first. We know that we have exactly 7 days in a week, and we also have 8 "spaces" (one on each end and six in between). Because this is CSS, each of the spaces consists of one or two margins, so actually we have 7 left margins and 7 right margins. So, if we say these will be equal, we can consider that we have 14 margins. If we say that we want a ratio of 12:1 for the days to the spaces, then we have 84 "units" in days and 7 units in spaces, for a total of 91 units. Each unit is then the width divided by 91. And then I'll round down to make sure it fits. We then need to set the width of a "day" to 12 units and the margin to be 0.5 units.
var styledDiv;
var sheet;
var controlPane;
function initStyling(fbdiv) {
styledDiv = fbdiv;
controlPane = document.getElementById('controls');
sheet = new CSSStyleSheet();
document.adoptedStyleSheets = [sheet];
}
function fitToPageSize() {
// delte the old rules
while (sheet.cssRules.length > 0)
sheet.deleteRule(0);
// calculate the page size
var pageSize = calculateSizeOfFeedbackDiv();
var unitIn = "px";
// calculate desired box sizes
var xunit = Math.floor(pageSize.x / 91);
var xday = xunit*12;
var xmargin = xunit/2;
// generate new rules
sheet.insertRule(".feedback { border-width: 0.1mm; width: " + pageSize.x + "px; height: " + pageSize.y + "px; }");
sheet.insertRule(".body-day { border-width: 0.1mm; width: " + xday + unitIn + "; height: 25mm; margin: " + xmargin + unitIn + " }");
sheet.insertRule(".body-day-date { top: 3mm; left: 3mm; font-size: 5mm }");
}
function calculateSizeOfFeedbackDiv() {
var viewx = window.innerWidth;
var viewy = window.innerHeight;
var fbx = viewx - 16 - 5; // 16 for double body margin // allow, say, 5 for border
var fby = viewy - controlPane.clientHeight - 16 - 5;
return { x : fbx, y : fby };
}
When I do this, I find that the line "wraps": it's longer than I have calculated. Inspecting the DOM in Chrome, I discover there is extra space between the elements. Ah, yes, I remember adding that now when we were all text based. So I removed it from the DOM.
This is tagged
PLANNING_CALENDAR_X_CORRECT.
Now, for the vertical dimension, we need to figure out how many rows there are. In fact, we have already done this when drawing the rows, which is where this function is called from, so we'll get it to count them for us and hand the number over.
if (leftDate > from) {
leftDate.setDate(leftDate.getDate() - 7);
}
var numRows = 0;
do {
console.log(" showing week with", leftDate, "in the left column");
// advance to next week
leftDate.setDate(leftDate.getDate() + 7);
numRows++;
} while (leftDate <= to);
fitToPageSize(numRows);
}
Then the calculation is basically the same: we have a given number of rows, with a margin above and below each one, and we want the ratio to be about 12:1. So we divide the page size by the number of rows multiplied by 13, and use that as the y unit size. Oh, and don't forget that CSS measurements for things like margins use the "compass points" notation so that y comes before x.
Finally, we want to calculate the position and size of the date number. This wants to be about a fifth of the way into the box and about an eighth of its size (a completely arbitrary and aesthetic judgment on my part, you understand). More importantly, the size wants to be the smaller of what x and y will allow (the positions are independent). However, there are a couple more constraints: it would be good if the number were legible, so I would like it to always be at least 8px, but it can't be more than the available space in the box, which implies a font size of about three quarters the height of the box.
function fitToPageSize(rows) {
var xpos = xday / 5;
var xsize = xday / 8;
var yunit = Math.floor(pageSize.y / (rows * 13));
var yweek = yunit*12;
var ymargin = yunit/2;
var ypos = yweek / 5;
var ysize = Math.min(Math.max(yweek / 8, 8), yweek * 3/4);
var dateSize = Math.min(xsize, ysize);
// generate new rules
sheet.insertRule(".feedback { border-width: 0.1mm; width: " + pageSize.x + "px; height: " + pageSize.y + "px; }");
sheet.insertRule(".body-day { border-width: 0.1mm; width: " + xday + unitIn + "; height: " + yweek + unitIn + "; margin: " + ymargin + unitIn + " " + xmargin + unitIn + " }");
sheet.insertRule(".body-day-date { top: " + ypos + unitIn + "; left: " + xpos + unitIn + "; font-size: " + dateSize + unitIn + " }");
}
So far, so good. Checked in and tagged as
PLANNING_CALENDAR_XY_CORRECT.
Now, what's bugging me is that the rounding that I did to make sure that the calendar doesn't overflow is working perfectly in the horizontal direction, but is slightly too aggressive in the vertical dimension. Simply removing it causes the box to overflow, as I'd expect, as does rounding to a single decimal place. I hate figuring these things out, but I guess I have to.
The problem, "of course", is that I have been ignoring the effect of the border widths in these calculations and just thinking that it will cancel out. Which it kind of does, but it's too ugly.
So the only solution is actually to take that into account, calculate the correct offsets given the border width, and remove the
Math.floor. Then it will all work correctly.
This is too dull and testy to show here, but you can see it in the repository; it's checked in and tagged as
PLANNING_CALENDAR_BORDER_INCLUDED.
One last thing: we also want to redraw the calendar when the window size changes (e.g. going full screen or removing the developer tools). To handle this, we need to add a handler for the resize event. This is tagged
PLANNING_CALENDAR_HANDLE_RESIZE.
initStyling(fbdiv);
addEventListener("resize", redraw);
redraw();
}