UNPKG

@teipublisher/pb-components

Version:
555 lines (525 loc) 17.3 kB
import { get as i18n } from './pb-i18n.js'; export class SearchResultService { /* * SEARCH RESULT OBJECT * Service that loads initial data from a datasource, * can be a database or an API, and converts it in * a format that can be used by the pb-timeline component. * * public methods: * getMinDateStr() * getMaxDateStr() * getMinDate() * getMaxDate() * export() * getIntervalSizes() */ /* * CONSRTUCTOR INPUTS EXPLAINED * jsonData: data to load, object with * keys => valid datestrings formatted YYYY-MM-DD * values => number of results for this day * maxInterval: max amount of bins allowed * scopes: array of all 6 possible values for scope */ constructor(jsonData = {}, maxInterval = 60, scopes = ['D', 'W', 'M', 'Y', '5Y', '10Y']) { this.data = { invalid: {}, valid: {} }; this.maxInterval = maxInterval; this.scopes = scopes; this._validateJsonData(jsonData); } /* * based on the loaded jsonData, compute * - min date as dateStr or utc-date-object * - max date as dateStr or utc-date-object */ getMinDateStr() { return Object.keys(this.data.valid).sort()[0]; } getMaxDateStr() { const days = Object.keys(this.data.valid); return days.sort()[days.length - 1]; } getMinDate() { return this._dateStrToUTCDate(this.getMinDateStr()); } getMaxDate() { return this._dateStrToUTCDate(this.getMaxDateStr()); } getEndOfRangeDate(scope, date) { return this._UTCDateToDateStr(this._increaseDateBy(scope, date)); } /* * exports data for each scope * when no argument is provided, the optimal scope based * on the maxInterval (default 60) will be assigned */ export(scope) { // auto assign scope when no argument provided scope = scope || this._autoAssignScope(); // validate scope if (!this.scopes.includes(scope)) { throw new Error( `invalid scope provided, expected: ["10Y", "5Y", "Y", "M", "W", "D"]. Got: "${scope}"`, ); } // initialize object to export const exportData = { data: [], scope, binTitleRotated: this._binTitleRotatedLookup(scope), }; if (Object.keys(this.data.valid).length === 0) { return exportData; } // get start and end date const startCategory = this._classify(this.getMinDateStr(), scope); const startDateStr = this._getFirstDay(startCategory); let currentDate = this._dateStrToUTCDate(startDateStr); const endDate = this.getMaxDate(); // iterate until end of intervall reached, add binObject for each step while (currentDate <= endDate) { const currentDateStr = this._UTCDateToDateStr(currentDate); const currentCategory = this._classify(currentDateStr, scope); exportData.data.push(this._buildBinObject(currentDateStr, currentCategory, scope)); currentDate = this._increaseDateBy(scope, currentDate); } // count all values Object.keys(this.data.valid) .sort() .forEach(dateStr => { const currentCategory = this._classify(dateStr, scope); const targetBinObject = exportData.data.find(it => it.category === currentCategory); try { const value = this.data.valid[dateStr]; if (typeof value === 'object') { targetBinObject.value += value.count || 0; if (value.info) { targetBinObject.info = targetBinObject.info.concat(value.info); } } else { targetBinObject.value += this.data.valid[dateStr] || 0; } } catch (e) { console.log(e); console.log('currentCategory'); console.log(currentCategory); } }); if (this.data.invalid) { let invalid = 0; let info = []; Object.values(this.data.invalid).forEach(value => { if (typeof value === 'object') { invalid += value.count || 0; info = info.concat(value.info); } else { invalid += value; } }); if (invalid > 0) { exportData.data.push({ tooltip: i18n('timeline.unknown'), title: i18n('timeline.unknown'), // binTitle: i18n('timeline.unknown'), category: '?', separator: true, value: invalid, info, }); } } return exportData; } /* * returns optimal scope based on the maxInterval * by computing the scope that meets the criteria * nbr of bins <= maxInterval */ _autoAssignScope() { for (let i = 0; i < this.scopes.length; i++) { if (this._computeIntervalSize(this.scopes[i]) <= this.maxInterval) { return this.scopes[i]; } } throw new Error( `Interval too big! Computed: ${this._computeIntervalSize(this.scopes[i])}. Allowed: ${ this.maxInterval }. Try to increase maxInterval.`, ); } /* * splits input data in 2 sections * => valid data * => invalid (if not a vaid date, for example 2012-00-00 is invalid) */ _validateJsonData(jsonData) { Object.keys(jsonData) .sort() .forEach(key => { if (this._isValidDateStr(key)) { this.data.valid[key] = jsonData[key]; } else { this.data.invalid[key] = jsonData[key]; } }); } /* * lookup table which bin titles should be rotated */ _binTitleRotatedLookup(scope) { const lookup = { '10Y': true, '5Y': true, Y: true, M: false, // only exception not to rotate in monthly scope W: true, D: true, }; return lookup[scope]; } /* * Helper method that builds a binObject that * can be read by the pb-timeline component */ _buildBinObject(dateStr, category, scope) { const split = dateStr.split('-'); const yearStr = split[0]; const monthStr = split[1]; const dayStr = split[2]; // for all scopes this remains the same const binObject = { dateStr, category, value: 0, info: [], }; // scope specific bin data if (scope === '10Y') { binObject.tooltip = `${category} - ${Number(category) + 9}`; // 1900 - 1999 binObject.selectionStart = `${category}`; binObject.selectionEnd = `${Number(category) + 9}`; // seperator every 100 years (10 bins) if (Number(category) % 100 === 0) { binObject.title = `${category} - ${Number(category) + 99}`; binObject.binTitle = category; binObject.seperator = true; } } else if (scope === '5Y') { binObject.tooltip = `${category} - ${Number(category) + 4}`; // 1995 - 1999 binObject.selectionStart = `${category}`; binObject.selectionEnd = `${Number(category) + 4}`; // seperator every 50 years (10 bins) if (Number(category) % 50 === 0) { binObject.title = `${category} - ${Number(category) + 49}`; binObject.binTitle = category; binObject.seperator = true; } } else if (scope === 'Y') { binObject.tooltip = category; binObject.selectionStart = category; binObject.selectionEnd = category; // seperator every 10 years (10 bins) if (Number(category) % 10 === 0) { binObject.title = `${category} - ${Number(category) + 9}`; binObject.binTitle = `${category}`; binObject.seperator = true; } } else if (scope === 'M') { const monthNum = Number(monthStr); const month = this._monthLookup(monthNum); // Jan,Feb,Mar,...,Nov,Dez binObject.binTitle = month[0]; // J,F,M,A,M,J,J,..N,D binObject.tooltip = `${month} ${yearStr}`; // May 1996 binObject.selectionStart = `${month} ${yearStr}`; binObject.selectionEnd = `${month} ${yearStr}`; // every first of the month if (monthNum === 1) { binObject.title = yearStr; // YYYY binObject.seperator = true; } } else if (scope === 'W') { const week = category.split('-')[1]; // => W52 binObject.tooltip = `${yearStr} ${week}`; // 1996 W52 binObject.selectionStart = `${yearStr} ${week}`; // 1996 W52 binObject.selectionEnd = `${yearStr} ${week}`; // 1996 W52 const currentDate = this._dateStrToUTCDate(dateStr); const lastWeek = this._addDays(currentDate, -7); // title and binTitle every first monday of the month if (currentDate.getUTCMonth() !== lastWeek.getUTCMonth()) { binObject.binTitle = week; binObject.title = this._monthLookup(currentDate.getUTCMonth() + 1); } // seperator every start of the year binObject.seperator = week === 'W1'; } else if (scope === 'D') { binObject.tooltip = dateStr; binObject.selectionStart = dateStr; binObject.selectionEnd = dateStr; // every monday if (this._dateStrToUTCDate(dateStr).getUTCDay() === 1) { binObject.binTitle = `${Number(dayStr)}.${Number(monthStr)}`; binObject.title = `${this._classify(dateStr, 'W').replace('-', ' ')}`; binObject.seperator = true; } } else { throw new Error( `invalid scope provided, expected: ["10Y", "5Y", "Y", "M", "W", "D"]. Got: "${scope}"`, ); } return binObject; } /* * ...classifies dateStr into category (based on scope) * EXAMPLES: * _classify("2016-01-12", "10Y") // => "2010" * _classify("2016-01-12", "5Y") // => "2015" * _classify("2016-01-12", "Y") // => "2016" * _classify("2016-01-12", "M") // => "2010-01" * _classify("2016-01-12", "W") // => "2016-W2" * _classify("2016-01-12", "D") // => "2016-01-12" */ _classify(dateStr, scope) { // returns category (as string) if (!dateStr.match(/^\d{4}-\d{2}-\d{2}$/)) { // quick validate dateStr throw new Error(`invalid dateStr format, expected "YYYY-MM-DD", got: "${dateStr}".`); } if (!dateStr || !scope) { // both inputs provided throw new Error(`both inputs must be provided. Got dateStr=${dateStr}, scope=${scope}`); } switch (scope) { case '10Y': case '5Y': const intervalSize = Number(scope.replace('Y', '')); const startYear = Math.floor(Number(dateStr.split('-')[0]) / intervalSize) * intervalSize; return startYear.toString(); case 'Y': return dateStr.substr(0, 4); case 'M': return dateStr.substr(0, 7); case 'W': const UTCDate = this._dateStrToUTCDate(dateStr); return this._UTCDateToWeekFormat(UTCDate); case 'D': return dateStr; } } /* * ...gets first day as UTC Date, based on the category * EXAMPLES: * _getFirstDay("2010") // => 2010-01-01 * _getFirstDay("2010-12") // => 2010-12-01 * _getFirstDay("2010-W10") // => 2010-03-08 */ _getFirstDay(categoryStr) { if (categoryStr.match(/^\d{4}-\d{2}-\d{2}$/)) { // YYYY-MM-DD => return same value return categoryStr; } if (categoryStr.match(/^\d{4}-\d{2}$/)) { // YYYY-MM return `${categoryStr}-01`; // add -01 } if (categoryStr.match(/^\d{4}$/)) { // YYYY return `${categoryStr}-01-01`; // add -01-01 } if (categoryStr.match(/^\d{4}-W([1-9]|[1-4][0-9]|5[0-3])$/)) { // YYYY-W? // ? => [1-53] // |YYYY-W |1-9 | 10-49 | 50-53 | const split = categoryStr.split('-'); const year = Number(split[0]); const weekNumber = Number(split[1].replace('W', '')); return this._getDateStrOfISOWeek(year, weekNumber); } throw new Error('invalid categoryStr'); } /* * converts dateStr (YYYY-MM-DD) to a date object in UTC time */ _dateStrToUTCDate(dateStr) { if (!this._isValidDateStr(dateStr)) { throw new Error( `invalid dateStr, expected "YYYY-MM-DD" with month[1-12] and day[1-31], got: "${dateStr}".`, ); } const split = dateStr.split('-'); const year = Number(split[0]); const month = Number(split[1]); const day = Number(split[2]); return new Date(Date.UTC(year, month - 1, day)); } /* * converts a UTC date object to a dateStr (YYYY-MM-DD) */ _UTCDateToDateStr(UTCDate) { return UTCDate.toISOString().split('T')[0]; } /* * example: * 1 Jan 2020 => 2020-W1 */ _UTCDateToWeekFormat(UTCDate) { const year = this._getISOWeekYear(UTCDate); const weekNbr = this._getISOWeek(UTCDate); return `${year}-W${weekNbr}`; } /* * returns the ISO week (_getISOWeek) or year (_getISOWeekYear) * as number based on a UTC date. */ _getISOWeek(UTCDate) { // https://weeknumber.net/how-to/javascript const date = new Date(UTCDate.getTime()); date.setHours(0, 0, 0, 0); // Thursday in current week decides the year. date.setDate(date.getDate() + 3 - ((date.getDay() + 6) % 7)); // January 4 is always in week 1. const week1 = new Date(date.getFullYear(), 0, 4); // Adjust to Thursday in week 1 and count number of weeks from date to week1. return ( 1 + Math.round( ((date.getTime() - week1.getTime()) / 86400000 - 3 + ((week1.getDay() + 6) % 7)) / 7, ) ); } /* * returns the ISO week year as number based on a UTC date * this is only needed for rollovers, for example: * => 1.jan 2011 is in W52 of year 2010. */ _getISOWeekYear(UTCDate) { // https://weeknumber.net/how-to/javascript const date = new Date(UTCDate.getTime()); date.setDate(date.getDate() + 3 - ((date.getDay() + 6) % 7)); return date.getFullYear(); } /* * given the year and weeknumber -> return dateStr (YYYY-MM-DD) */ _getDateStrOfISOWeek(year, weekNumber) { // https://stackoverflow.com/a/16591175/6272061 const simple = new Date(Date.UTC(year, 0, 1 + (weekNumber - 1) * 7)); const dow = simple.getUTCDay(); const ISOweekStart = simple; if (dow <= 4) ISOweekStart.setDate(simple.getDate() - simple.getUTCDay() + 1); else ISOweekStart.setDate(simple.getDate() + 8 - simple.getUTCDay()); return ISOweekStart.toISOString().split('T')[0]; } /* * compute the interval sizes based on the scope * prediction only, not the actuall export of the data */ getIntervalSizes() { return { D: this._computeIntervalSize('D'), W: this._computeIntervalSize('W'), M: this._computeIntervalSize('M'), Y: this._computeIntervalSize('Y'), '5Y': this._computeIntervalSize('5Y'), '10Y': this._computeIntervalSize('10Y'), }; } _computeIntervalSize(scope) { const maxDate = this.getMaxDateStr(); if (!maxDate) { return 0; } const endDate = this._dateStrToUTCDate(maxDate); const firstDayDateStr = this._getFirstDay(this._classify(this.getMinDateStr(), scope)); let currentDate = this._dateStrToUTCDate(firstDayDateStr); let count = 0; while (currentDate <= endDate) { count++; currentDate = this._increaseDateBy(scope, currentDate); } return count; } _increaseDateBy(scope, date) { switch (scope) { case 'D': return this._addDays(date, 1); case 'W': return this._addDays(date, 7); case 'M': return this._addMonths(date, 1); case 'Y': return this._addYears(date, 1); case '5Y': return this._addYears(date, 5); case '10Y': return this._addYears(date, 10); } } /* * functions that add n days (_addDays), months (_addMonths) * or years (_addYears) to a UTC date object * returns the computed new UTC date */ _addDays(UTCDate, days) { const newUTCDate = new Date(UTCDate.valueOf()); newUTCDate.setUTCDate(newUTCDate.getUTCDate() + days); return newUTCDate; } _addMonths(UTCdate, months) { const newUTCDate = new Date(UTCdate.valueOf()); const d = newUTCDate.getUTCDate(); newUTCDate.setUTCMonth(newUTCDate.getUTCMonth() + +months); if (newUTCDate.getUTCDate() != d) { newUTCDate.setUTCDate(0); } return newUTCDate; } _addYears(UTCdate, years) { const newUTCDate = new Date(UTCdate.valueOf()); newUTCDate.setUTCFullYear(newUTCDate.getUTCFullYear() + years); return newUTCDate; } /* * Validates dateStr. rules: * => year: 4 digit number * => month: [1-12] * => day: [1-31] */ _isValidDateStr(str) { if (!str) { return false; } const split = str.split('-'); if (split.length !== 3) return false; const year = split[0]; const month = split[1]; const day = split[2]; if (year === '0000' || day === '00' || month === '00') return false; if (Number(day) < 1 || Number(day) > 31) return false; if (Number(month) < 1 || Number(month) > 12) return false; // if all checks are passed => valid datestring! return true; } /* * Converts month number (str or number) to a 3 char * abbreviation of the month (in english) */ _monthLookup(num) { if (num > 12 || num < 1) { throw new Error(`invalid 'num' provided, expected 1-12. Got: ${num}`); } const lookup = { 1: 'Jan', 2: 'Feb', 3: 'Mar', 4: 'Apr', 5: 'May', 6: 'Jun', 7: 'Jul', 8: 'Aug', 9: 'Sep', 10: 'Oct', 11: 'Nov', 12: 'Dec', }; return lookup[num.toString()]; } }