import { Events, Notice } from "obsidian"; import type FantasyCalendar from "src/main"; import { MOON_PHASES, Phase } from "src/utils/constants"; import { dateString, isValidDay, isValidMonth, isValidYear, wrap } from "src/utils/functions"; import type { Calendar, CurrentCalendarData, Month, Event, LeapDay, Moon } from "../@types"; export class DayHelper { private _events: Event[]; shouldUpdate: boolean = false; get calendar() { return this.month.calendar; } get date() { return { day: this.number, month: this.month.number, year: this.year }; } get events(): Event[] { if (!this._events || !this._events.length || this.shouldUpdate) { this._events = this.month.getEventsOnDay(this.date); } return this._events; } get longDate() { return { day: this.number, month: this.month.name, year: this.year }; } /** Days before this day in the year. */ get daysBefore() { return ( this.month.daysBefore + this.number - 1 - this.month.leapDays.filter( (l) => l.numbered && l.after < this.number - 1 ).length ); } get year() { return this.month.year; } get weekday() { const firstOfYear = this.calendar.firstDayOfYear(this.year); return wrap( (this.daysBefore % this.calendar.weekdays.length) + firstOfYear, this.calendar.weekdays.length ); } get isCurrentDay() { return ( this.number == this.calendar.current.day && this.month.number == this.calendar.current.month && this.month.year == this.calendar.current.year ); } get isDisplaying() { return ( this.number == this.calendar.viewing.day && this.month.year == this.calendar.viewing.year && this.month.number == this.calendar.viewing.month ); } private _moons: Array<[Moon, Phase]>; get moons() { if (!this._moons || !this._moons.length) { this._moons = this.month.getMoonsForDay(this.date); } return this._moons; } constructor( public month: MonthHelper, public number: number, public leapday?: LeapDay ) {} } export class MonthHelper { days: DayHelper[] = []; daysBefore: number; leapDays: LeapDay[] = []; shouldUpdate = false; get id() { return this.data.id; } get index() { return this.calendar.data.months.indexOf(this.data); } get name() { return this.data.name; } get length() { return this.days.length; } get firstWeekday() { if (!this.calendar.data.overflow) return 0; return this.days[0].weekday; } get lastWeekday() { return this.days[this.days.length - 1].weekday; } get type() { return this.data.type; } events: Event[]; getEventsOnDay(day: CurrentCalendarData) { if (!this.events || !this.events.length || this.shouldUpdate) { this.days.forEach((day) => (day.shouldUpdate = true)); this.events = this.calendar.getEventsForMonth(this); this.shouldUpdate = false; } return this.events.filter((event) => { if ( (!event.date.year || event.date.year == day.year) && (!event.date.month || event.date.month == day.month) && event.date.day == day.day ) return true; if (!event.end && !event.formulas?.length) return false; const start = { ...event.date }; const end = { ...(event.end ?? {}) }; if (!start.year) start.year = end.year = this.year; if (!start.month) start.month = end.month = this.number; const hash = Number(this.calendar.hash(day)); if ( Number(this.calendar.hash(start)) <= hash && hash <= Number(this.calendar.hash(end) ?? Infinity) ) { if (!event.formulas?.length) { return true; } else { const startDays = this.calendar.totalDaysBeforeYear(start.year) + this.calendar.daysBeforeMonth( start.month, start.year, true ) + start.day; const currentDays = this.calendar.totalDaysBeforeYear(day.year) + this.calendar.daysBeforeMonth( day.month, day.year, true ) + day.day; const daysBetween = currentDays - startDays; return daysBetween % event.formulas[0].number == 0; } } return false; }); } shouldUpdateMoons = false; moons: Array<[Moon, Phase]>[]; getMoonsForDay(day: CurrentCalendarData) { if (!this.moons || !this.moons.length || this.shouldUpdateMoons) { this.moons = this.calendar.getMoonsForMonth(this); } return this.moons[day.day - 1]; } constructor( public data: Month, public number: number, public year: number, public calendar: CalendarHelper ) { this.leapDays = this.calendar.leapDaysForMonth(this.number, year); this.daysBefore = this.calendar.daysBeforeMonth(this.number, this.year); this.days = [ ...new Array( data.length + this.leapDays.filter( (l) => !l.intercalary || (l.intercalary && l.numbered) ).length ).keys() ].map((k) => { return new DayHelper( this, k + 1, this.leapDays.find((leapday) => leapday.after == k) ); }); } } interface YearEventCache { events: Event[]; shouldUpdate: boolean; months: Map<number, MonthHelper>; } export default class CalendarHelper extends Events { addEvent(event: Event) { const year = event.date.year; const month = event.date.month; this.refreshMonth(month, year); } refreshMonth(month: number, year: number) { if (!this._cache.has(year)) return; if (!this._cache.get(year).months.has(month)) return; this._cache.get(year).shouldUpdate = true; this._cache .get(year) .months.forEach((month) => (month.shouldUpdate = true)); if ( (year == this.displayed.year && month == this.displayed.month) || (year == this.viewing.year && month == this.viewing.month) ) { this.trigger("month-update"); } } refreshYear(year: number) { if (!this._cache.has(year)) return; this._cache.get(year).shouldUpdate = true; this._cache .get(year) .months.forEach((month) => (month.shouldUpdate = true)); if (year == this.displayed.year || year == this.viewing.year) { this.trigger("month-update"); } } standardMonths: Month[]; /** * Get a day helper from cache for a given date calendar. */ getDayForDate(date: CurrentCalendarData): DayHelper { const month = this.getMonth(date.month, date.year); const day = month.days[date.day - 1]; return day; } /** * Get all the events that occur in a given month. */ getEventsForMonth(helper: MonthHelper): Event[] { //get from cache first //else const { year, number: month } = helper; if (!this._cache.has(year)) { this._cache.set(year, { events: [], shouldUpdate: true, months: new Map() }); } if (this._cache.get(year).shouldUpdate) { const events = this.calendar.events.filter((event) => { const date = { ...event.date }; const end = { ...event.end }; //Year and Month match if (date.year == year || date.year == undefined) return true; //Event is after the month if (date.year > year) return false; //No end date and event is before the month if (!end && !event.formulas?.length && date.year < year) return false; if ( date.year <= year && (end?.year >= year || event.formulas?.length) ) return true; return false; }); this._cache.set(year, { months: this._cache.get(year).months, events, shouldUpdate: false }); } const events = this._cache.get(year).events.filter((event) => { const date = { ...event.date }; const end = { ...event.end }; //No-month events are on every month. if (date.month == undefined) return true; //Year and Month match if ( (date.year == year || date.year == undefined) && date.month == month ) return true; //Event is after the month if (date.year > year || (date.year == year && date.month > month)) return false; //No end date and event is before the month if ( !end && !event.formulas?.length && (date.month != month || date.year < year) ) return false; if (date.year == undefined) end.year = date.year = year; if ( (date.year <= year || date.month <= month) && (event.formulas?.length || (end.year >= year && end.month >= month)) ) return true; return false; }); return events; } /** * Get the display name for a year. Used mainly for custom years. */ getNameForYear(year: number): string { if (!this.data.useCustomYears) return `${year}`; if ( this.data.useCustomYears && year - 1 >= 0 && year <= this.data.years?.length ) { return this.data.years[year - 1].name; } } /** * Maximum number of days possible in a year. */ maxDays: number; /** * Options alias. */ get displayWeeks() { return this.calendar.displayWeeks; } /** * Creates an instance of CalendarHelper. * @param {Calendar} calendar * @param {FantasyCalendar} plugin * @memberof CalendarHelper */ constructor(public calendar: Calendar, public plugin: FantasyCalendar) { super(); this.displayed = { ...this.current }; this.update(this.calendar); this.plugin.registerEvent( this.plugin.app.workspace.on( "fantasy-calendars-event-update", (tree) => { if (!tree.has(this.calendar.id)) return; const years = tree.get(this.calendar.id); for (const year of years) { if (!this._cache.has(year)) continue; this.refreshYear(year); } } ) ); } /** * Cache used to store built month helpers, events, and whether a year should update. */ private _cache: Map<number, YearEventCache> = new Map(); /** * Get an array of month helpers for an entire year. */ getMonthsForYear(year: number) { if (!this._cache.has(year)) { this._cache.set(year, { events: [], shouldUpdate: true, months: new Map( this.data.months.map((m, i) => [ i, new MonthHelper(m, i, year, this) ]) ) }); } if (this._cache.get(year).months.size != this.data.months.length) { this._cache.set(year, { ...this._cache.get(year), months: new Map( this.data.months.map((m, i) => [ i, new MonthHelper(m, i, year, this) ]) ) }); } return Array.from(this._cache.get(year).months.values()); } /** * Get a hash of a given date. * * Hash takes the form of `YYYYMMDD`, with months and days padded to the maximum value. */ hash(date: Partial<CurrentCalendarData>) { if (date.year == null || date.month == null || date.day == null) return null; const months = `${this.data.months.length}`.length; const month = `${date.month}`.padStart(months, "0"); const days = `${this.maxDays}`.length; const day = `${date.day}`.padStart(days, "0"); return `${date.year}${month}${day}`; } /** * Update the calendar object to a new calendar. */ update(calendar?: Calendar) { this.calendar = calendar ?? this.calendar; this.maxDays = Math.max(...this.data.months.map((m) => m.length)); this.standardMonths = this.data.months.filter( (m) => m.type != "intercalary" ); if (!this.calendar?.current) { this.calendar.current = { day: null, month: null, year: null }; } if (!isValidYear(this.calendar?.current.year, this.calendar)) { this.calendar.current.year = 1; } if (!isValidMonth(this.calendar?.current.month, this.calendar)) { this.calendar.current.month = 0; } if (!isValidDay(this.calendar?.current.day, this.calendar)) { this.calendar.current.day = 1; } this.trigger("month-update"); this.trigger("day-update"); } /** * Alias for calendar categories. */ get categories() { return this.calendar.categories; } /** * Alias for calendar static data. */ get data() { return this.calendar.static; } /** * Alias for calendar current date. */ get current() { return this.calendar.current; } /** * Alias for calendar leap days data. */ get leapdays() { return this.data.leapDays ?? []; } /** * Used to track currently displayed date on the calendar. * Probably just need to track month and year... or a MonthHelper. */ displayed: CurrentCalendarData = { year: null, month: null, day: null }; /** * Used to track current viewed date (day view) on the calendar. * Probably just need to track a DayHelper. */ viewing: CurrentCalendarData = { year: null, month: null, day: null }; /** * Display string for current date. */ get currentDate() { return dateString(this.current, this.data.months); } /** * Display string for displayed date. */ get displayedDate() { return dateString(this.displayed, this.data.months); } /** * Display string for viewed date. */ get viewedDate() { return dateString(this.viewing, this.data.months); } /** * Reset a calendar to display current date. */ reset() { this.displayed = { ...this.current }; this.viewing = { ...this.current }; this.trigger("month-update"); this.trigger("day-update"); } /** * Set the current displayed month. */ setCurrentMonth(n: number) { this.displayed.month = n; this.trigger("month-update"); } /** * Increment viewed day and overflow months and years as necessary. */ goToNextDay() { const day = this.getDayForDate(this.viewing); this.viewing.day += 1; if (this.viewing.day > day.month.days.length) { this.goToNext(); this.viewing.month = this.displayed.month; this.viewing.year = this.displayed.year; this.viewing.day = 1; } this.trigger("day-update"); } /** * Increment current day and overflow months and years as necessary. */ goToNextCurrentDay() { this.current.day += 1; const currentMonth = this.getMonth( this.current.month, this.current.year ); if (this.current.day >= currentMonth.days.length) { this.current.day = 1; this.current.month += 1; if (this.current.month >= this.data.months.length) { this.current.month = 0; this.current.year += 1; } } this.trigger("day-update"); } /** * Get the index of the next month to be displayed, wrapping as necessary. */ get nextMonthIndex() { return wrap(this.displayed.month + 1, this.data.months.length); } /** * Get the MonthHelper of the next month to be displayed. */ get nextMonth() { return this.getMonth(this.displayed.month + 1, this.displayed.year); } /** * Check if the calendar can increment year. Always returns true unless the calendar has custom years defined. */ canGoToNextYear(year = this.displayed.year) { return !this.data.useCustomYears || year < this.data.years.length; } getNextMonth() { if (this.plugin.data.showIntercalary) { return this.getMonth(this.displayed.month + 1, this.displayed.year); } else { return this.getDirectionalStandardMonthHelper(1); } } getNextMonthIndex() { const month = this.getNextMonth(); return this.data.months.indexOf(month.data); } getPreviousMonth() { if (this.plugin.data.showIntercalary) { return this.getMonth(this.displayed.month - 1, this.displayed.year); } else { return this.getDirectionalStandardMonthHelper(-1); } } getPreviousMonthIndex() { const month = this.getPreviousMonth(); return this.data.months.indexOf(month.data); } getDirectionalStandardMonthHelper( direction: 1 | -1, year = this.displayed.year ) { const index = this.getDirectionalStandardMonth(direction); return this.getMonth(index, year); } getDirectionalStandardMonth(direction: 1 | -1) { const current = this.data.months[this.displayed.month]; const standardIndex = this.standardMonths.indexOf(current); const directionIndex = wrap( standardIndex + direction, this.standardMonths.length ); const index = this.data.months.indexOf( this.standardMonths[directionIndex] ); return index; } /** * Go to the next month index. Used to change months on the calendar. */ goToNext() { const index = this.getNextMonthIndex(); if (index < this.displayed.month) { if (!this.canGoToNextYear()) { new Notice( "This is the last year. Additional years can be created in settings." ); return; } this.goToNextYear(); } this.setCurrentMonth(index); } /** * Go to the next year index. Used to change years on the calendar. */ goToNextYear() { this.displayed.year += 1; this.trigger("year-update"); } /** * Get the index of the previous month to be displayed, wrapping as necessary. */ get prevMonthIndex() { return wrap(this.displayed.month - 1, this.data.months.length); } /** * Get the MonthHelper of the previous month to be displayed. */ get previousMonth() { return this.getMonth(this.displayed.month - 1, this.displayed.year); } /** * Go to the previous month index. Used to change months on the calendar. */ goToPrevious() { const index = this.getPreviousMonthIndex(); if (index > this.displayed.month) { if (this.displayed.year == 1) { new Notice("This is the earliest year."); return; } this.goToPreviousYear(); } this.setCurrentMonth(index); } /** * Go to the viewed previous day. Used to change days on the day view. */ goToPreviousDay() { this.viewing.day -= 1; if (this.viewing.day < 1) { this.goToPrevious(); this.viewing.month = this.displayed.month; this.viewing.year = this.displayed.year; this.viewing.day = this.currentMonth.days.length; } this.trigger("day-update"); } /** * Go to the previous year index. Used to change years on the calendar. */ goToPreviousYear() { this.displayed.year -= 1; this.trigger("year-update"); } /** * Alias for calendar data weekdays. */ get weekdays() { return this.data.weekdays; } /** * Get the MonthHelper for the currently displayed month. */ get currentMonth() { return this.getMonth(this.displayed.month, this.displayed.year); } /** * Test if a leap day occurs in a given year. */ testLeapDay(leapday: LeapDay, year: number) { return leapday.interval .sort((a, b) => a.interval - b.interval) .some(({ interval, exclusive }, index, array) => { if (exclusive && index == 0) { return (year - leapday.offset ?? 0) % interval != 0; } if (exclusive) return; if (array[index + 1] && array[index + 1].exclusive) { return ( (year - leapday.offset ?? 0) % interval == 0 && (year - leapday.offset ?? 0) % array[index + 1].interval != 0 ); } return (year - leapday.offset ?? 0) % interval == 0; }); } /** * Get all leapdays that occur in a given year. */ leapDaysForYear(year: number) { return this.leapdays.filter((l) => { return this.testLeapDay(l, year); }); } /** * Get all leapdays that occur in a given month in a specific year. */ leapDaysForMonth(month: number, year = this.displayed.year) { return this.leapdays.filter((l) => { if (l.timespan != month) return false; return this.testLeapDay(l, year); }); } /** * Get a MonthHelper for a month number in a specific year, wrapping the month number as necessary. * * Will prioritize pulling a MonthHelper from the cache. * * Direction is used to skip intercalary months. */ getMonth(number: number, year: number, direction: number = 0): MonthHelper { const months = this.data.months; let index = wrap(number, months.length); if (number < 0) year -= 1; if (year == 0) return null; if (number >= months.length) year += 1; if (this._cache.has(year)) { if (this._cache.get(year)!.months.has(index)) { return this._cache.get(year)!.months.get(index); } } else { this._cache.set(year, { events: [], shouldUpdate: true, months: new Map() }); } if (months[index].type == "intercalary" && direction != 0) { return this.getMonth(number + direction, year, direction); } const helper = new MonthHelper(months[index], index, year, this); this._cache.get(year).months.set(index, helper); this._cache.set(year, this._cache.get(year)); return helper; } /** * Get the padded days for a given month. * * This is used to display the "overflowed" days from the previous and next month on the calendar. * * This has the side benefit of pre-caching the previous and next months, so they are built when switched to. */ getPaddedDaysForMonth(month: MonthHelper) { let current = month.days; /** Get Days of Previous Month */ let previous: DayHelper[] = []; const previousMonth = this.getMonth( month.index - 1, this.displayed.year, -1 ); if (month.firstWeekday > 0 && month.type == "month") { previous = previousMonth != null ? previousMonth.days.slice(-month.firstWeekday) : Array(month.firstWeekday).fill(null); } /** Get Days of Next Month */ let next: DayHelper[] = []; const nextMonth = this.getMonth( month.index + 1, this.displayed.year, 1 ); if ( month.lastWeekday < this.weekdays.length - 1 && month.type == "month" ) { next = nextMonth.days.slice( 0, this.weekdays.length - month.lastWeekday - 1 ); } return { previous, current, next }; } /** * Returns the rounded up number of weeks of the current month. Use to build calendar rows. */ get weeksPerCurrentMonth() { return Math.ceil( this.getMonth(this.displayed.month, this.displayed.year).length / this.data.weekdays.length ); } /** * Get the number of weeks in a given month. */ weeksOfMonth(month: MonthHelper) { return Math.ceil( (month.length + month.firstWeekday) / this.data.weekdays.length ); } /** * Get the first week number of a given month. * * TODO: Figure out how to add in ISO spec compliance here. */ weekNumbersOfMonth(month: MonthHelper) { const daysBefore = month.daysBefore + this.firstDayOfYear(month.year); return Math.floor(daysBefore / this.data.weekdays.length); } /** * Total number of days in a year. Does not include leap days. */ get daysPerYear() { return this.data.months .filter((m) => m.type === "month") .reduce((a, b) => a + b.length, 0); } /** * Get the total number of days in a year before a given month. */ daysBeforeMonth(month: number, year: number, all: boolean = false) { if (!month || month == 0) return 0; return this.data.months .slice(0, month) .filter((m) => (all ? true : m.type == "month")) .map((m, i) => { const leapdays = this.leapDaysForMonth(i, year); return m.length + leapdays.filter((l) => !l.intercalary).length; }) .reduce((a, b) => a + b, 0); } dayNumberForDate(date: CurrentCalendarData) { return this.daysBeforeMonth(date.month, date.year, true) + date.day; } get firstWeekday() { return this.data.firstWeekDay; } /** * Alias to get the total number of leap days that have occured before the currently displayed year. */ get leapDaysBefore() { if (this.displayed.year == 1) return 0; return this.leapDaysBeforeYear(this.displayed.year - 1); } /** Get Total Number of Leap Days before a given year * @param tester Year to find leap days before NOT INCLUDING THIS YEAR */ leapDaysBeforeYear(tester: number) { /** If we're checking year 1, there are no leap days. */ if (tester == 1) return 0; /** Subtract 1 from tester. We're looking for leap days BEFORE the year. */ const year = tester - 1; let total = 0; /** Iterate over each leap day. */ for (const { interval, offset } of this.leapdays.filter( (l) => !l.intercalary )) { let leapdays = 0; /** Iterate over each condition on each leapday. */ for (let i = 0; i < interval.length; i++) { const condition = interval[i]; /** Determine how many leap days match non-exclusive rules AFTER this rule. * This has to be done to avoid "double-counting" days for days that match multiple rules. */ const rest = interval .slice(i + 1) .filter((c) => !c.exclusive) .map((c) => Math.floor( (year + (c.ignore ? 0 : offset)) / c.interval ) ) .reduce((a, b) => a + b, 0); /** Calculate how many days match this rule. */ const calc = Math.floor( (year + (condition.ignore ? 0 : offset)) / condition.interval ); if (condition.exclusive) { /** If the rule is exlusive, subtract the result from the total, then add in the rest. */ leapdays -= calc; leapdays += rest; } else { /** If the rule is exlusive, add the result to the total, then subtract out the rest. */ leapdays += calc; leapdays -= rest; } } total += leapdays; } return total; } /** * Alias to get the total number of days before the currently displayed year. */ get totalDaysBefore() { return this.totalDaysBeforeYear(this.displayed.year); } /** * Get the total number of days before a given year. */ totalDaysBeforeYear(year: number, all = false) { if (year < 1) return 0; return ( Math.abs(year - 1) * this.data.months .filter((m) => all || m.type == "month") .reduce((a, b) => a + b.length, 0) + this.leapDaysBeforeYear(year) ); } /** * Get the weekday of a given year. */ firstDayOfYear(year = this.displayed.year) { if (!this.data.overflow) return 0; if (year == 1) return this.firstWeekday; return wrap( (this.totalDaysBeforeYear(year) % this.data.weekdays.length) + this.firstWeekday + (this.data.offset ?? 0), this.data.weekdays.length ); } /** * Alias to get the moon data. */ get moons() { return this.data.moons; } /** * Get the moons and their phases for a given month. */ getMoonsForMonth(month: MonthHelper): Array<[Moon, Phase]>[] { const phases: Array<[Moon, Phase]>[] = []; for (const day of month.days) { const daysBefore = this.totalDaysBeforeYear(month.year, true) + this.daysBeforeMonth(month.number, month.year, true) + day.number - 1; const moons: Array<[Moon, Phase]> = []; for (let moon of this.moons) { const { offset, cycle } = moon; const granularity = 24; let data = (daysBefore - offset) / cycle; let position = data - Math.floor(data); const phase = (position * granularity) % granularity; const options = MOON_PHASES[granularity]; moons.push([ moon, options[wrap(Math.round(phase), options.length)] ]); } phases.push(moons); } return phases; } }