forked from MapComplete/MapComplete
		
	
		
			
	
	
		
			292 lines
		
	
	
	
		
			12 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
		
		
			
		
	
	
			292 lines
		
	
	
	
		
			12 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
|  | import {UIEventSource} from "../../Logic/UIEventSource"; | ||
|  | import Combine from "../Base/Combine"; | ||
|  | import State from "../../State"; | ||
|  | import {FixedUiElement} from "../Base/FixedUiElement"; | ||
|  | import {OH} from "./OpeningHours"; | ||
|  | import Translations from "../i18n/Translations"; | ||
|  | import Constants from "../../Models/Constants"; | ||
|  | import opening_hours from "opening_hours"; | ||
|  | import BaseUIElement from "../BaseUIElement"; | ||
|  | import Toggle from "../Input/Toggle"; | ||
|  | import {VariableUiElement} from "../Base/VariableUIElement"; | ||
|  | import Table from "../Base/Table"; | ||
|  | import {Translation} from "../i18n/Translation"; | ||
|  | import {UIElement} from "../UIElement"; | ||
|  | 
 | ||
|  | export default class OpeningHoursVisualization extends UIElement { | ||
|  |     private static readonly weekdays: Translation[] = [ | ||
|  |         Translations.t.general.weekdays.abbreviations.monday, | ||
|  |         Translations.t.general.weekdays.abbreviations.tuesday, | ||
|  |         Translations.t.general.weekdays.abbreviations.wednesday, | ||
|  |         Translations.t.general.weekdays.abbreviations.thursday, | ||
|  |         Translations.t.general.weekdays.abbreviations.friday, | ||
|  |         Translations.t.general.weekdays.abbreviations.saturday, | ||
|  |         Translations.t.general.weekdays.abbreviations.sunday, | ||
|  |     ] | ||
|  |     private readonly _tags: UIEventSource<any>; | ||
|  |     private readonly _key: string; | ||
|  | 
 | ||
|  |     constructor(tags: UIEventSource<any>, key: string) { | ||
|  |         super() | ||
|  |         this._tags = tags; | ||
|  |         this._key = key; | ||
|  |     } | ||
|  |      | ||
|  |     InnerRender(): BaseUIElement { | ||
|  |         const tags = this._tags; | ||
|  |         const key = this._key; | ||
|  |         const tagsDirect = tags.data; | ||
|  |         const ohTable = new VariableUiElement(tags | ||
|  |             .map(tags => tags[key]) // This mapping will absorb all other changes to tags in order to prevent regeneration
 | ||
|  |             .map(ohtext => { | ||
|  |                     try { | ||
|  |                         // noinspection JSPotentiallyInvalidConstructorUsage
 | ||
|  |                         const oh = new opening_hours(ohtext, { | ||
|  |                             lat: tagsDirect._lat, | ||
|  |                             lon: tagsDirect._lon, | ||
|  |                             address: { | ||
|  |                                 country_code: tagsDirect._country | ||
|  |                             } | ||
|  |                         }, {tag_key: this._key}); | ||
|  | 
 | ||
|  |                         return OpeningHoursVisualization.CreateFullVisualisation(oh) | ||
|  |                     } catch (e) { | ||
|  |                         console.log(e); | ||
|  |                         return new Combine([Translations.t.general.opening_hours.error_loading, | ||
|  |                             new Toggle( | ||
|  |                                 new FixedUiElement(e).SetClass("subtle"), | ||
|  |                                 undefined, | ||
|  |                                 State.state?.osmConnection?.userDetails.map(userdetails => userdetails.csCount >= Constants.userJourney.tagsVisibleAndWikiLinked) | ||
|  |                             ) | ||
|  |                         ]); | ||
|  |                     } | ||
|  | 
 | ||
|  |                 } | ||
|  |             )) | ||
|  | 
 | ||
|  |         return new Toggle( | ||
|  |             ohTable, | ||
|  |             Translations.t.general.loadingCountry.Clone(), | ||
|  |             tags.map(tgs => tgs._country !== undefined) | ||
|  |         ); | ||
|  |     } | ||
|  | 
 | ||
|  |     private static CreateFullVisualisation(oh: any): BaseUIElement { | ||
|  | 
 | ||
|  |         /** First, we determine which range of dates we want to visualize: this week or next week?**/ | ||
|  | 
 | ||
|  |         const today = new Date(); | ||
|  |         today.setHours(0, 0, 0, 0); | ||
|  |         const lastMonday = OpeningHoursVisualization.getMonday(today); | ||
|  |         const nextSunday = new Date(lastMonday); | ||
|  |         nextSunday.setDate(nextSunday.getDate() + 7); | ||
|  | 
 | ||
|  |         if (!oh.getState() && !oh.getUnknown()) { | ||
|  |             // POI is currently closed
 | ||
|  |             const nextChange: Date = oh.getNextChange(); | ||
|  |             if ( | ||
|  |                 // Shop isn't gonna open anymore in this timerange
 | ||
|  |                 nextSunday < nextChange | ||
|  |                 // And we are already in the weekend to show next week
 | ||
|  |                 && (today.getDay() == 0 || today.getDay() == 6) | ||
|  |             ) { | ||
|  |                 // We move the range to next week!
 | ||
|  |                 lastMonday.setDate(lastMonday.getDate() + 7); | ||
|  |                 nextSunday.setDate(nextSunday.getDate() + 7); | ||
|  |             } | ||
|  |         } | ||
|  | 
 | ||
|  | 
 | ||
|  |         /* We calculate the ranges when it is opened! */ | ||
|  |         const ranges = OH.GetRanges(oh, lastMonday, nextSunday); | ||
|  | 
 | ||
|  |         /* First, a small sanity check. The business might be permanently closed, 24/7 opened or something other special | ||
|  |         * So, we have to handle the case that ranges is completely empty*/ | ||
|  |         if (ranges.filter(range => range.length > 0).length === 0) { | ||
|  |             return OpeningHoursVisualization.ShowSpecialCase(oh).SetClass("p-4 rounded-full block bg-gray-200") | ||
|  |         } | ||
|  | 
 | ||
|  |         /** With all the edge cases handled, we can actually construct the table! **/ | ||
|  | 
 | ||
|  |         return OpeningHoursVisualization.ConstructVizTable(oh, ranges, lastMonday) | ||
|  | 
 | ||
|  | 
 | ||
|  |     } | ||
|  | 
 | ||
|  |     private static ConstructVizTable(oh: any, ranges: { isOpen: boolean; isSpecial: boolean; comment: string; startDate: Date; endDate: Date }[][], | ||
|  |                                      rangeStart: Date): BaseUIElement { | ||
|  | 
 | ||
|  | 
 | ||
|  |         const isWeekstable: boolean = oh.isWeekStable(); | ||
|  |         let [changeHours, changeHourText] = OH.allChangeMoments(ranges); | ||
|  |         const today = new Date(); | ||
|  |         today.setHours(0, 0, 0, 0); | ||
|  | 
 | ||
|  |         // @ts-ignore
 | ||
|  |         const todayIndex = Math.ceil((today - rangeStart) / (1000 * 60 * 60 * 24)) | ||
|  |         // By default, we always show the range between 8 - 19h, in order to give a stable impression
 | ||
|  |         // Ofc, a bigger range is used if needed
 | ||
|  |         const earliestOpen = Math.min(8 * 60 * 60, ...changeHours); | ||
|  |         let latestclose = Math.max(...changeHours); | ||
|  |         // We always make sure there is 30m of leeway in order to give enough room for the closing entry
 | ||
|  |         latestclose = Math.max(19 * 60 * 60, latestclose + 30 * 60) | ||
|  |         const availableArea = latestclose - earliestOpen; | ||
|  | 
 | ||
|  |         /* | ||
|  |         * The OH-visualisation is a table, consisting of 8 rows and 2 columns: | ||
|  |         * The first row is a header row (which is NOT passed as header but just as a normal row!) containing empty for the first column and one object giving all the end times | ||
|  |         * The other rows are one for each weekday: the first element showing 'mo', 'tu', ..., the second element containing the bars. | ||
|  |         * Note that the bars are actually an embedded <div> spanning the full width, containing multiple sub-elements | ||
|  |         * */ | ||
|  | 
 | ||
|  |         const [header, headerHeight] = OpeningHoursVisualization.ConstructHeaderElement(availableArea, changeHours, changeHourText, earliestOpen) | ||
|  | 
 | ||
|  |         const weekdays = [] | ||
|  |         const weekdayStyles = [] | ||
|  |         for (let i = 0; i < 7; i++) { | ||
|  | 
 | ||
|  |             const day = OpeningHoursVisualization.weekdays[i].Clone(); | ||
|  |             day.SetClass("w-full h-full block") | ||
|  | 
 | ||
|  |             const rangesForDay = ranges[i].map(range => | ||
|  |                 OpeningHoursVisualization.CreateRangeElem(availableArea, earliestOpen, latestclose, range, isWeekstable) | ||
|  |             ) | ||
|  |             const allRanges = new Combine([ | ||
|  |                 ...OpeningHoursVisualization.CreateLinesAtChangeHours(changeHours, availableArea, earliestOpen) , | ||
|  |                 ...rangesForDay]).SetClass("w-full block"); | ||
|  | 
 | ||
|  |             let extraStyle = "" | ||
|  |             if (todayIndex == i) { | ||
|  |                 extraStyle = "background-color: var(--subtle-detail-color);" | ||
|  |                 allRanges.SetClass("ohviz-today") | ||
|  |             } else if (i >= 5) { | ||
|  |                 extraStyle = "background-color: rgba(230, 231, 235, 1);" | ||
|  |             } | ||
|  |             weekdays.push([day, allRanges]) | ||
|  |             weekdayStyles.push(["padding-left: 0.5em;" + extraStyle, `position: relative;` + extraStyle]) | ||
|  |         } | ||
|  |         return new Table(undefined, | ||
|  |             [[" ", header], ...weekdays], | ||
|  |             [["width: 5%", `position: relative; height: ${headerHeight}`], ...weekdayStyles] | ||
|  |         ).SetClass("w-full") | ||
|  |             .SetStyle("border-collapse: collapse; word-break; word-break: normal; word-wrap: normal") | ||
|  | 
 | ||
|  | 
 | ||
|  |     } | ||
|  | 
 | ||
|  |     private static CreateRangeElem(availableArea: number, earliestOpen: number, latestclose: number, | ||
|  |                                    range: { isOpen: boolean; isSpecial: boolean; comment: string; startDate: Date; endDate: Date }, | ||
|  |                                    isWeekstable: boolean): BaseUIElement { | ||
|  | 
 | ||
|  |         const textToShow = range.comment ?? (isWeekstable ? "" : range.startDate.toLocaleDateString()); | ||
|  | 
 | ||
|  |         if (!range.isOpen && !range.isSpecial) { | ||
|  |             return new FixedUiElement(textToShow).SetClass("ohviz-day-off") | ||
|  |         } | ||
|  | 
 | ||
|  |         const startOfDay: Date = new Date(range.startDate); | ||
|  |         startOfDay.setHours(0, 0, 0, 0); | ||
|  |         // @ts-ignore
 | ||
|  |         const startpoint = (range.startDate - startOfDay) / 1000 - earliestOpen; | ||
|  |         // @ts-ignore
 | ||
|  |         const width = (100 * (range.endDate - range.startDate) / 1000) / (latestclose - earliestOpen); | ||
|  |         const startPercentage = (100 * startpoint / availableArea); | ||
|  |         return new FixedUiElement(textToShow).SetStyle(`left:${startPercentage}%; width:${width}%`) | ||
|  |             .SetClass("ohviz-range"); | ||
|  |     } | ||
|  | 
 | ||
|  |     private static CreateLinesAtChangeHours(changeHours: number[], availableArea: number, earliestOpen: number): | ||
|  |         BaseUIElement[] { | ||
|  | 
 | ||
|  |         const allLines: BaseUIElement[] = [] | ||
|  |         for (const changeMoment of changeHours) { | ||
|  |             const offset = 100 * (changeMoment - earliestOpen) / availableArea; | ||
|  |             if (offset < 0 || offset > 100) { | ||
|  |                 continue; | ||
|  |             } | ||
|  |             const el = new FixedUiElement("").SetStyle(`left:${offset}%;`).SetClass("ohviz-line"); | ||
|  |             allLines.push(el); | ||
|  |         } | ||
|  |         return allLines; | ||
|  |     } | ||
|  | 
 | ||
|  | 
 | ||
|  |     /** | ||
|  |      * The OH-Visualization header element, a single bar with hours | ||
|  |      * @param availableArea | ||
|  |      * @param changeHours | ||
|  |      * @param changeHourText | ||
|  |      * @param earliestOpen | ||
|  |      * @constructor | ||
|  |      * @private | ||
|  |      */ | ||
|  |     private static ConstructHeaderElement(availableArea: number, changeHours: number[], changeHourText: string[], earliestOpen: number) | ||
|  |         : [BaseUIElement, string] { | ||
|  |         let header: BaseUIElement[] = []; | ||
|  | 
 | ||
|  |         header.push(...OpeningHoursVisualization.CreateLinesAtChangeHours(changeHours, availableArea, earliestOpen)) | ||
|  | 
 | ||
|  |         let showHigher = false; | ||
|  |         let showHigherUsed = false; | ||
|  |         for (let i = 0; i < changeHours.length; i++) { | ||
|  |             let changeMoment = changeHours[i]; | ||
|  |             const offset = 100 * (changeMoment - earliestOpen) / availableArea; | ||
|  |             if (offset < 0 || offset > 100) { | ||
|  |                 continue; | ||
|  |             } | ||
|  | 
 | ||
|  |             if (i > 0 && ((changeMoment - changeHours[i - 1]) / (60*60)) < 2) { | ||
|  |                 // Quite close to the previous value
 | ||
|  |                 // We alternate the heights
 | ||
|  |                 showHigherUsed = true; | ||
|  |                 showHigher = !showHigher; | ||
|  |             } else { | ||
|  |                 showHigher = false; | ||
|  |             } | ||
|  | 
 | ||
|  |             const el = new Combine([ | ||
|  | 
 | ||
|  |                 new FixedUiElement(changeHourText[i]) | ||
|  |                     .SetClass("relative bg-white pl-1 pr-1 h-3 font-sm rounded-xl border-2 border-black  border-opacity-50") | ||
|  |                     .SetStyle("left: -50%; word-break:initial") | ||
|  | 
 | ||
|  |             ]) | ||
|  |                 .SetStyle(`left:${offset}%;margin-top: ${showHigher ? '1.4rem;' : "0.1rem"}`) | ||
|  |                 .SetClass("block absolute top-0 m-0 h-full box-border ohviz-time-indication"); | ||
|  |             header.push(el); | ||
|  |         } | ||
|  |         const headerElem = new Combine(header).SetClass(`w-full absolute block ${showHigherUsed ? "h-16" : "h-8"}`) | ||
|  |             .SetStyle("margin-top: -1rem") | ||
|  |         const headerHeight = showHigherUsed ? "4rem" : "2rem"; | ||
|  |         return [headerElem, headerHeight] | ||
|  | 
 | ||
|  |     } | ||
|  | 
 | ||
|  |     /* | ||
|  |     * Visualizes any special case: e.g. not open for a long time, 24/7 open, ... | ||
|  |     * */ | ||
|  |     private static ShowSpecialCase(oh: any) { | ||
|  |         const opensAtDate = oh.getNextChange(); | ||
|  |         if (opensAtDate === undefined) { | ||
|  |             const comm = oh.getComment() ?? oh.getUnknown(); | ||
|  |             if (!!comm) { | ||
|  |                 return new FixedUiElement(comm) | ||
|  |             } | ||
|  | 
 | ||
|  |             if (oh.getState()) { | ||
|  |                 return Translations.t.general.opening_hours.open_24_7.Clone() | ||
|  |             } | ||
|  |             return Translations.t.general.opening_hours.closed_permanently.Clone() | ||
|  |         } | ||
|  |         const willOpenAt = `${opensAtDate.getDate()}/${opensAtDate.getMonth() + 1} ${OH.hhmm(opensAtDate.getHours(), opensAtDate.getMinutes())}` | ||
|  |         return Translations.t.general.opening_hours.closed_until.Subs({date: willOpenAt}) | ||
|  |     } | ||
|  | 
 | ||
|  |     private static getMonday(d) { | ||
|  |         d = new Date(d); | ||
|  |         const day = d.getDay(); | ||
|  |         const diff = d.getDate() - day + (day == 0 ? -6 : 1); // adjust when day is sunday
 | ||
|  |         return new Date(d.setDate(diff)); | ||
|  |     } | ||
|  | 
 | ||
|  | } |