Last finishing touches for the opening-hours visualization

This commit is contained in:
Pieter Vander Vennet 2020-10-09 20:10:21 +02:00
parent 895ec01213
commit 35bd49e5ba
13 changed files with 487 additions and 97 deletions

View file

@ -1,10 +1,10 @@
import {UIElement} from "../UI/UIElement"; import {UIElement} from "../UI/UIElement";
import {State} from "../State"; import State from "../State";
import Translations from "../UI/i18n/Translations"; import Translations from "../UI/i18n/Translations";
import {UIEventSource} from "./UIEventSource"; import {UIEventSource} from "./UIEventSource";
import {AllKnownLayouts} from "../Customizations/AllKnownLayouts"; import {AllKnownLayouts} from "../Customizations/AllKnownLayouts";
import Combine from "../UI/Base/Combine"; import Combine from "../UI/Base/Combine";
import {CheckBox} from "../UI/Input/CheckBox"; import CheckBox from "../UI/Input/CheckBox";
import {PersonalLayout} from "./PersonalLayout"; import {PersonalLayout} from "./PersonalLayout";
import {Layout} from "../Customizations/Layout"; import {Layout} from "../Customizations/Layout";
import {SubtleButton} from "../UI/Base/SubtleButton"; import {SubtleButton} from "../UI/Base/SubtleButton";

View file

@ -23,7 +23,7 @@ export default class State {
// The singleton of the global state // The singleton of the global state
public static state: State; public static state: State;
public static vNumber = "0.1.0a"; public static vNumber = "0.1.0b";
// The user journey states thresholds when a new feature gets unlocked // The user journey states thresholds when a new feature gets unlocked
public static userJourney = { public static userJourney = {

View file

@ -23,9 +23,9 @@ export default class PublicHolidayInput extends InputElement<string> {
const dropdown = new DropDown( const dropdown = new DropDown(
Translations.t.general.opening_hours.open_during_ph, Translations.t.general.opening_hours.open_during_ph,
[ [
{shown: "unknown", value: ""}, {shown: Translations.t.general.opening_hours.ph_not_known, value: ""},
{shown: "closed", value: "off"}, {shown: Translations.t.general.opening_hours.ph_closed, value: "off"},
{shown: "opened", value: " "} {shown:Translations.t.general.opening_hours.ph_open, value: " "}
] ]
); );
this._dropdown = dropdown.SetStyle("display:inline-block;"); this._dropdown = dropdown.SetStyle("display:inline-block;");

View file

@ -1,71 +1,281 @@
import {UIElement} from "./UIElement"; import {UIElement} from "./UIElement";
import {UIEventSource} from "../Logic/UIEventSource"; import {UIEventSource} from "../Logic/UIEventSource";
import opening_hours from "opening_hours"; import opening_hours from "opening_hours";
import Combine from "./Base/Combine";
import Translations from "./i18n/Translations";
import {FixedUiElement} from "./Base/FixedUiElement";
import {OH} from "../Logic/OpeningHours";
export default class OpeningHoursVisualization extends UIElement { export default class OpeningHoursVisualization extends UIElement {
private readonly _key: string;
constructor(tags: UIEventSource<any>) { constructor(tags: UIEventSource<any>, key: string) {
super(tags); super(tags);
this._key = key;
} }
private static GetRanges(tags: any, from: Date, to: Date): { private static GetRanges(oh: any, from: Date, to: Date): ({
isOpen: boolean, isOpen: boolean,
isUnknown: boolean, isSpecial: boolean,
comment: string, comment: string,
startDate: Date startDate: Date,
}[] { endDate: Date
}[])[] {
const oh = new opening_hours(tags.opening_hours, {
const values = [[], [], [], [], [], [], []];
const iterator = oh.getIterator(from);
let prevValue = undefined;
while (iterator.advance(to)) {
if (prevValue) {
prevValue.endDate = iterator.getDate() as Date
}
const endDate = new Date(iterator.getDate()) as Date;
endDate.setHours(0, 0, 0, 0)
endDate.setDate(endDate.getDate() + 1);
const value = {
isSpecial: iterator.getUnknown(),
isOpen: iterator.getState(),
comment: iterator.getComment(),
startDate: iterator.getDate() as Date,
endDate: endDate // Should be overwritten by the next iteration
}
prevValue = value;
if (value.comment === undefined && !value.isOpen && !value.isSpecial) {
// simply closed, nothing special here
continue;
}
// Get day: sunday is 0, monday is 1. We move everything so that monday == 0
values[(value.startDate.getDay() + 6) % 7].push(value);
}
return values;
}
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));
}
private allChangeMoments(ranges: {
isOpen: boolean,
isSpecial: boolean,
comment: string,
startDate: Date,
endDate: Date
}[][]): [number[], string[]] {
const changeHours: number[] = []
const changeHourText: string[] = [];
const extrachangeHours: number[] = []
const extrachangeHourText: string[] = [];
for (const weekday of ranges) {
for (const range of weekday) {
if (!range.isOpen && !range.isSpecial) {
continue;
}
const startOfDay: Date = new Date(range.startDate);
startOfDay.setHours(0, 0, 0, 0);
// @ts-ignore
const changeMoment: number = (range.startDate - startOfDay) / 1000;
if (changeHours.indexOf(changeMoment) < 0) {
changeHours.push(changeMoment);
changeHourText.push(OH.hhmm(range.startDate.getHours(), range.startDate.getMinutes()))
}
// @ts-ignore
let changeMomentEnd: number = (range.endDate - startOfDay) / 1000;
if (changeMomentEnd >= 24 * 60 * 60) {
if (extrachangeHours.indexOf(changeMomentEnd) < 0) {
extrachangeHours.push(changeMomentEnd);
extrachangeHourText.push(OH.hhmm(range.endDate.getHours(), range.endDate.getMinutes()))
}
} else if (changeHours.indexOf(changeMomentEnd) < 0) {
changeHours.push(changeMomentEnd);
changeHourText.push(OH.hhmm(range.endDate.getHours(), range.endDate.getMinutes()))
}
}
}
changeHourText.sort();
changeHours.sort();
extrachangeHourText.sort();
extrachangeHours.sort();
changeHourText.push(...extrachangeHourText);
changeHours.push(...extrachangeHours);
return [changeHours, changeHourText]
}
private static readonly weekdays = [
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,
]
InnerRender(): string {
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);
const tags = this._source.data;
const oh = new opening_hours(tags[this._key], {
lat: tags._lat, lat: tags._lat,
lon: tags._lon, lon: tags._lon,
address: { address: {
country_code: tags._country country_code: tags._country
} }
}); }, {tag_key: this._key});
const values = []; if (!oh.getState() && !oh.getUnknown()) {
// POI is currently closed
const iterator = oh.getIterator(from); const nextChange: Date = oh.getNextChange();
if (
while (iterator.advance(to)) { // Shop isn't gonna open anymore in this timerange
nextSunday < nextChange
const value = { // And we are already in the weekend to show next week
isUnknown: iterator.getUnknown(), && (today.getDay() == 0 || today.getDay() == 6)
isOpen: iterator.getState(), ) {
comment: iterator.getComment(), // We mover further along
startDate: iterator.getDate() lastMonday.setDate(lastMonday.getDate() + 7);
nextSunday.setDate(nextSunday.getDate() + 7);
}
} }
if (value.comment === undefined && !value.isOpen && !value.isUnknown) { // ranges[0] are all ranges for monday
// simply closed, nothing special here const ranges = OpeningHoursVisualization.GetRanges(oh, lastMonday, nextSunday);
console.log(ranges)
if (ranges.map(r => r.length).reduce((a, b) => a + b, 0) == 0) {
// Closed!
const opensAtDate = oh.getNextChange();
if(opensAtDate === undefined){
return Translations.t.general.opening_hours.closed_permanently.SetClass("ohviz-closed").Render()
}
const moment = `${opensAtDate.getDay()}/${opensAtDate.getMonth() + 1} ${OH.hhmm(opensAtDate.getHours(), opensAtDate.getMinutes())}`
return Translations.t.general.opening_hours.closed_until.Subs({date: moment}).SetClass("ohviz-closed").Render()
}
const isWeekstable = oh.isWeekStable();
let [changeHours, changeHourText] = this.allChangeMoments(ranges);
// 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 rows: UIElement[] = [];
const availableArea = latestclose - earliestOpen;
// @ts-ignore
const now = (100 * (((new Date() - today) / 1000) - earliestOpen)) / availableArea;
let header = "";
if (now >= 0 && now <= 100) {
header += new FixedUiElement("").SetStyle(`left:${now}%;`).SetClass("ohviz-now").Render()
}
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").Render();
header += el;
}
for (let i = 0; i < changeHours.length; i++) {
let changeMoment = changeHours[i];
const offset = 100 * (changeMoment - earliestOpen) / availableArea;
if (offset < 0 || offset > 100) {
continue;
}
const el = new FixedUiElement(
`<div style='margin-top: ${i % 2 == 0 ? '1.5em;' : "1%"}'>${changeHourText[i]}</div>`
)
.SetStyle(`left:${offset}%`)
.SetClass("ohviz-time-indication").Render();
header += el;
}
rows.push(new Combine([`<td width="5%">&NonBreakingSpace;</td>`,
`<td style="position:relative;height:2.5em;">${header}</td>`]));
for (let i = 0; i < 7; i++) {
const dayRanges = ranges[i];
const isToday = (new Date().getDay() + 6) % 7 === i;
let weekday = OpeningHoursVisualization.weekdays[i].Render();
if (!isWeekstable) {
const day = new Date(lastMonday)
day.setDate(day.getDate() + i);
weekday = " " + day.getDate() + "/" + (day.getMonth() + 1);
}
let innerContent: string[] = [];
for (const changeMoment of changeHours) {
const offset = 100 * (changeMoment - earliestOpen) / availableArea;
innerContent.push(new FixedUiElement("").SetStyle(`left:${offset}%;`).SetClass("ohviz-line").Render())
}
for (const range of dayRanges) {
if (!range.isOpen && !range.isSpecial) {
innerContent.push(
new FixedUiElement(range.comment).SetClass("ohviz-day-off").Render())
continue; continue;
} }
console.log(value) const startOfDay: Date = new Date(range.startDate);
values.push(value); 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);
innerContent.push(
new FixedUiElement(range.comment).SetStyle(`left:${startPercentage}%; width:${width}%`).SetClass("ohviz-range").Render())
} }
return values;
if (now >= 0 && now <= 100) {
innerContent.push(new FixedUiElement("").SetStyle(`left:${now}%;`).SetClass("ohviz-now").Render())
}
let clss = ""
if (isToday) {
clss = "ohviz-today"
}
rows.push(new Combine(
[`<td class="ohviz-weekday ${clss}">${weekday}</td>`,
`<td style="position:relative;" class="${clss}">${innerContent.join("")}</td>`]))
} }
InnerRender(): string { return new Combine([
"<table class='ohviz' style='width:100%;'>",
rows.map(el => "<tr>" + el.Render() + "</tr>").join(""),
const from = new Date("2019-12-31"); "</table>"
const to = new Date("2020-01-05"); ]).SetClass("ohviz-container").Render();
const ranges = OpeningHoursVisualization.GetRanges(this._source.data, from, to);
let text = "";
for (const range of ranges) {
text += `From${range.startDate} it is${range.isOpen} ${range.comment?? ""}<br/>`
}
return text;
} }
} }

View file

@ -0,0 +1,16 @@
import {UIElement} from "./UIElement";
import OpeningHoursVisualization from "./OhVisualization";
import {UIEventSource} from "../Logic/UIEventSource";
export default class SpecialVisualizations {
public static specialVisualizations: { funcName: string, constr: ((tagSource: UIEventSource<any>, argument: string) => UIElement) }[] =
[{
funcName: "opening_hours_table",
constr: (tagSource: UIEventSource<any>, keyname) => {
return new OpeningHoursVisualization(tagSource, keyname)
}
}]
}

View file

@ -17,12 +17,13 @@ import {FixedUiElement} from "./Base/FixedUiElement";
import ValidatedTextField from "./Input/ValidatedTextField"; import ValidatedTextField from "./Input/ValidatedTextField";
import CheckBoxes from "./Input/Checkboxes"; import CheckBoxes from "./Input/Checkboxes";
import State from "../State"; import State from "../State";
import SpecialVisualizations from "./SpecialVisualizations";
export class TagRendering extends UIElement implements TagDependantUIElement { export class TagRendering extends UIElement implements TagDependantUIElement {
private readonly _question: string | Translation; private readonly _question: string | Translation;
private readonly _mapping: { k: TagsFilter, txt: string | UIElement, priority?: number }[]; private readonly _mapping: { k: TagsFilter, txt: string | Translation, priority?: number }[];
private currentTags: UIEventSource<any>; private currentTags: UIEventSource<any>;
@ -428,37 +429,19 @@ export class TagRendering extends UIElement implements TagDependantUIElement {
private RenderAnswer(): UIElement { private RenderAnswer(): UIElement {
const tags = TagUtils.proprtiesToKV(this._source.data); const tags = TagUtils.proprtiesToKV(this._source.data);
let freeform: UIElement = new FixedUiElement("");
let freeformScore = -10;
if (this._freeform !== undefined && this._source.data[this._freeform.key] !== undefined) {
freeform = this.ApplyTemplate(this._freeform.renderTemplate);
freeformScore = 0;
}
let highestScore = -100;
let highestTemplate = undefined;
for (const oneOnOneElement of this._mapping) { for (const oneOnOneElement of this._mapping) {
if (oneOnOneElement.k == null || if (oneOnOneElement.k.matches(tags)) {
oneOnOneElement.k.matches(tags)) { // We have found a matching key -> we use this template
// We have found a matching key -> we use the template, but only if it scores better return this.ApplyTemplate(oneOnOneElement.txt);
let score = oneOnOneElement.priority ??
(oneOnOneElement.k === null ? -1 : 0);
if (score > highestScore) {
highestScore = score;
highestTemplate = oneOnOneElement.txt
}
} }
} }
if (freeformScore > highestScore) { if (this._freeform !== undefined && this._source.data[this._freeform.key] !== undefined) {
return freeform; return this.ApplyTemplate(this._freeform.renderTemplate);
} }
if (highestTemplate !== undefined) { return new FixedUiElement("");
// we render the found template
return this.ApplyTemplate(highestTemplate);
}
} }
InnerRender(): string { InnerRender(): string {
@ -531,13 +514,19 @@ export class TagRendering extends UIElement implements TagDependantUIElement {
if (template === undefined || template === null) { if (template === undefined || template === null) {
return undefined; return undefined;
} }
const knownSpecials : {funcName: string, constr: ((arg: string) => UIElement)}[]= SpecialVisualizations.specialVisualizations.map(
special => ({
funcName: special.funcName,
constr: arg => special.constr(this.currentTags, arg)
})
)
return new VariableUiElement(this.currentTags.map(tags => { return new VariableUiElement(this.currentTags.map(tags => {
const tr = Translations.WT(template); return Translations.WT(template)
if (tr.Subs === undefined) { .Subs(tags)
// This is a weird edge case .EvaluateSpecialComponents(knownSpecials)
return tr.InnerRender(); .InnerRender()
}
return tr.Subs(tags).InnerRender()
})).ListenTo(Locale.language); })).ListenTo(Locale.language);
} }

View file

@ -8,7 +8,7 @@ export default class Translation extends UIElement {
private static forcedLanguage = undefined; private static forcedLanguage = undefined;
public Subs(text: any) { public Subs(text: any): Translation {
const newTranslations = {}; const newTranslations = {};
for (const lang in this.translations) { for (const lang in this.translations) {
let template: string = this.translations[lang]; let template: string = this.translations[lang];
@ -40,6 +40,32 @@ export default class Translation extends UIElement {
} }
public EvaluateSpecialComponents(knownSpecials: { funcName: string, constr: ((call: string) => UIElement) }[]): UIElement {
const newTranslations = {};
for (const lang in this.translations) {
let template: string = this.translations[lang];
for (const knownSpecial of knownSpecials) {
const combined = [];
const matched = template.match(`(.*){${knownSpecial.funcName}\\((.*)\\)}(.*)`);
if (matched === null) {
continue;
}
const partBefore = matched[1];
const argument = matched[2];
const partAfter = matched[3];
const element = knownSpecial.constr(argument).Render();
template = partBefore + element + partAfter;
}
newTranslations[lang] = template;
}
return new Translation(newTranslations);
}
get txt(): string { get txt(): string {
if (this.translations["*"]) { if (this.translations["*"]) {
@ -60,6 +86,7 @@ export default class Translation extends UIElement {
return undefined; return undefined;
} }
InnerRender(): string { InnerRender(): string {
return this.txt return this.txt
} }

View file

@ -896,6 +896,26 @@ export default class Translations {
}), }),
not_all_rules_parsed: new T({ not_all_rules_parsed: new T({
"en": "The openin hours of this shop are complicated. The following rules are ignored in the input element:" "en": "The openin hours of this shop are complicated. The following rules are ignored in the input element:"
}),
closed_until: new T({
"en": "Closed until {date}",
"nl": "Gesloten - open op {date}"
}),
closed_permanently: new T({
"en": "Closed - no opening day known",
"nl": "Gesloten"
}),
ph_not_known: new T({
"en": "unknown",
"nl": "niet gekend"
}),
ph_closed: new T({
"en": "closed",
"nl": "gesloten"
}), ph_open: new T({
"en": "opened",
"nl": "open"
}) })
@ -952,8 +972,11 @@ export default class Translations {
if (typeof (s) === "string") { if (typeof (s) === "string") {
return new Translation({en: s}); return new Translation({en: s});
} }
if (s instanceof Translation) {
return s; return s;
} }
throw "??? Not a valid translation"
}
public static CountTranslations() { public static CountTranslations() {
const queue: any = [Translations.t]; const queue: any = [Translations.t];

File diff suppressed because one or more lines are too long

View file

@ -196,6 +196,17 @@
"key": "email", "key": "email",
"type": "email" "type": "email"
} }
},
{
"question": {
"en": "When it this bike café opened?",
"nl": "Wanneer is dit fietscafé geopend?"
},
"render": "{opening_hours_table(opening_hours)}",
"freeform": {
"key": "opening_hours",
"type": "opening_hours"
}
} }
], ],
"hideUnderlayingFeaturesMinPercentage": 0, "hideUnderlayingFeaturesMinPercentage": 0,

View file

@ -185,7 +185,8 @@
}, },
"render": "<a href='{website}' target='_blank'>{website}</a>", "render": "<a href='{website}' target='_blank'>{website}</a>",
"freeform": { "freeform": {
"key": "website" "key": "website",
"type": "url"
} }
}, },
{ {
@ -215,7 +216,7 @@
} }
}, },
{ {
"render": "Shop is open {opening_hours}", "render": "{opening_hours_table(opening_hours)}",
"question": "When is this shop opened?", "question": "When is this shop opened?",
"freeform": { "freeform": {
"key": "opening_hours", "key": "opening_hours",

View file

@ -108,3 +108,116 @@
.oh-timerange-label { .oh-timerange-label {
color: white; color: white;
} }
/**** Opening hours visualization table ****/
.ohviz-table {
}
.ohviz-range {
display: block;
background: #99e7ff;
position: absolute;
left: 0;
top: 5%;
height: 85%;
border: 1px solid #ccc;
border-radius: 5px;
box-sizing: border-box;
text-align: center;
font-size: smaller;
}
.ohviz-today .ohviz-range {
border: 1.5px solid black;
}
.ohviz-day-off {
display: block;
background: repeating-linear-gradient(
45deg,
rgba(255, 255, 255, 0),
rgba(255, 255, 255, 0) 10px,
rgba(216, 235, 255, 0.5) 10px,
rgba(216, 235, 255, 0.5) 20px
);
position: absolute;
left: 0;
top: 0;
height: 100%;
width: 100%;
box-sizing: border-box;
color: black;
font-weight: bold;
text-align: center;
}
.ohviz-now {
position: absolute;
top: 0;
margin: 0;
height: 100%;
border: 1px solid black;
box-sizing: border-box
}
.ohviz-line {
position: absolute;
top: 0;
margin: 0;
height: 100%;
border-left: 1px solid #ccc;
box-sizing: border-box
}
.ohviz-time-indication > div {
position: relative;
background-color: white;
left: -50%;
padding-left: 0.3em;
padding-right: 0.3em;
font-size: smaller;
border-radius: 0.3em;
border: 1px solid #ccc;
}
.ohviz-time-indication {
position: absolute;
top: 0;
margin: 0;
height: 100%;
box-sizing: border-box;
}
.ohviz-today {
background-color: #e5f5ff;
}
.ohviz-weekday {
padding-left: 0.5em;
}
.ohviz {
border-collapse: collapse;
}
.ohviz-container {
border: 0.5em solid #e5f5ff;
border-radius: 1em;
display: block;
}
.ohviz-closed {
padding: 1em;
background-color: #eee;
border-radius: 1em;
display: block;
}

View file

@ -4,12 +4,12 @@ import OpeningHoursVisualization from "./UI/OhVisualization";
import {UIEventSource} from "./Logic/UIEventSource"; import {UIEventSource} from "./Logic/UIEventSource";
new OpeningHoursVisualization( new UIEventSource<any>({ new OpeningHoursVisualization( new UIEventSource<any>({
opening_hours: "mo-fr 09:00-17:00; Sa 09:00-17:00 'by appointment'; PH off; Th[1] off;", opening_hours: "2000 Dec 21 10:00-12:00;",
_country: "be", _country: "be",
_lat: "51.2", _lat: "51.2",
_lon: "3.2" _lon: "3.2"
} }
)).AttachTo("maindiv") ), 'opening_hours').AttachTo("maindiv")
/*/ /*/