Thursday, July 20, 2023

Month and Year

For short calendars, just having the day number is enough. For longer calendars, it is also necessary to have some indication of which month (and possibly which year) is intended.

In the past, I have just put the month and date to the right of the calendar, starting as the month changes. This time, however, I am going to go for a "watermark" effect in which the month and year appear behind the calendar. This seems intuitively easy, but there are four separate aspects we need to consider:
  • Which month/year combinations we want to show;
  • Which and how many rows each should occupy;
  • Consequently how big the month name can/should be;
  • The actual content and styling of the watermark.
All of this will be spread across all the code we have so far, with each aspect being taken on in the appropriate place.

First, let's consider which month names we want to display. This is most easily handled in redraw, where we are scrolling through the weeks. As we consider each week, we consider if we consider it to be a first or last week of the month and thus build a table of rows and their respective month names.

If that sounds simple, you haven't considered the problem.

First off, what do we do with rows that have the end of one month and the start of the next month? I say we ignore them: it would be confusing to label them as either.

What do we do if we start halfway through a month? We just collect the one or two rows that are definitely in that month.

What if we start with the last few days of the month on the same row as another month? We ignore it as before.

And we need to do all of that while keeping track of what month and year we are in, along with how many months that is.

Right, let's get started.

We're going to make a start by upgrading the information we send between the functions to an object. This now has an element numRows which reflects the total number of rows, as before, and a new months array which will contain objects describing the desired month labels. We also create an (initially null) variable to track the "current" month.

We then use the leftDate to calculate the rightDate (six days later - the date in the right hand column). This enables us to easily check if the whole row contains the same month. If not, we just set the tracking variable to null.

The next case we consider is whether we are currently tracking a month and it is the same month as the left date. If so, this is just another row in that month and we can increment it.

The final case is when all of the week is in the same month, but we are not currently tracking it. In this case, we want to create a new object, start tracking it, and add it to our list of months.
  var thisMonth = null;
  do {
    var rightDate = new Date(leftDate);
    rightDate.setDate(rightDate.getDate() + 6);
    console.log(" showing week with", leftDate, "in the left column and", rightDate, "in the right column");

    // figure out if this is worthy of making a month
    if (leftDate.getMonth() != rightDate.getMonth()) {
      // we are not interested
      thisMonth = null;
    } else if (thisMonth && thisMonth.month == leftDate.getMonth()) { // if we are already recording this month, increment it
      thisMonth.numRows++;
    } else { // this week is all in this month and is a different month to what has gone before
      thisMonth = { month: leftDate.getMonth(), year: leftDate.getFullYear(), from: rowInfo.numRows, numRows: 1 };
      rowInfo.months.push(thisMonth);
    }
And that is checked in and tagged as PLANNING_CALENDAR_COLLECT_MONTHS.

That isn't quite all the work done, however. We also need to create divs in here that contain the text of the month names. We are going to style them in the background, but that doesn't alter the fact that we need to create divs containing the month name and year. And it makes sense to do this at the same time as creating the divs for the weeks themselves.

Each div that we create contains the appropriate text for the month and the class watermark to obtain the static styling for all watermarks and watermark-i for the individual positioning and measurement.
      var watermark = document.createElement("div");
      watermark.classList = 'watermark watermark-' + watermarkNo;
      watermarkNo++;
      var text = leftDate.toLocaleDateString(undefined, { month: 'long', year: 'numeric'});
      var wktext = document.createTextNode(text);
      watermark.appendChild(wktext);
      fbdiv.appendChild(watermark);
That is checked in and tagged as PLANNING_CALENDAR_WATERMARK_DIVS.

Now that we have the month names and know where we want to position them, we can go about trying to style them. The easy bit is the static styling, which we place in watermark.css. Simply put, we just need to choose a font and a color and then make it slip into the background by specifying a z-index and specifying absolute positioning (this also requires us to define the positioning of the feedback div as relative). We obviously don't want to specify the font size or position here, since that is the purpose of the indexed watermark type.
.watermark {
  font-family: sans-serif;
  font-weight: bold;
  color: lightgrey;
  position: absolute;
  z-index: -1;
}
Moving onto that, then. When we are defining the measurement stylesheet, we have the list of rows which we want to style, and separately we know how big each row is. We now need to figure out how much space there is in those rows (both horizontally and vertically) and then how we can scale it to make it fit. In order to do that, we need to know a reference size for the text we want to display. This, in turn, requires us to know what that text is. Looking back at what we did earlier, it's actually quite easy to rearrange the code to include that by putting the creation of thisMonth to the end of the else clause.

The recommended way to figure out the size of text is to use an HTML canvas object and use the TextMetrics object. Even though we don't want a canvas per se, we will thus add a hidden canvas and use that to do the measuring.
  <body onload="init()">
    <div id='controls' class="controls">
      <canvas id='canvas' style="display: none;"></canvas>
      <label for="start-date">Start date:</label>
var metricFontSize = 10;
  canvas = document.getElementById("canvas");
  sheet = new CSSStyleSheet({ media: "screen" });
  printSheet = new CSSStyleSheet({ media: "print" });
  document.adoptedStyleSheets = [sheet, printSheet];
  cx = canvas.getContext("2d");
  cx.font = metricFontSize + "px sans-serif";
It's fairly easy then to see how to measure the text using the canvas measureText function. This then gives us a width and height of the bounding box for the text at a known font size.

The available vertical space for the text is the number of rows multiplied by the row height plus the number of gaps (one less than the number of rows) multiplied by the y margin. The available horizontal space is likewise 7 (the number of columns) multiplied by the cell width plus 6 multiplied by the x margin.

Now, obviously, we don't want to use all of that space - it would look too cramped. So we'll take 75% of that and see what scale we could get away with in both horizontal and vertical dimensions, and then choose the smaller of those. It's then just maths to put away all the rest of the placement and scaling.

One final problem which crops up is the "phantom space" at the top of the div. This is just caused by CSS allowing extra space that is "never used" between lines. There doesn't seem to be an easy way to remove this (I would expect to be able to specify where I want the baseline relative to the "top" of the div, but I can't), but it isn't that far away, so I'll not worry about it.
  for (var i=0;i<rowInfo.months.length;i++) {
    handleWatermarks(sheet, i, rowInfo.months[i], xday, xmargin, yweek, ymargin);
  }
}

function handleWatermarks(sheet, idx, rowInfo, xcell, xmargin, ycell, ymargin) {
  var mx = cx.measureText(rowInfo.text);
  var width = mx.actualBoundingBoxRight + mx.actualBoundingBoxLeft;
  var height = mx.actualBoundingBoxDescent + mx.actualBoundingBoxAscent;

  var availx = 7 * xcell + 6 * xmargin * 2;
  var availy = rowInfo.numRows * ycell + (rowInfo.numRows-1) * ymargin * 2;
  
  var scalex = availx/width;
  var scaley = availy/height;
  var scale = Math.min(scalex, scaley) * .75; // .75 to leave some space around the edges

  var usedx = scale * width;
  var usedy = scale * height;
  var left = (availx - usedx) / 2;
  var top = (availy - usedy) / 2 + rowInfo.from * (ycell + ymargin * 2);

  sheet.insertRule(".watermark-" + idx + "{ font-size: " + scale*metricFontSize + "pt; left: " + left + "px; top: " + top + "px; }");
So that's the display version sorted out, but what we're really after is the printed version. And a moment's testing will indicate that the printed version does not. For a very simple reason, which is that all of this canvas calculation is done in pixels, and all our measurements are in millimetres or inches. And we are just dividing one by the other. So we need to do a conversion. Allegedly (good enough for me) there are 96px in an inch, which is the same as 3.78px in a mm. And then we need to make sure that our units are correctly specified in the stylesheet.
  for (var i=0;i<rowInfo.months.length;i++) {
    handleWatermarks(sheet, i, rowInfo.months[i], pageSize.unitIn, xday, xmargin, yweek, ymargin);
  }
}

function handleWatermarks(sheet, idx, rowInfo, unitIn, xcell, xmargin, ycell, ymargin) {
  var mx = cx.measureText(rowInfo.text);
  var width = mx.actualBoundingBoxRight + mx.actualBoundingBoxLeft;
  var height = mx.actualBoundingBoxDescent + mx.actualBoundingBoxAscent;
  switch (unitIn) {
    case "mm": {
      width /= 3.78;
      height /= 3.78;
      break;
    }
    case "in": {
      width /= 96;
      height /= 96;
      break;
    }
  }

  var availx = 7 * xcell + 6 * xmargin * 2;
  var availy = rowInfo.numRows * ycell + (rowInfo.numRows-1) * ymargin * 2;
  var left = (availx - usedx) / 2;
  var top = (availy - usedy) / 2 + rowInfo.from * (ycell + ymargin * 2);

  sheet.insertRule(".watermark-" + idx + "{ font-size: " + scale*metricFontSize + "pt; left: " + left + unitIn + "; top: " + top + unitIn + "; }");
And everything is checked in and tagged as PLANNING_CALENDAR_WATERMARKS_PRINTED.

No comments:

Post a Comment