--- --- import { stringLookup } from "../strings.mjs"; import { announceForAccessibility } from "../PageAlert.mjs"; import DateUtil from "../DateUtil.mjs"; const DATE_SPEC_ELEM_TAG = "h1"; const LIST_SPEC_ELEM_TAG = "ul"; const CALENDAR_DATE_FMT_OPTIONS = {{ site.hematite.short_date_format | default: nil | jsonify }} ?? { day: "2-digit", year: "2-digit", month: "2-digit" }; // Used for generating unique IDs let nextViewModeSelectorId = 0; /// Pull calendar data from [elem]. If [formatElemLabels], apply special calendar markup /// to the contents of [elem], changing [elem]. function getCalendarData(elem, formatElemLabels) { let result = []; // Last date set by a header we've encountered let lastDate = null; let lastHeaderId = ""; for (const child of elem.children) { let tagName = child.tagName.toLowerCase(); if (tagName == DATE_SPEC_ELEM_TAG) { // Remove '-rd' and '-th' suffixes. try { lastDate = DateUtil.parse(child.innerText); lastHeaderId = child.getAttribute("id"); } catch (e) { child.innerText = stringLookup(`invalid_date`, dateText); lastDate = null; } } if (tagName == LIST_SPEC_ELEM_TAG && lastDate) { let listItems = []; for (const item of child.children) { if (item.tagName.toLowerCase() == "li") { let agendaItem = new AgendaItem(item.innerHTML); listItems.push(agendaItem); // Reformat tags in the item, if requested. if (formatElemLabels) { let itemTags = document.createElement('span'); let itemContent = document.createElement('span'); for (const tag of agendaItem.tags) { let tagLink = document.createElement('a'); tagLink.classList.add('tag'); tagLink.href = `{{ 'assets/html/all_tags.html' | relative_url }}#tag__${escape(tag)}`; tagLink.innerText = tag; tagLink.classList.add(AgendaItem.getTagClass(tag)); itemTags.appendChild(tagLink); } itemContent.innerHTML = agendaItem.html; item.replaceChildren(itemTags, itemContent); } } } result.push({ agenda: listItems, date: lastDate, link: `#${lastHeaderId}` }); } } result.sort(AgendaItem.compare); return result; } /// Adds post data to the given calendar data item. function addPostData(data) { let postDates = [ {% for item in site.posts %} {{ item.date | date_to_xmlschema | jsonify }}, {% endfor %} ].map((dateItem) => DateUtil.parse(dateItem) ); let postTags = {{ site.posts | map: "tags" | jsonify }}; let postTitles = {{ site.posts | map: "title" | jsonify }}; let postLinks = [ {% for item in site.posts %} {{ item.url | relative_url | jsonify }}, {% endfor %} ]; if (postDates.length != postTitles.length || postLinks.length != postTitles.length) { console.warn("Some post data array has a different length, refusing to show posts."); return; } for (let i = 0; i < postDates.length; i++) { data.push({ date: postDates[i], agenda: [ AgendaItem.forPost(postTitles[i], postTags[i], postLinks[i]) ], link: undefined, }); } data.sort(AgendaItem.compare); let newData = []; let currentItem; for (let i = 0; i < data.length; i++) { if (!currentItem) { currentItem = { date: data[i].date, agenda: [], }; newData.push(currentItem); } if (DateUtil.datesAreOnSameDay(currentItem.date, data[i].date)) { for (const item of data[i].agenda) { currentItem.agenda.push(item); } currentItem.link ??= data[i].link; } else { currentItem = data[i]; newData.push(currentItem); } } return newData; } class AgendaItem { /// Creates an AgendaItem from text [data] which is made up of /// HTML and leading format tags. constructor(data) { let htmlStart = 0; this.tags = []; for (const match of data.matchAll(/[\[](\w+)[\]]/g)) { this.tags.push(match[1]); htmlStart = match.index + match[0].length; } this.html = data.substring(htmlStart); } } AgendaItem.forPost = (title, tags, url) => { let result = new AgendaItem(''); title = title .replaceAll(/[>]/g, '>') .replaceAll(/[<]/g, '<'); result.html = `${title}`; result.tags = [...tags]; return result; }; AgendaItem.getTagClass = (tag) => { return `calendarTag__${tag}`; }; AgendaItem.compare = (a, b) => { if (a.date < b.date) { return -1; } if (a.date > b.date) { return 1; } return 0; }; class Calendar { VIEW_MODE_MONTH = 1; VIEW_MODE_WEEK = 2; VIEW_MODE_DAY = 3; constructor(data, containerElem, headerElem) { this.mode_ = this.VIEW_MODE_WEEK; this.container_ = document.createElement("div"); this.header_ = headerElem ?? null; this.data_ = data; this.anchorDate_ = this.closestItemDateTo_(new Date())?.date ?? new Date(); this.updateLayout_(); containerElem.appendChild(this.container_); } /// Returns the item with closest date to [searchDate]. closestItemDateTo_(searchDate) { let i = Math.floor(this.data_.length / 2); let lastI; let searchStart = 0; let searchStop = this.data_.length; let isBetterMatch = (otherIdx) => { if (0 > otherIdx || otherIdx >= this.data_.length) { return false; } let dtOther = this.data_[otherIdx].date.getTime() - searchDate.getTime(); let dtCurrent = this.data_[i].date.getTime() - searchDate.getTime(); if (Math.abs(dtOther) < Math.abs(dtCurrent)) { return true; } return false; }; // Binary search do { lastI = i; // Bounds check if (0 > i || i >= this.data_.length) { break; } let current = this.data_[i]; if (DateUtil.datesAreOnSameDay(current.date, searchDate)) { return current; } if (current.date > searchDate) { searchStop = i; } else if (current.date < searchDate) { searchStart = i + 1; } i = Math.floor((searchStart + searchStop) / 2); } while (lastI != i); if (0 <= i && i < this.data_.length) { if (isBetterMatch(i + 1)) { return this.data_[i + 1]; } else if (isBetterMatch(i - 1)) { return this.data_[i - 1]; } return this.data_[i]; } return null; } /// Get an item in [data_] from [date], if [searchDate] /// is the same day as the requested item. If there /// are multiple matches, one of them is returned. lookupItem_(searchDate) { let closest = this.closestItemDateTo_(searchDate); if (closest == null || !DateUtil.datesAreOnSameDay(closest.date, searchDate)) { return null; } return closest; } createCardForDay_(date) { let card = document.createElement("div"); let header = document.createElement("a"); let details = document.createElement("ul"); let content = this.lookupItem_(date); card.classList.add("calendar-card"); header.innerText = date.toLocaleDateString(CALENDAR_DATE_FMT_OPTIONS); if (content) { if (content.link) { header.href = content.link; } for (let itemData of content.agenda) { let container = document.createElement("li"); container.innerHTML = itemData.html; for (const tag of itemData.tags) { container.classList.add(AgendaItem.getTagClass(tag)); } details.appendChild(container); } } if (DateUtil.dateIsToday(date)) { card.classList.add("today"); } card.appendChild(header); card.appendChild(details); return card; } updateLayout_() { this.content_?.remove(); this.content_ = document.createElement("div"); this.content_.classList.add("calendar-content"); let startDate = this.anchorDate_; let endDate = DateUtil.nextDay(this.anchorDate_); if (this.mode_ == this.VIEW_MODE_WEEK) { startDate = DateUtil.beginningOfWeek(this.anchorDate_); endDate = DateUtil.nextWeek(startDate); this.content_.classList.add("week-display"); } else if (this.mode_ == this.VIEW_MODE_MONTH) { startDate = DateUtil.beginningOfWeek(DateUtil.beginningOfMonth(this.anchorDate_)); endDate = DateUtil.beginningOfMonth(DateUtil.nextMonth(this.anchorDate_)); this.content_.classList.add("month-display"); console.log(startDate, endDate); } for (const date of DateUtil.daysInRange(startDate, endDate)) { this.content_.appendChild(this.createCardForDay_(date)); } if (this.header_) { let startStr = startDate.toLocaleDateString(CALENDAR_DATE_FMT_OPTIONS); let endStr = endDate.toLocaleDateString(CALENDAR_DATE_FMT_OPTIONS); this.header_.innerText = stringLookup(`calendar_header_date_range`, startStr, endStr); } this.container_.appendChild(this.content_); } /// Get the next anchor (i.e. advance the anchor by a week, month, /// day, etc. getNextAnchor_() { let result = this.anchorDate_; if (this.mode_ == this.VIEW_MODE_WEEK) { result = DateUtil.nextWeek(this.anchorDate_); } else if (this.mode_ == this.VIEW_MODE_DAY) { result = DateUtil.nextDay(this.anchorDate_); } else { result = DateUtil.nextMonth(this.anchorDate_); } return result; } getPrevAnchor_() { let result = this.anchorDate_; if (this.mode_ == this.VIEW_MODE_WEEK) { result = DateUtil.prevWeek(this.anchorDate_); } else if (this.mode_ == this.VIEW_MODE_DAY) { result = DateUtil.prevDay(this.anchorDate_); } else { result = DateUtil.prevMonth(this.anchorDate_); } return result; } /// Transition to the next time unit. next() { this.anchorDate_ = this.getNextAnchor_(); this.updateLayout_(); } prev() { this.anchorDate_ = this.getPrevAnchor_(); this.updateLayout_(); } setMode(mode) { this.mode_ = mode; this.updateLayout_(); } getMode() { return this.mode_; } getLocalizedMode() { if (this.mode_ == this.VIEW_MODE_WEEK) { return stringLookup(`calendar_mode_week`); } else if (this.mode_ == this.VIEW_MODE_DAY) { return stringLookup(`calendar_mode_day`); } return stringLookup(`calendar_mode_month`); } } /// Creates a visual calendar, pulling input from [inputElem] /// and writing output to [outputElem]. If [includePosts], all post-formatted /// articles are also included. function calendarSetup(sourceElem, outputElem, calendarTitleElem, includePosts) { let data = getCalendarData(sourceElem, true); if (includePosts) { data = addPostData(data); } let controlsContainer = document.createElement("div"); controlsContainer.classList.add('controls'); outputElem.appendChild(controlsContainer); let calendar = new Calendar(data, outputElem, calendarTitleElem); let viewModeContainer = document.createElement("div"); let viewModeLabel = document.createElement("label"); let viewModeSelector = document.createElement("select"); viewModeSelector.innerHTML = ` `; viewModeSelector.setAttribute("id", `viewModeSelector${nextViewModeSelectorId}`); viewModeLabel.setAttribute("for", `viewModeSelector${nextViewModeSelectorId++}`); let nextBtn = document.createElement("button"); let prevBtn = document.createElement("button"); viewModeLabel.innerText = stringLookup(`calendar_choose_view_mode`); // Update elements/controls based on the current calendar mode. let updateModeLabels = () => { let mode = calendar.getLocalizedMode(); nextBtn.innerText = stringLookup(`calendar_next_btn`, mode); prevBtn.innerText = stringLookup(`calendar_prev_btn`, mode); viewModeSelector.value = calendar.getMode(); }; updateModeLabels(); nextBtn.onclick = () => { calendar.next(); let mode = calendar.getLocalizedMode(); announceForAccessibility(stringLookup(`calendar_went_next`, mode)); }; prevBtn.onclick = () => { calendar.prev(); let mode = calendar.getLocalizedMode(); announceForAccessibility(stringLookup(`calendar_went_prev`, mode)); }; viewModeSelector.onchange = () => { calendar.setMode(parseInt(viewModeSelector.value)); updateModeLabels(); let mode = calendar.getLocalizedMode(); announceForAccessibility(stringLookup(`calendar_changed_mode`, mode)); }; viewModeContainer.replaceChildren(viewModeLabel, viewModeSelector); controlsContainer.replaceChildren(prevBtn, viewModeContainer, nextBtn); } export default calendarSetup;