import {customElement, state} from 'lit/decorators.js'; import GmfTimeInput from 'gmf/time-input/time-input'; import {css, CSSResult, html, TemplateResult, unsafeCSS} from 'lit'; import {debounce} from 'ngeo/misc/debounce2'; import {TimePropertyResolutionEnum} from 'gmf/datasource/OGC'; /** * Gives you a timeslider (simple or range) based on OGC time. * Based on GmfTimeInput. * Example: * */ @customElement('gmf-timeslider') export default class GmfTimeslider extends GmfTimeInput { @state() protected sliderStart?: number; @state() protected sliderEnd?: number; private isInitialRendering = true; private datesSteps: number[]; private readonly onSliderRelease = debounce(() => { this.emitChangeEvent(); }, 300); static readonly styles: CSSResult[] = [ css` .container { position: relative; width: 100%; height: 3rem; } input[type='range'] { -webkit-appearance: none; -moz-appearance: none; appearance: none; width: 100%; outline: none; position: absolute; margin: auto; top: 0; bottom: 0; background-color: transparent; pointer-events: none; } .slider-track { width: 100%; height: 5px; position: absolute; margin: 0.5rem 0; top: 0; bottom: 0; border-radius: 5px; background: linear-gradient( to right, var(--brand-secondary) 0%, var(--brand-primary) 0%, var(--brand-primary) 100%, var(--brand-secondary) 100% ); } input[type='range']::-webkit-slider-runnable-track { -webkit-appearance: none; height: 5px; } input[type='range']::-moz-range-track { -moz-appearance: none; height: 5px; } input[type='range']::-ms-track { appearance: none; height: 5px; } input[type='range']::-webkit-slider-thumb { -webkit-appearance: none; height: 1.2em; width: 0.4em; background-color: var(--main-bg-color); border: solid 1px var(--btn-default-border); cursor: pointer; margin-top: -0.3rem; pointer-events: auto; border-radius: 2px; } input[type='range']::-moz-range-thumb { -webkit-appearance: none; height: 1.2em; width: 0.4em; cursor: pointer; border-radius: 2px; background-color: var(--main-bg-color); border: solid 1px var(--btn-default-border); pointer-events: auto; } input[type='range']::-ms-thumb { appearance: none; height: 1.2em; width: 0.4em; cursor: pointer; border-radius: 2px; background-color: var(--main-bg-color); border: solid 1px var(--btn-default-border); pointer-events: auto; } input[type='range']:active::-webkit-slider-thumb { background-color: white; border: 1px solid var(--brand-secondary); } .dates-txt { position: relative; display: flex; justify-content: space-between; top: 2rem; } `, ]; /** * Lit rendering. * @returns the html template for timeslider(s). */ render(): TemplateResult { if (!this.timeProp || !this.datesSteps) { return html``; } // On initial rendering, the colouring must be applied once the value are // known in the element. if (this.isInitialRendering && this.isTimeRange()) { setTimeout(() => this.fillColor(), 100); } this.isInitialRendering = false; const sliderStart = html` `; const dateTxtStart = html`
${this.getLocalDate(this.dateStart)}
`; let sliderEnd = null; let dateTxtEnd = null; if (this.isTimeRange()) { sliderEnd = html` `; dateTxtEnd = html`
${this.getLocalDate(this.dateEnd)}
`; } return html`
${sliderStart} ${sliderEnd ? html`${sliderEnd}` : html``}
${dateTxtStart} ${dateTxtEnd ? html`${dateTxtEnd}` : html``}
`; } /** * Set up the start date and the end date based on the time attribute. * Set up also the slider min and max and possible matching dates. * @protected * @override */ protected setupMinMaxDefaultValues(): void { this.datesSteps = this.computeDatesSteps(); const dateStart = +new Date(this.timeProp.minDefValue ?? this.timeProp.minValue); const dateEnd = +new Date(this.timeProp.maxDefValue ?? this.timeProp.maxValue); const dateStartInSteps = this.findNearestValue(dateStart, this.datesSteps); const dateEndInSteps = this.findNearestValue(dateEnd, this.datesSteps); this.sliderStart = this.datesSteps.indexOf(dateStartInSteps); this.sliderEnd = this.datesSteps.indexOf(dateEndInSteps); this.updateTime(dateStart, dateEnd); this.onSliderRelease(); } /** * In an array of number, find the nearest matching value. * The values must be sorted (smallest first). * @param value the base value to find the nearest value. * @param values the sorted possible values. * @returns The nearest value in the values array. * @private */ private findNearestValue(value: number, values: number[]): number { return values.reduce((prev, curr) => { return Math.abs(curr - value) < Math.abs(prev - value) ? curr : prev; }, 0); } /** * Update start time on input change. * @param event input event. * @protected * @override */ protected onDateStartSelected(event: InputEvent): void { const target: HTMLInputElement = event.target as HTMLInputElement; const value = parseInt(target.value); this.sliderStart = value; this.updateTime(this.datesSteps[value]); this.onSliderRelease(); } /** * Update end time on input change. * @param event input event. * @protected * @override */ protected onDateEndSelected(event: InputEvent): void { const target: HTMLInputElement = event.target as HTMLInputElement; const value = parseInt(target.value); this.sliderEnd = value; this.updateTime(this.datesSteps[this.sliderStart], this.datesSteps[value]); this.onSliderRelease(); } /** * Update the slider representation on slider move. * @param event input event. * @private */ private onDateStartMoved(event: InputEvent): void { this.onDateMoved(true, event); } /** * Update the slider representation on slider move. * @param event input event. * @private */ private onDateEndMoved(event: InputEvent): void { this.onDateMoved(false, event); } /** * Get the right date representation based on the resolution. * @param date The date (timestamp) to format. * @returns the date in a local format. * @private */ private getLocalDate(date: number): string { const option: Intl.DateTimeFormatOptions = { year: 'numeric', }; if (this.timeProp.resolution === TimePropertyResolutionEnum.SECOND) { option.hour = 'numeric'; option.day = 'numeric'; option.month = 'numeric'; } if (this.timeProp.resolution === TimePropertyResolutionEnum.DAY) { option.day = 'numeric'; option.month = 'numeric'; } else if (this.timeProp.resolution === TimePropertyResolutionEnum.MONTH) { option.month = 'numeric'; } return new Date(date).toLocaleDateString(undefined, option); } /** * @returns all possible date in an array, based on the wanted values OR * on the wanted interval and the min/max dates. Max values: 10000. * @private */ private computeDatesSteps(): number[] { // If predefined values, returns them as date. if (this.timeProp.values) { return this.computeDatesStepsFromValues(); } const dateMin = +new Date(this.timeProp.minValue); const dateMax = +new Date(this.timeProp.maxValue); // No timeProp.interval ? returns min max and notify the error. if (!this.timeProp.interval && this.time.interval.length !== 4) { console.error('No valid time interval provided.'); return [dateMin, dateMax]; } // Compute date steps from time.interval const secondsInterval = this.timeProp.interval[3]; if (secondsInterval > 0) { return this.computeDatesStepsForSeconds(dateMin, dateMax, secondsInterval); } const dayInterval = this.timeProp.interval[2]; if (dayInterval > 0) { return this.computeDatesStepsForDays(dateMin, dateMax, dayInterval); } const monthInterval = this.timeProp.interval[1]; if (monthInterval > 0) { return this.computeDatesStepsForMonth(dateMin, dateMax, monthInterval); } const yearInterval = this.timeProp.interval[0]; return this.computeDatesStepsForYear(dateMin, dateMax, yearInterval); } /** * @param length the length of wanted array. * @returns An array with repeated null values, the times of the provided * length (limited to 10000). * @private */ private getLimitedArray = (length: number): null[] => { return Array(Math.min(length, 10000)).fill(null) as null[]; }; /** * @returns the timestamp of the time.values. * @private */ private computeDatesStepsFromValues(): number[] { return this.timeProp.values.map((value) => +new Date(value)); } /** * @param dateMin The minimal date. * @param dateMax The maximal date * @param interval the wanted interval * @returns All the seconds timestamps between the provided dates and regarding the wanted interval. * @private */ private computeDatesStepsForSeconds(dateMin: number, dateMax: number, interval: number): number[] { const oneSecond = 1000; const dateDiff = dateMax - dateMin; const steps = Math.ceil(dateDiff / (oneSecond * interval)); const datesSteps = this.getLimitedArray(steps).map((_, index) => { const date = new Date(dateMin); date.setSeconds(date.getSeconds() + oneSecond * index); return +date; }); datesSteps.push(+new Date(this.timeProp.maxValue)); return datesSteps; } /** * @param dateMin The minimal date. * @param dateMax The maximal date * @param interval the wanted interval * @returns All the days timestamps between the provided dates and regarding the wanted interval. * @private */ private computeDatesStepsForDays(dateMin: number, dateMax: number, interval: number): number[] { const oneSecond = 1000; const oneDay = oneSecond * 60 * 60 * 24; const dateDiff = dateMax - dateMin; const steps = Math.ceil(dateDiff / (oneDay * interval)); const datesSteps = this.getLimitedArray(steps).map((_, index) => { const date = new Date(dateMin); date.setDate(date.getDate() + index); return +date; }); datesSteps.push(+new Date(this.timeProp.maxValue)); return datesSteps; } /** * @param dateMin The minimal date. * @param dateMax The maximal date * @param interval the wanted interval * @returns All the month timestamp between the provided dates and regarding the wanted interval. * @private */ private computeDatesStepsForMonth(dateMin: number, dateMax: number, interval: number): number[] { let nbMonth = this.getMonthDiff(new Date(dateMin), new Date(dateMax)); nbMonth = nbMonth > 0 ? nbMonth : 1; nbMonth = Math.ceil(nbMonth / interval); const datesSteps = this.getLimitedArray(nbMonth).map((_, index) => { const date = new Date(dateMin); date.setMonth(date.getMonth() + index); return +date; }); datesSteps.push(+new Date(this.timeProp.maxValue)); return datesSteps; } /** * @param dateMin The minimal date. * @param dateMax The maximal date * @param interval the wanted interval * @returns All the years timestamp between the provided dates and regarding the wanted interval. * @private */ private computeDatesStepsForYear(dateMin: number, dateMax: number, interval: number): number[] { let nbYear = this.getYearDiff(new Date(dateMin), new Date(dateMax)); nbYear = nbYear > 0 ? nbYear : 1; nbYear = Math.ceil(nbYear / interval); const datesSteps = this.getLimitedArray(nbYear).map((_, index) => { const date = new Date(dateMin); date.setFullYear(date.getFullYear() + index); return +date; }); datesSteps.push(+new Date(this.timeProp.maxValue)); return datesSteps; } /** * @returns The amount of months between two dates. * @param date1 min date. * @param date2 max date. * @private */ private getMonthDiff(date1: Date, date2: Date): number { let months = (date2.getFullYear() - date1.getFullYear()) * 12; months -= date1.getMonth(); months += date2.getMonth(); return months <= 0 ? 0 : months; } /** * @returns The amount of years between two dates. * @param date1 min date. * @param date2 max date. * @private */ private getYearDiff(date1: Date, date2: Date): number { return date2.getFullYear() - date1.getFullYear(); } /** * Updates the input values and representation. * Manage the sliders to not let one goes beyond the other. * @param isStart is the start/first slider * @param event the original input event. * @private */ private onDateMoved(isStart: boolean, event: InputEvent): void { if (!this.isTimeRange()) { this.onDateStartSelected(event); return; } const minGap = 0; const sliderStart: HTMLInputElement = this.shadowRoot.querySelector('.slider-start'); const sliderEnd: HTMLInputElement = this.shadowRoot.querySelector('.slider-end'); // Block slider to not let it go beyond the other slider. if (parseInt(sliderEnd.value) - parseInt(sliderStart.value) <= minGap) { if (isStart) { sliderStart.value = `${parseInt(sliderEnd.value) - minGap}`; } else { sliderEnd.value = `${parseInt(sliderStart.value) + minGap}`; } } if (isStart) { this.onDateStartSelected(event); } else { this.onDateEndSelected(event); } this.fillColor(); } /** * Update the slider colors based on both sliders positions. * @private */ private fillColor() { const sliderTrack: HTMLInputElement = this.shadowRoot.querySelector('.slider-track'); const nbSteps = this.datesSteps.length - 1; const percent1 = (this.sliderStart / nbSteps) * 100; const percent2 = (this.sliderEnd / nbSteps) * 100; sliderTrack.style.background = `linear-gradient(to right, var(--brand-secondary) ${percent1}% , var(--brand-primary) ${percent1}% , var(--brand-primary) ${percent2}%, var(--brand-secondary) ${percent2}%)`; } }