@internetarchive/histogram-date-range 1.2.2-alpha-webdev7377.8 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,1057 +1,1080 @@
1
- import '@internetarchive/ia-activity-indicator';
2
- import dayjs from 'dayjs/esm';
3
- import customParseFormat from 'dayjs/esm/plugin/customParseFormat';
4
- import {
5
- css,
6
- html,
7
- LitElement,
8
- nothing,
9
- PropertyValues,
10
- svg,
11
- SVGTemplateResult,
12
- TemplateResult,
13
- } from 'lit';
14
- import { customElement, property, state } from 'lit/decorators.js';
15
- import { live } from 'lit/directives/live.js';
16
-
17
- dayjs.extend(customParseFormat);
18
-
19
- // these values can be overridden via the component's HTML (camelCased) attributes
20
- const WIDTH = 180;
21
- const HEIGHT = 40;
22
- const SLIDER_WIDTH = 10;
23
- const TOOLTIP_WIDTH = 125;
24
- const TOOLTIP_HEIGHT = 30;
25
- const DATE_FORMAT = 'YYYY';
26
- const MISSING_DATA = 'no data';
27
- const UPDATE_DEBOUNCE_DELAY_MS = 0;
28
-
29
- // this constant is not set up to be overridden
30
- const SLIDER_CORNER_SIZE = 4;
31
-
32
- // these CSS custom props can be overridden from the HTML that is invoking this component
33
- const sliderColor = css`var(--histogramDateRangeSliderColor, #4B65FE)`;
34
- const selectedRangeColor = css`var(--histogramDateRangeSelectedRangeColor, #DBE0FF)`;
35
- const barIncludedFill = css`var(--histogramDateRangeBarIncludedFill, #2C2C2C)`;
36
- const activityIndicatorColor = css`var(--histogramDateRangeActivityIndicator, #2C2C2C)`;
37
- const barExcludedFill = css`var(--histogramDateRangeBarExcludedFill, #CCCCCC)`;
38
- const inputRowMargin = css`var(--histogramDateRangeInputRowMargin, 0)`;
39
- const inputBorder = css`var(--histogramDateRangeInputBorder, 0.5px solid #2C2C2C)`;
40
- const inputWidth = css`var(--histogramDateRangeInputWidth, 35px)`;
41
- const inputFontSize = css`var(--histogramDateRangeInputFontSize, 1.2rem)`;
42
- const inputFontFamily = css`var(--histogramDateRangeInputFontFamily, sans-serif)`;
43
- const tooltipBackgroundColor = css`var(--histogramDateRangeTooltipBackgroundColor, #2C2C2C)`;
44
- const tooltipTextColor = css`var(--histogramDateRangeTooltipTextColor, #FFFFFF)`;
45
- const tooltipFontSize = css`var(--histogramDateRangeTooltipFontSize, 1.1rem)`;
46
- const tooltipFontFamily = css`var(--histogramDateRangeTooltipFontFamily, sans-serif)`;
47
-
48
- type SliderId = 'slider-min' | 'slider-max';
49
-
50
- export type BinSnappingInterval = 'none' | 'month' | 'year';
51
-
52
- interface HistogramItem {
53
- value: number;
54
- height: number;
55
- binStart: string;
56
- binEnd: string;
57
- tooltip: string;
58
- }
59
-
60
- interface BarDataset extends DOMStringMap {
61
- numItems: string;
62
- binStart: string;
63
- binEnd: string;
64
- }
65
-
66
- @customElement('histogram-date-range')
67
- export class HistogramDateRange extends LitElement {
68
- /* eslint-disable lines-between-class-members */
69
-
70
- // public reactive properties that can be set via HTML attributes
71
- @property({ type: Number }) width = WIDTH;
72
- @property({ type: Number }) height = HEIGHT;
73
- @property({ type: Number }) sliderWidth = SLIDER_WIDTH;
74
- @property({ type: Number }) tooltipWidth = TOOLTIP_WIDTH;
75
- @property({ type: Number }) tooltipHeight = TOOLTIP_HEIGHT;
76
- @property({ type: Number }) updateDelay = UPDATE_DEBOUNCE_DELAY_MS;
77
- @property({ type: String }) dateFormat = DATE_FORMAT;
78
- @property({ type: String }) missingDataMessage = MISSING_DATA;
79
- @property({ type: String }) minDate = '';
80
- @property({ type: String }) maxDate = '';
81
- @property({ type: Boolean }) disabled = false;
82
- @property({ type: Array }) bins: number[] = [];
83
- /** If true, update events will not be canceled by the date inputs receiving focus */
84
- @property({ type: Boolean }) updateWhileFocused = false;
85
- /** What interval bins should be rounded to for display */
86
- @property({ type: String }) binSnapping: BinSnappingInterval = 'none';
87
-
88
- // internal reactive properties not exposed as attributes
89
- @state() private _tooltipOffset = 0;
90
- @state() private _tooltipContent?: TemplateResult;
91
- @state() private _tooltipVisible = false;
92
- @state() private _tooltipDateFormat?: string;
93
- @state() private _isDragging = false;
94
- @state() private _isLoading = false;
95
-
96
- // non-reactive properties (changes don't auto-trigger re-rendering)
97
- private _minSelectedDate = '';
98
- private _maxSelectedDate = '';
99
- private _minDateMS = 0;
100
- private _maxDateMS = 0;
101
- private _dragOffset = 0;
102
- private _histWidth = 0;
103
- private _binWidth = 0;
104
- private _currentSlider?: SVGRectElement;
105
- private _histData: HistogramItem[] = [];
106
- private _emitUpdatedEventTimer?: ReturnType<typeof setTimeout>;
107
- private _previousDateRange = '';
108
-
109
- /* eslint-enable lines-between-class-members */
110
-
111
- disconnectedCallback(): void {
112
- this.removeListeners();
113
- super.disconnectedCallback();
114
- }
115
-
116
- willUpdate(changedProps: PropertyValues): void {
117
- // check for changes that would affect bin data calculations
118
- if (
119
- changedProps.has('bins') ||
120
- changedProps.has('minDate') ||
121
- changedProps.has('maxDate') ||
122
- changedProps.has('minSelectedDate') ||
123
- changedProps.has('maxSelectedDate') ||
124
- changedProps.has('width') ||
125
- changedProps.has('height') ||
126
- changedProps.has('binSnapping')
127
- ) {
128
- this.handleDataUpdate();
129
- }
130
- }
131
-
132
- /**
133
- * Set private properties that depend on the attribute bin data
134
- *
135
- * We're caching these values and not using getters to avoid recalculating all
136
- * of the hist data every time the user drags a slider or hovers over a bar
137
- * creating a tooltip.
138
- */
139
- private handleDataUpdate(): void {
140
- if (!this.hasBinData) {
141
- return;
142
- }
143
- this._histWidth = this.width - this.sliderWidth * 2;
144
- this._minDateMS = this.snapTimestamp(this.getMSFromString(this.minDate));
145
- // NB: The max date string, converted as-is to ms, represents the _start_ of the final date interval; we want the _end_.
146
- this._maxDateMS =
147
- this.snapTimestamp(
148
- this.getMSFromString(this.maxDate) + this.snapInterval
149
- ) + this.snapEndOffset;
150
-
151
- this._binWidth = this._histWidth / this._numBins;
152
- this._previousDateRange = this.currentDateRangeString;
153
- this._histData = this.calculateHistData();
154
- this.minSelectedDate = this.minSelectedDate
155
- ? this.minSelectedDate
156
- : this.minDate;
157
- this.maxSelectedDate = this.maxSelectedDate
158
- ? this.maxSelectedDate
159
- : this.maxDate;
160
- }
161
-
162
- /**
163
- * Rounds the given timestamp to the next full second.
164
- */
165
- private snapToNextSecond(timestamp: number): number {
166
- return Math.ceil(timestamp / 1000) * 1000;
167
- }
168
-
169
- /**
170
- * Rounds the given timestamp to the (approximate) nearest start of a month,
171
- * such that dates up to and including the 15th of the month are rounded down,
172
- * while dates past the 15th are rounded up.
173
- */
174
- private snapToMonth(timestamp: number): number {
175
- const d = new Date(timestamp);
176
- const [year, month, day] = [d.getFullYear(), d.getMonth(), d.getDate()];
177
-
178
- return day < 16 // Obviously only an approximation, but good enough for snapping
179
- ? new Date(year, month, 1).getTime()
180
- : new Date(year, month + 1, 1).getTime();
181
- }
182
-
183
- /**
184
- * Rounds the given timestamp to the (approximate) nearest start of a year,
185
- * such that dates up to the end of June are rounded down, while dates in
186
- * July or later are rounded up.
187
- */
188
- private snapToYear(timestamp: number): number {
189
- const d = new Date(timestamp);
190
- const [year, month] = [d.getFullYear(), d.getMonth()];
191
-
192
- return month < 6 // NB: months are 0-indexed, so 6 = July
193
- ? new Date(year, 0, 1).getTime()
194
- : new Date(year + 1, 0, 1).getTime();
195
- }
196
-
197
- /**
198
- * Rounds the given timestamp according to the `binSnapping` property.
199
- * Default is simply to snap to the nearest full second.
200
- */
201
- private snapTimestamp(timestamp: number): number {
202
- switch (this.binSnapping) {
203
- case 'year':
204
- return this.snapToYear(timestamp);
205
- case 'month':
206
- return this.snapToMonth(timestamp);
207
- case 'none':
208
- default:
209
- return this.snapToNextSecond(timestamp);
210
- }
211
- }
212
-
213
- private calculateHistData(): HistogramItem[] {
214
- const { bins, height, dateRangeMS, _numBins, _minDateMS } = this;
215
- const minValue = Math.min(...this.bins);
216
- const maxValue = Math.max(...this.bins);
217
- // if there is no difference between the min and max values, use a range of
218
- // 1 because log scaling will fail if the range is 0
219
- const valueRange = minValue === maxValue ? 1 : Math.log1p(maxValue);
220
- const valueScale = height / valueRange;
221
- const dateScale = dateRangeMS / _numBins;
222
-
223
- return bins.map((v: number, i: number) => {
224
- const binStartMS = this.snapTimestamp(i * dateScale + _minDateMS);
225
- const binStart = this.formatDate(binStartMS);
226
-
227
- const binEndMS =
228
- this.snapTimestamp((i + 1) * dateScale + _minDateMS) +
229
- this.snapEndOffset;
230
- const binEnd = this.formatDate(binEndMS);
231
-
232
- const tooltipStart = this.formatDate(binStartMS, this.tooltipDateFormat);
233
- const tooltipEnd = this.formatDate(binEndMS, this.tooltipDateFormat);
234
- // If start/end are the same, just render a single value
235
- const tooltip =
236
- tooltipStart === tooltipEnd
237
- ? tooltipStart
238
- : `${tooltipStart} - ${tooltipEnd}`;
239
-
240
- return {
241
- value: v,
242
- // use log scaling for the height of the bar to prevent tall bars from
243
- // making the smaller ones too small to see
244
- height: Math.floor(Math.log1p(v) * valueScale),
245
- binStart,
246
- binEnd,
247
- tooltip,
248
- };
249
- });
250
- }
251
-
252
- private get hasBinData(): boolean {
253
- return this._numBins > 0;
254
- }
255
-
256
- private get _numBins(): number {
257
- if (!this.bins || !this.bins.length) {
258
- return 0;
259
- }
260
- return this.bins.length;
261
- }
262
-
263
- private get histogramLeftEdgeX(): number {
264
- return this.sliderWidth;
265
- }
266
-
267
- private get histogramRightEdgeX(): number {
268
- return this.width - this.sliderWidth;
269
- }
270
-
271
- /**
272
- * Approximate size in ms of the interval to which bins are snapped.
273
- */
274
- private get snapInterval(): number {
275
- const yearMS = 31_536_000_000; // A 365-day approximation of ms in a year
276
- const monthMS = 2_592_000_000; // A 30-day approximation of ms in a month
277
- switch (this.binSnapping) {
278
- case 'year':
279
- return yearMS;
280
- case 'month':
281
- return monthMS;
282
- case 'none':
283
- default:
284
- return 0;
285
- }
286
- }
287
-
288
- /**
289
- * Offset added to the end of each bin to ensure disjoin intervals, if applicable.
290
- */
291
- private get snapEndOffset(): number {
292
- return this.binSnapping !== 'none' && this._numBins > 1 ? -1 : 0;
293
- }
294
-
295
- /**
296
- * Optional date format to use for tooltips only.
297
- * Falls back to `dateFormat` if not provided.
298
- */
299
- @property({ type: String }) get tooltipDateFormat(): string {
300
- return this._tooltipDateFormat ?? this.dateFormat ?? DATE_FORMAT;
301
- }
302
-
303
- set tooltipDateFormat(value: string) {
304
- this._tooltipDateFormat = value;
305
- }
306
-
307
- /** component's loading (and disabled) state */
308
- @property({ type: Boolean }) get loading(): boolean {
309
- return this._isLoading;
310
- }
311
-
312
- set loading(value: boolean) {
313
- this.disabled = value;
314
- this._isLoading = value;
315
- }
316
-
317
- /** formatted minimum date of selected date range */
318
- @property() get minSelectedDate(): string {
319
- return this.formatDate(this.getMSFromString(this._minSelectedDate));
320
- }
321
-
322
- /** updates minSelectedDate if new date is valid */
323
- set minSelectedDate(rawDate: string) {
324
- if (!this._minSelectedDate) {
325
- // because the values needed to calculate valid max/min values are not
326
- // available during the lit init when it's populating properties from
327
- // attributes, fall back to just the raw date if nothing is already set
328
- this._minSelectedDate = rawDate;
329
- return;
330
- }
331
- const proposedDateMS = this.getMSFromString(rawDate);
332
- const isValidDate = !Number.isNaN(proposedDateMS);
333
- const isNotTooRecent =
334
- proposedDateMS <= this.getMSFromString(this.maxSelectedDate);
335
- if (isValidDate && isNotTooRecent) {
336
- this._minSelectedDate = this.formatDate(proposedDateMS);
337
- }
338
- this.requestUpdate();
339
- }
340
-
341
- /** formatted maximum date of selected date range */
342
- @property() get maxSelectedDate(): string {
343
- return this.formatDate(this.getMSFromString(this._maxSelectedDate));
344
- }
345
-
346
- /** updates maxSelectedDate if new date is valid */
347
- set maxSelectedDate(rawDate: string) {
348
- if (!this._maxSelectedDate) {
349
- // because the values needed to calculate valid max/min values are not
350
- // available during the lit init when it's populating properties from
351
- // attributes, fall back to just the raw date if nothing is already set
352
- this._maxSelectedDate = rawDate;
353
- return;
354
- }
355
- const proposedDateMS = this.getMSFromString(rawDate);
356
- const isValidDate = !Number.isNaN(proposedDateMS);
357
- const isNotTooOld =
358
- proposedDateMS >= this.getMSFromString(this.minSelectedDate);
359
- if (isValidDate && isNotTooOld) {
360
- this._maxSelectedDate = this.formatDate(proposedDateMS);
361
- }
362
- this.requestUpdate();
363
- }
364
-
365
- /** horizontal position of min date slider */
366
- get minSliderX(): number {
367
- const x = this.translateDateToPosition(this.minSelectedDate);
368
- return this.validMinSliderX(x);
369
- }
370
-
371
- /** horizontal position of max date slider */
372
- get maxSliderX(): number {
373
- const maxSelectedDateMS = this.snapTimestamp(
374
- this.getMSFromString(this.maxSelectedDate) + this.snapInterval
375
- );
376
- const x = this.translateDateToPosition(this.formatDate(maxSelectedDateMS));
377
- return this.validMaxSliderX(x);
378
- }
379
-
380
- private get dateRangeMS(): number {
381
- return this._maxDateMS - this._minDateMS;
382
- }
383
-
384
- private showTooltip(e: PointerEvent): void {
385
- if (this._isDragging || this.disabled) {
386
- return;
387
- }
388
- const target = e.currentTarget as SVGRectElement;
389
- const x = target.x.baseVal.value + this.sliderWidth / 2;
390
- const dataset = target.dataset as BarDataset;
391
- const itemsText = `item${dataset.numItems !== '1' ? 's' : ''}`;
392
- const formattedNumItems = Number(dataset.numItems).toLocaleString();
393
-
394
- this._tooltipOffset =
395
- x + (this._binWidth - this.sliderWidth - this.tooltipWidth) / 2;
396
-
397
- this._tooltipContent = html`
398
- ${formattedNumItems} ${itemsText}<br />
399
- ${dataset.tooltip}
400
- `;
401
- this._tooltipVisible = true;
402
- }
403
-
404
- private hideTooltip(): void {
405
- this._tooltipContent = undefined;
406
- this._tooltipVisible = false;
407
- }
408
-
409
- // use arrow functions (rather than standard JS class instance methods) so
410
- // that `this` is bound to the histogramDateRange object and not the event
411
- // target. for more info see
412
- // https://lit-element.polymer-project.org/guide/events#using-this-in-event-listeners
413
- private drag = (e: PointerEvent): void => {
414
- // prevent selecting text or other ranges while dragging, especially in Safari
415
- e.preventDefault();
416
- if (this.disabled) {
417
- return;
418
- }
419
- this.setDragOffset(e);
420
- this._isDragging = true;
421
- this.addListeners();
422
- this.cancelPendingUpdateEvent();
423
- };
424
-
425
- private drop = (): void => {
426
- if (this._isDragging) {
427
- this.removeListeners();
428
- this.beginEmitUpdateProcess();
429
- }
430
- this._isDragging = false;
431
- };
432
-
433
- /**
434
- * Adjust the date range based on slider movement
435
- *
436
- * @param e PointerEvent from the slider being moved
437
- */
438
- private move = (e: PointerEvent): void => {
439
- const histogramClientX = this.getBoundingClientRect().x;
440
- const newX = e.clientX - histogramClientX - this._dragOffset;
441
- const slider = this._currentSlider as SVGRectElement;
442
- if ((slider.id as SliderId) === 'slider-min') {
443
- this.minSelectedDate = this.translatePositionToDate(
444
- this.validMinSliderX(newX)
445
- );
446
- } else {
447
- this.maxSelectedDate = this.translatePositionToDate(
448
- this.validMaxSliderX(newX)
449
- );
450
- if (this.getMSFromString(this.maxSelectedDate) > this._maxDateMS) {
451
- this.maxSelectedDate = this.maxDate;
452
- }
453
- }
454
- };
455
-
456
- /**
457
- * Constrain a proposed value for the minimum (left) slider
458
- *
459
- * If the value is less than the leftmost valid position, then set it to the
460
- * left edge of the histogram (ie the slider width). If the value is greater
461
- * than the rightmost valid position (the position of the max slider), then
462
- * set it to the position of the max slider
463
- */
464
- private validMinSliderX(newX: number): number {
465
- // allow the left slider to go right only to the right slider, even if the
466
- // max selected date is out of range
467
- const rightLimit = Math.min(
468
- this.translateDateToPosition(this.maxSelectedDate),
469
- this.histogramRightEdgeX
470
- );
471
- newX = this.clamp(newX, this.histogramLeftEdgeX, rightLimit);
472
- const isInvalid =
473
- Number.isNaN(newX) || rightLimit < this.histogramLeftEdgeX;
474
- return isInvalid ? this.histogramLeftEdgeX : newX;
475
- }
476
-
477
- /**
478
- * Constrain a proposed value for the maximum (right) slider
479
- *
480
- * If the value is greater than the rightmost valid position, then set it to
481
- * the right edge of the histogram (ie histogram width - slider width). If the
482
- * value is less than the leftmost valid position (the position of the min
483
- * slider), then set it to the position of the min slider
484
- */
485
- private validMaxSliderX(newX: number): number {
486
- // allow the right slider to go left only to the left slider, even if the
487
- // min selected date is out of range
488
- const leftLimit = Math.max(
489
- this.histogramLeftEdgeX,
490
- this.translateDateToPosition(this.minSelectedDate)
491
- );
492
- newX = this.clamp(newX, leftLimit, this.histogramRightEdgeX);
493
- const isInvalid =
494
- Number.isNaN(newX) || leftLimit > this.histogramRightEdgeX;
495
- return isInvalid ? this.histogramRightEdgeX : newX;
496
- }
497
-
498
- private addListeners(): void {
499
- window.addEventListener('pointermove', this.move);
500
- window.addEventListener('pointerup', this.drop);
501
- window.addEventListener('pointercancel', this.drop);
502
- }
503
-
504
- private removeListeners(): void {
505
- window.removeEventListener('pointermove', this.move);
506
- window.removeEventListener('pointerup', this.drop);
507
- window.removeEventListener('pointercancel', this.drop);
508
- }
509
-
510
- /**
511
- * start a timer to emit an update event. this timer can be canceled (and the
512
- * event not emitted) if user drags a slider or focuses a date input within
513
- * the update delay
514
- */
515
- private beginEmitUpdateProcess(): void {
516
- this.cancelPendingUpdateEvent();
517
- this._emitUpdatedEventTimer = setTimeout(() => {
518
- if (this.currentDateRangeString === this._previousDateRange) {
519
- // don't emit duplicate event if no change since last emitted event
520
- return;
521
- }
522
- this._previousDateRange = this.currentDateRangeString;
523
- const options = {
524
- detail: {
525
- minDate: this.minSelectedDate,
526
- maxDate: this.maxSelectedDate,
527
- },
528
- bubbles: true,
529
- composed: true,
530
- };
531
- this.dispatchEvent(new CustomEvent('histogramDateRangeUpdated', options));
532
- }, this.updateDelay);
533
- }
534
-
535
- private cancelPendingUpdateEvent(): void {
536
- if (this._emitUpdatedEventTimer === undefined) {
537
- return;
538
- }
539
- clearTimeout(this._emitUpdatedEventTimer);
540
- this._emitUpdatedEventTimer = undefined;
541
- }
542
-
543
- /**
544
- * find position of pointer in relation to the current slider
545
- */
546
- private setDragOffset(e: PointerEvent): void {
547
- this._currentSlider = e.currentTarget as SVGRectElement;
548
- const sliderX =
549
- (this._currentSlider.id as SliderId) === 'slider-min'
550
- ? this.minSliderX
551
- : this.maxSliderX;
552
- const histogramClientX = this.getBoundingClientRect().x;
553
- this._dragOffset = e.clientX - histogramClientX - sliderX;
554
- }
555
-
556
- /**
557
- * @param x horizontal position of slider
558
- * @returns string representation of date
559
- */
560
- private translatePositionToDate(x: number): string {
561
- // Snap to the nearest second, fixing the case where input like 1/1/2010
562
- // would get translated to 12/31/2009 due to slight discrepancies from
563
- // pixel boundaries and floating point error.
564
- const milliseconds = this.snapToNextSecond(
565
- ((x - this.sliderWidth) * this.dateRangeMS) / this._histWidth
566
- );
567
- return this.formatDate(this._minDateMS + milliseconds);
568
- }
569
-
570
- /**
571
- * Returns slider x-position corresponding to given date
572
- *
573
- * @param date
574
- * @returns x-position of slider
575
- */
576
- private translateDateToPosition(date: string): number {
577
- const milliseconds = this.getMSFromString(date);
578
- return (
579
- this.sliderWidth +
580
- ((milliseconds - this._minDateMS) * this._histWidth) / this.dateRangeMS
581
- );
582
- }
583
-
584
- /** ensure that the returned value is between minValue and maxValue */
585
- private clamp(x: number, minValue: number, maxValue: number): number {
586
- return Math.min(Math.max(x, minValue), maxValue);
587
- }
588
-
589
- private handleInputFocus(): void {
590
- if (!this.updateWhileFocused) {
591
- this.cancelPendingUpdateEvent();
592
- }
593
- }
594
-
595
- private handleMinDateInput(e: Event): void {
596
- const target = e.currentTarget as HTMLInputElement;
597
- if (target.value !== this.minSelectedDate) {
598
- this.minSelectedDate = target.value;
599
- this.beginEmitUpdateProcess();
600
- }
601
- }
602
-
603
- private handleMaxDateInput(e: Event): void {
604
- const target = e.currentTarget as HTMLInputElement;
605
- if (target.value !== this.maxSelectedDate) {
606
- this.maxSelectedDate = target.value;
607
- this.beginEmitUpdateProcess();
608
- }
609
- }
610
-
611
- private handleKeyUp(e: KeyboardEvent): void {
612
- if (e.key === 'Enter') {
613
- const target = e.currentTarget as HTMLInputElement;
614
- target.blur();
615
- if (target.id === 'date-min') {
616
- this.handleMinDateInput(e);
617
- } else if (target.id === 'date-max') {
618
- this.handleMaxDateInput(e);
619
- }
620
- }
621
- }
622
-
623
- private get currentDateRangeString(): string {
624
- return `${this.minSelectedDate}:${this.maxSelectedDate}`;
625
- }
626
-
627
- private getMSFromString(date: unknown): number {
628
- // It's possible that `date` is not a string in certain situations.
629
- // For instance if you use LitElement bindings and the date is `2000`,
630
- // it will be treated as a number instead of a string. This just makes sure
631
- // we're dealing with a string.
632
- const stringified = typeof date === 'string' ? date : String(date);
633
- const digitGroupCount = (stringified.split(/(\d+)/).length - 1) / 2;
634
- if (digitGroupCount === 1) {
635
- // if there's just a single set of digits, assume it's a year
636
- const dateObj = new Date(0, 0); // start at January 1, 1900
637
- dateObj.setFullYear(Number(stringified)); // override year
638
- return dateObj.getTime(); // get time in milliseconds
639
- }
640
- return dayjs(stringified, [this.dateFormat, DATE_FORMAT]).valueOf();
641
- }
642
-
643
- /**
644
- * expand or narrow the selected range by moving the slider nearest the
645
- * clicked bar to the outer edge of the clicked bar
646
- *
647
- * @param e Event click event from a histogram bar
648
- */
649
- private handleBarClick(e: Event): void {
650
- const dataset = (e.currentTarget as SVGRectElement).dataset as BarDataset;
651
- // use the midpoint of the width of the clicked bar to determine which is
652
- // the nearest slider
653
- const clickPosition =
654
- (this.getMSFromString(dataset.binStart) +
655
- this.getMSFromString(dataset.binEnd)) /
656
- 2;
657
- const distanceFromMinSlider = Math.abs(
658
- clickPosition - this.getMSFromString(this.minSelectedDate)
659
- );
660
- const distanceFromMaxSlider = Math.abs(
661
- clickPosition - this.getMSFromString(this.maxSelectedDate)
662
- );
663
- // update the selected range by moving the nearer slider
664
- if (distanceFromMinSlider < distanceFromMaxSlider) {
665
- this.minSelectedDate = dataset.binStart;
666
- } else {
667
- this.maxSelectedDate = dataset.binEnd;
668
- }
669
- this.beginEmitUpdateProcess();
670
- }
671
-
672
- private get minSliderTemplate(): SVGTemplateResult {
673
- // width/height in pixels of curved part of the sliders (like
674
- // border-radius); used as part of a SVG quadratic curve. see
675
- // https://developer.mozilla.org/en-US/docs/Web/SVG/Tutorial/Paths#curve_commands
676
- const cs = SLIDER_CORNER_SIZE;
677
-
678
- const sliderShape = `
679
- M${this.minSliderX},0
680
- h-${this.sliderWidth - cs}
681
- q-${cs},0 -${cs},${cs}
682
- v${this.height - cs * 2}
683
- q0,${cs} ${cs},${cs}
684
- h${this.sliderWidth - cs}
685
- `;
686
- return this.generateSliderSVG(this.minSliderX, 'slider-min', sliderShape);
687
- }
688
-
689
- private get maxSliderTemplate(): SVGTemplateResult {
690
- const cs = SLIDER_CORNER_SIZE;
691
- const sliderShape = `
692
- M${this.maxSliderX},0
693
- h${this.sliderWidth - cs}
694
- q${cs},0 ${cs},${cs}
695
- v${this.height - cs * 2}
696
- q0,${cs} -${cs},${cs}
697
- h-${this.sliderWidth - cs}
698
- `;
699
- return this.generateSliderSVG(this.maxSliderX, 'slider-max', sliderShape);
700
- }
701
-
702
- private generateSliderSVG(
703
- sliderPositionX: number,
704
- id: SliderId,
705
- sliderShape: string
706
- ): SVGTemplateResult {
707
- // whether the curved part of the slider is facing towards the left (1), ie
708
- // minimum, or facing towards the right (-1), ie maximum
709
- const k = id === 'slider-min' ? 1 : -1;
710
-
711
- return svg`
712
- <svg
713
- id="${id}"
714
- class="
715
- ${this.disabled ? '' : 'draggable'}
716
- ${this._isDragging ? 'dragging' : ''}"
717
- @pointerdown="${this.drag}"
718
- >
719
- <path d="${sliderShape} z" fill="${sliderColor}" />
720
- <rect
721
- x="${
722
- sliderPositionX - this.sliderWidth * k + this.sliderWidth * 0.4 * k
723
- }"
724
- y="${this.height / 3}"
725
- width="1"
726
- height="${this.height / 3}"
727
- fill="white"
728
- />
729
- <rect
730
- x="${
731
- sliderPositionX - this.sliderWidth * k + this.sliderWidth * 0.6 * k
732
- }"
733
- y="${this.height / 3}"
734
- width="1"
735
- height="${this.height / 3}"
736
- fill="white"
737
- />
738
- </svg>
739
- `;
740
- }
741
-
742
- get selectedRangeTemplate(): SVGTemplateResult {
743
- return svg`
744
- <rect
745
- x="${this.minSliderX}"
746
- y="0"
747
- width="${this.maxSliderX - this.minSliderX}"
748
- height="${this.height}"
749
- fill="${selectedRangeColor}"
750
- />`;
751
- }
752
-
753
- get histogramTemplate(): SVGTemplateResult[] {
754
- const xScale = this._histWidth / this._numBins;
755
- const barWidth = xScale - 1;
756
- let x = this.sliderWidth; // start at the left edge of the histogram
757
-
758
- return this._histData.map(data => {
759
- const { minSelectedDate, maxSelectedDate } = this;
760
- const barHeight = data.height;
761
-
762
- const binIsBeforeMin = this.isBefore(data.binEnd, minSelectedDate);
763
- const binIsAfterMax = this.isAfter(data.binStart, maxSelectedDate);
764
- const barFill =
765
- binIsBeforeMin || binIsAfterMax ? barExcludedFill : barIncludedFill;
766
-
767
- // the stroke-dasharray style below creates a transparent border around the
768
- // right edge of the bar, which prevents user from encountering a gap
769
- // between adjacent bars (eg when viewing the tooltips or when trying to
770
- // extend the range by clicking on a bar)
771
- const barStyle = `stroke-dasharray: 0 ${barWidth} ${barHeight} ${barWidth} 0 ${barHeight}`;
772
-
773
- const bar = svg`
774
- <rect
775
- class="bar"
776
- style=${barStyle}
777
- x=${x}
778
- y=${this.height - barHeight}
779
- width=${barWidth}
780
- height=${barHeight}
781
- @pointerenter=${this.showTooltip}
782
- @pointerleave=${this.hideTooltip}
783
- @click=${this.handleBarClick}
784
- fill=${barFill}
785
- data-num-items=${data.value}
786
- data-bin-start=${data.binStart}
787
- data-bin-end=${data.binEnd}
788
- data-tooltip=${data.tooltip}
789
- />`;
790
- x += xScale;
791
- return bar;
792
- });
793
- }
794
-
795
- /** Whether the first arg represents a date strictly before the second arg */
796
- private isBefore(date1: string, date2: string): boolean {
797
- const date1MS = this.getMSFromString(date1);
798
- const date2MS = this.getMSFromString(date2);
799
- return date1MS < date2MS;
800
- }
801
-
802
- /** Whether the first arg represents a date strictly after the second arg */
803
- private isAfter(date1: string, date2: string): boolean {
804
- const date1MS = this.getMSFromString(date1);
805
- const date2MS = this.getMSFromString(date2);
806
- return date1MS > date2MS;
807
- }
808
-
809
- private formatDate(dateMS: number, format: string = this.dateFormat): string {
810
- if (Number.isNaN(dateMS)) {
811
- return '';
812
- }
813
- const date = dayjs(dateMS);
814
- if (date.year() < 1000) {
815
- // years before 1000 don't play well with dayjs custom formatting, so fall
816
- // back to displaying only the year
817
- return String(date.year());
818
- }
819
- return date.format(format);
820
- }
821
-
822
- /**
823
- * NOTE: we are relying on the lit `live` directive in the template to
824
- * ensure that the change to minSelectedDate is noticed and the input value
825
- * gets properly re-rendered. see
826
- * https://lit.dev/docs/templates/directives/#live
827
- */
828
- get minInputTemplate(): TemplateResult {
829
- return html`
830
- <input
831
- id="date-min"
832
- placeholder="${this.dateFormat}"
833
- type="text"
834
- @focus="${this.handleInputFocus}"
835
- @blur="${this.handleMinDateInput}"
836
- @keyup="${this.handleKeyUp}"
837
- .value="${live(this.minSelectedDate)}"
838
- ?disabled="${this.disabled}"
839
- />
840
- `;
841
- }
842
-
843
- get maxInputTemplate(): TemplateResult {
844
- return html`
845
- <input
846
- id="date-max"
847
- placeholder="${this.dateFormat}"
848
- type="text"
849
- @focus="${this.handleInputFocus}"
850
- @blur="${this.handleMaxDateInput}"
851
- @keyup="${this.handleKeyUp}"
852
- .value="${live(this.maxSelectedDate)}"
853
- ?disabled="${this.disabled}"
854
- />
855
- `;
856
- }
857
-
858
- get minLabelTemplate(): TemplateResult {
859
- return html`<label for="date-min" class="sr-only">Minimum date:</label>`;
860
- }
861
-
862
- get maxLabelTemplate(): TemplateResult {
863
- return html`<label for="date-max" class="sr-only">Maximum date:</label>`;
864
- }
865
-
866
- get tooltipTemplate(): TemplateResult {
867
- return html`
868
- <style>
869
- #tooltip {
870
- width: ${this.tooltipWidth}px;
871
- height: ${this.tooltipHeight}px;
872
- top: ${-9 - this.tooltipHeight}px;
873
- left: ${this._tooltipOffset}px;
874
- display: ${this._tooltipVisible ? 'block' : 'none'};
875
- }
876
- #tooltip:after {
877
- left: ${this.tooltipWidth / 2}px;
878
- }
879
- </style>
880
- <div id="tooltip">${this._tooltipContent}</div>
881
- `;
882
- }
883
-
884
- private get noDataTemplate(): TemplateResult {
885
- return html`
886
- <div class="missing-data-message">${this.missingDataMessage}</div>
887
- `;
888
- }
889
-
890
- private get activityIndicatorTemplate(): TemplateResult | typeof nothing {
891
- if (!this.loading) {
892
- return nothing;
893
- }
894
- return html`
895
- <ia-activity-indicator mode="processing"> </ia-activity-indicator>
896
- `;
897
- }
898
-
899
- static styles = css`
900
- .missing-data-message {
901
- text-align: center;
902
- }
903
- #container {
904
- margin: 0;
905
- touch-action: none;
906
- position: relative;
907
- }
908
- .disabled {
909
- opacity: 0.3;
910
- }
911
- ia-activity-indicator {
912
- position: absolute;
913
- left: calc(50% - 10px);
914
- top: 10px;
915
- width: 20px;
916
- height: 20px;
917
- --activityIndicatorLoadingDotColor: rgba(0, 0, 0, 0);
918
- --activityIndicatorLoadingRingColor: ${activityIndicatorColor};
919
- }
920
-
921
- /* prevent selection from interfering with tooltip, especially on mobile */
922
- /* https://stackoverflow.com/a/4407335/1163042 */
923
- .noselect {
924
- -webkit-touch-callout: none; /* iOS Safari */
925
- -webkit-user-select: none; /* Safari */
926
- -moz-user-select: none; /* Old versions of Firefox */
927
- -ms-user-select: none; /* Internet Explorer/Edge */
928
- user-select: none; /* current Chrome, Edge, Opera and Firefox */
929
- }
930
- .bar {
931
- /* create a transparent border around the hist bars to prevent "gaps" and
932
- flickering when moving around between bars. this also helps with handling
933
- clicks on the bars, preventing users from being able to click in between
934
- bars */
935
- stroke: rgba(0, 0, 0, 0);
936
- /* ensure transparent stroke wide enough to cover gap between bars */
937
- stroke-width: 2px;
938
- }
939
- .bar:hover {
940
- /* highlight currently hovered bar */
941
- fill-opacity: 0.7;
942
- }
943
- .disabled .bar:hover {
944
- /* ensure no visual hover interaction when disabled */
945
- fill-opacity: 1;
946
- }
947
- /****** histogram ********/
948
- #tooltip {
949
- position: absolute;
950
- background: ${tooltipBackgroundColor};
951
- color: ${tooltipTextColor};
952
- text-align: center;
953
- border-radius: 3px;
954
- padding: 2px;
955
- font-size: ${tooltipFontSize};
956
- font-family: ${tooltipFontFamily};
957
- touch-action: none;
958
- pointer-events: none;
959
- }
960
- #tooltip:after {
961
- content: '';
962
- position: absolute;
963
- margin-left: -5px;
964
- top: 100%;
965
- /* arrow */
966
- border: 5px solid ${tooltipTextColor};
967
- border-color: ${tooltipBackgroundColor} transparent transparent
968
- transparent;
969
- }
970
- /****** slider ********/
971
- .draggable:hover {
972
- cursor: grab;
973
- }
974
- .dragging {
975
- cursor: grabbing !important;
976
- }
977
- /****** inputs ********/
978
- #inputs {
979
- display: flex;
980
- justify-content: center;
981
- margin: ${inputRowMargin};
982
- }
983
- #inputs .dash {
984
- position: relative;
985
- bottom: -1px;
986
- align-self: center; /* Otherwise the dash sticks to the top while the inputs grow */
987
- }
988
- input {
989
- width: ${inputWidth};
990
- margin: 0 3px;
991
- border: ${inputBorder};
992
- border-radius: 2px !important;
993
- text-align: center;
994
- font-size: ${inputFontSize};
995
- font-family: ${inputFontFamily};
996
- }
997
- .sr-only {
998
- position: absolute !important;
999
- width: 1px !important;
1000
- height: 1px !important;
1001
- margin: 0 !important;
1002
- padding: 0 !important;
1003
- border: 0 !important;
1004
- overflow: hidden !important;
1005
- white-space: nowrap !important;
1006
- clip: rect(1px, 1px, 1px, 1px) !important;
1007
- -webkit-clip-path: inset(50%) !important;
1008
- clip-path: inset(50%) !important;
1009
- }
1010
- `;
1011
-
1012
- render(): TemplateResult {
1013
- if (!this.hasBinData) {
1014
- return this.noDataTemplate;
1015
- }
1016
- return html`
1017
- <div
1018
- id="container"
1019
- class="
1020
- noselect
1021
- ${this._isDragging ? 'dragging' : ''}
1022
- "
1023
- style="width: ${this.width}px"
1024
- >
1025
- ${this.activityIndicatorTemplate} ${this.tooltipTemplate}
1026
- <div
1027
- class="inner-container
1028
- ${this.disabled ? 'disabled' : ''}"
1029
- >
1030
- <svg
1031
- width="${this.width}"
1032
- height="${this.height}"
1033
- @pointerleave="${this.drop}"
1034
- >
1035
- ${this.selectedRangeTemplate}
1036
- <svg id="histogram">${this.histogramTemplate}</svg>
1037
- ${this.minSliderTemplate} ${this.maxSliderTemplate}
1038
- </svg>
1039
- <div id="inputs">
1040
- ${this.minLabelTemplate} ${this.minInputTemplate}
1041
- <div class="dash">-</div>
1042
- ${this.maxLabelTemplate} ${this.maxInputTemplate}
1043
- <slot name="inputs-right-side"></slot>
1044
- </div>
1045
- </div>
1046
- </div>
1047
- `;
1048
- }
1049
- }
1050
-
1051
- // help TypeScript provide strong typing when interacting with DOM APIs
1052
- // https://stackoverflow.com/questions/65148695/lit-element-typescript-project-global-interface-declaration-necessary
1053
- declare global {
1054
- interface HTMLElementTagNameMap {
1055
- 'histogram-date-range': HistogramDateRange;
1056
- }
1057
- }
1
+ import '@internetarchive/ia-activity-indicator';
2
+ import dayjs from 'dayjs/esm';
3
+ import customParseFormat from 'dayjs/esm/plugin/customParseFormat';
4
+ import {
5
+ css,
6
+ html,
7
+ LitElement,
8
+ nothing,
9
+ PropertyValues,
10
+ svg,
11
+ SVGTemplateResult,
12
+ TemplateResult,
13
+ } from 'lit';
14
+ import { customElement, property, state } from 'lit/decorators.js';
15
+ import { live } from 'lit/directives/live.js';
16
+ import { classMap } from 'lit/directives/class-map.js';
17
+
18
+ dayjs.extend(customParseFormat);
19
+
20
+ // these values can be overridden via the component's HTML (camelCased) attributes
21
+ const WIDTH = 180;
22
+ const HEIGHT = 40;
23
+ const SLIDER_WIDTH = 10;
24
+ const TOOLTIP_WIDTH = 125;
25
+ const TOOLTIP_HEIGHT = 30;
26
+ const DATE_FORMAT = 'YYYY';
27
+ const MISSING_DATA = 'no data';
28
+ const UPDATE_DEBOUNCE_DELAY_MS = 0;
29
+
30
+ // this constant is not set up to be overridden
31
+ const SLIDER_CORNER_SIZE = 4;
32
+
33
+ // these CSS custom props can be overridden from the HTML that is invoking this component
34
+ const sliderColor = css`var(--histogramDateRangeSliderColor, #4B65FE)`;
35
+ const selectedRangeColor = css`var(--histogramDateRangeSelectedRangeColor, #DBE0FF)`;
36
+ const barIncludedFill = css`var(--histogramDateRangeBarIncludedFill, #2C2C2C)`;
37
+ const activityIndicatorColor = css`var(--histogramDateRangeActivityIndicator, #2C2C2C)`;
38
+ const barExcludedFill = css`var(--histogramDateRangeBarExcludedFill, #CCCCCC)`;
39
+ const inputRowMargin = css`var(--histogramDateRangeInputRowMargin, 0)`;
40
+ const inputBorder = css`var(--histogramDateRangeInputBorder, 0.5px solid #2C2C2C)`;
41
+ const inputWidth = css`var(--histogramDateRangeInputWidth, 35px)`;
42
+ const inputFontSize = css`var(--histogramDateRangeInputFontSize, 1.2rem)`;
43
+ const inputFontFamily = css`var(--histogramDateRangeInputFontFamily, sans-serif)`;
44
+ const tooltipBackgroundColor = css`var(--histogramDateRangeTooltipBackgroundColor, #2C2C2C)`;
45
+ const tooltipTextColor = css`var(--histogramDateRangeTooltipTextColor, #FFFFFF)`;
46
+ const tooltipFontSize = css`var(--histogramDateRangeTooltipFontSize, 1.1rem)`;
47
+ const tooltipFontFamily = css`var(--histogramDateRangeTooltipFontFamily, sans-serif)`;
48
+
49
+ type SliderId = 'slider-min' | 'slider-max';
50
+
51
+ export type BinSnappingInterval = 'none' | 'month' | 'year';
52
+
53
+ interface HistogramItem {
54
+ value: number;
55
+ height: number;
56
+ binStart: string;
57
+ binEnd: string;
58
+ tooltip: string;
59
+ }
60
+
61
+ interface BarDataset extends DOMStringMap {
62
+ numItems: string;
63
+ binStart: string;
64
+ binEnd: string;
65
+ }
66
+
67
+ @customElement('histogram-date-range')
68
+ export class HistogramDateRange extends LitElement {
69
+ /* eslint-disable lines-between-class-members */
70
+
71
+ // public reactive properties that can be set via HTML attributes
72
+ @property({ type: Number }) width = WIDTH;
73
+ @property({ type: Number }) height = HEIGHT;
74
+ @property({ type: Number }) sliderWidth = SLIDER_WIDTH;
75
+ @property({ type: Number }) tooltipWidth = TOOLTIP_WIDTH;
76
+ @property({ type: Number }) tooltipHeight = TOOLTIP_HEIGHT;
77
+ @property({ type: Number }) updateDelay = UPDATE_DEBOUNCE_DELAY_MS;
78
+ @property({ type: String }) dateFormat = DATE_FORMAT;
79
+ @property({ type: String }) missingDataMessage = MISSING_DATA;
80
+ @property({ type: String }) minDate = '';
81
+ @property({ type: String }) maxDate = '';
82
+ @property({ type: Boolean }) disabled = false;
83
+ @property({ type: Array }) bins: number[] = [];
84
+ /** If true, update events will not be canceled by the date inputs receiving focus */
85
+ @property({ type: Boolean }) updateWhileFocused = false;
86
+
87
+ /**
88
+ * What interval bins should be snapped to for determining their time ranges.
89
+ * - `none` (default): Bins should each represent an identical duration of time,
90
+ * without regard for the actual dates represented.
91
+ * - `month`: Bins should each represent one or more full, non-overlapping months.
92
+ * The bin ranges will be "snapped" to the nearest month boundaries, which can
93
+ * result in bins that represent different amounts of time, particularly if the
94
+ * provided bins do not evenly divide the provided date range, or if the months
95
+ * represented are of different lengths.
96
+ * - `year`: Same as `month`, but snapping to year boundaries instead of months.
97
+ */
98
+ @property({ type: String }) binSnapping: BinSnappingInterval = 'none';
99
+
100
+ // internal reactive properties not exposed as attributes
101
+ @state() private _tooltipOffset = 0;
102
+ @state() private _tooltipContent?: TemplateResult;
103
+ @state() private _tooltipVisible = false;
104
+ @state() private _tooltipDateFormat?: string;
105
+ @state() private _isDragging = false;
106
+ @state() private _isLoading = false;
107
+
108
+ // non-reactive properties (changes don't auto-trigger re-rendering)
109
+ private _minSelectedDate = '';
110
+ private _maxSelectedDate = '';
111
+ private _minDateMS = 0;
112
+ private _maxDateMS = 0;
113
+ private _dragOffset = 0;
114
+ private _histWidth = 0;
115
+ private _binWidth = 0;
116
+ private _currentSlider?: SVGRectElement;
117
+ private _histData: HistogramItem[] = [];
118
+ private _emitUpdatedEventTimer?: ReturnType<typeof setTimeout>;
119
+ private _previousDateRange = '';
120
+
121
+ /* eslint-enable lines-between-class-members */
122
+
123
+ disconnectedCallback(): void {
124
+ this.removeListeners();
125
+ super.disconnectedCallback();
126
+ }
127
+
128
+ willUpdate(changedProps: PropertyValues): void {
129
+ // check for changes that would affect bin data calculations
130
+ if (
131
+ changedProps.has('bins') ||
132
+ changedProps.has('minDate') ||
133
+ changedProps.has('maxDate') ||
134
+ changedProps.has('minSelectedDate') ||
135
+ changedProps.has('maxSelectedDate') ||
136
+ changedProps.has('width') ||
137
+ changedProps.has('height') ||
138
+ changedProps.has('binSnapping')
139
+ ) {
140
+ this.handleDataUpdate();
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Set private properties that depend on the attribute bin data
146
+ *
147
+ * We're caching these values and not using getters to avoid recalculating all
148
+ * of the hist data every time the user drags a slider or hovers over a bar
149
+ * creating a tooltip.
150
+ */
151
+ private handleDataUpdate(): void {
152
+ if (!this.hasBinData) {
153
+ return;
154
+ }
155
+ this._histWidth = this.width - this.sliderWidth * 2;
156
+
157
+ this._minDateMS = this.snapTimestamp(this.getMSFromString(this.minDate));
158
+ // NB: The max date string, converted as-is to ms, represents the *start* of the
159
+ // final date interval; we want the *end*, so we add any snap interval/offset.
160
+ this._maxDateMS =
161
+ this.snapTimestamp(
162
+ this.getMSFromString(this.maxDate) + this.snapInterval
163
+ ) + this.snapEndOffset;
164
+
165
+ this._binWidth = this._histWidth / this._numBins;
166
+ this._previousDateRange = this.currentDateRangeString;
167
+ this._histData = this.calculateHistData();
168
+ this.minSelectedDate = this.minSelectedDate
169
+ ? this.minSelectedDate
170
+ : this.minDate;
171
+ this.maxSelectedDate = this.maxSelectedDate
172
+ ? this.maxSelectedDate
173
+ : this.maxDate;
174
+ }
175
+
176
+ /**
177
+ * Rounds the given timestamp to the next full second.
178
+ */
179
+ private snapToNextSecond(timestamp: number): number {
180
+ return Math.ceil(timestamp / 1000) * 1000;
181
+ }
182
+
183
+ /**
184
+ * Rounds the given timestamp to the (approximate) nearest start of a month,
185
+ * such that dates up to and including the 15th of the month are rounded down,
186
+ * while dates past the 15th are rounded up.
187
+ */
188
+ private snapToMonth(timestamp: number): number {
189
+ const d = new Date(timestamp);
190
+ const [year, month, day] = [d.getFullYear(), d.getMonth(), d.getDate()];
191
+
192
+ return day < 16 // Obviously only an approximation, but good enough for snapping
193
+ ? new Date(year, month, 1).getTime()
194
+ : new Date(year, month + 1, 1).getTime();
195
+ }
196
+
197
+ /**
198
+ * Rounds the given timestamp to the (approximate) nearest start of a year,
199
+ * such that dates up to the end of June are rounded down, while dates in
200
+ * July or later are rounded up.
201
+ */
202
+ private snapToYear(timestamp: number): number {
203
+ const d = new Date(timestamp);
204
+ const [year, month] = [d.getFullYear(), d.getMonth()];
205
+
206
+ return month < 6 // NB: months are 0-indexed, so 6 = July
207
+ ? new Date(year, 0, 1).getTime()
208
+ : new Date(year + 1, 0, 1).getTime();
209
+ }
210
+
211
+ /**
212
+ * Rounds the given timestamp according to the `binSnapping` property.
213
+ * Default is simply to snap to the nearest full second.
214
+ */
215
+ private snapTimestamp(timestamp: number): number {
216
+ switch (this.binSnapping) {
217
+ case 'year':
218
+ return this.snapToYear(timestamp);
219
+ case 'month':
220
+ return this.snapToMonth(timestamp);
221
+ case 'none':
222
+ default:
223
+ // We still align it to second boundaries to resolve minor discrepancies
224
+ return this.snapToNextSecond(timestamp);
225
+ }
226
+ }
227
+
228
+ private calculateHistData(): HistogramItem[] {
229
+ const { bins, height, dateRangeMS, _numBins, _minDateMS } = this;
230
+ const minValue = Math.min(...this.bins);
231
+ const maxValue = Math.max(...this.bins);
232
+ // if there is no difference between the min and max values, use a range of
233
+ // 1 because log scaling will fail if the range is 0
234
+ const valueRange = minValue === maxValue ? 1 : Math.log1p(maxValue);
235
+ const valueScale = height / valueRange;
236
+ const dateScale = dateRangeMS / _numBins;
237
+
238
+ return bins.map((v: number, i: number) => {
239
+ const binStartMS = this.snapTimestamp(i * dateScale + _minDateMS);
240
+ const binStart = this.formatDate(binStartMS);
241
+
242
+ const binEndMS =
243
+ this.snapTimestamp((i + 1) * dateScale + _minDateMS) +
244
+ this.snapEndOffset;
245
+ const binEnd = this.formatDate(binEndMS);
246
+
247
+ const tooltipStart = this.formatDate(binStartMS, this.tooltipDateFormat);
248
+ const tooltipEnd = this.formatDate(binEndMS, this.tooltipDateFormat);
249
+ // If start/end are the same, just render a single value
250
+ const tooltip =
251
+ tooltipStart === tooltipEnd
252
+ ? tooltipStart
253
+ : `${tooltipStart} - ${tooltipEnd}`;
254
+
255
+ return {
256
+ value: v,
257
+ // use log scaling for the height of the bar to prevent tall bars from
258
+ // making the smaller ones too small to see
259
+ height: Math.floor(Math.log1p(v) * valueScale),
260
+ binStart,
261
+ binEnd,
262
+ tooltip,
263
+ };
264
+ });
265
+ }
266
+
267
+ private get hasBinData(): boolean {
268
+ return this._numBins > 0;
269
+ }
270
+
271
+ private get _numBins(): number {
272
+ if (!this.bins || !this.bins.length) {
273
+ return 0;
274
+ }
275
+ return this.bins.length;
276
+ }
277
+
278
+ private get histogramLeftEdgeX(): number {
279
+ return this.sliderWidth;
280
+ }
281
+
282
+ private get histogramRightEdgeX(): number {
283
+ return this.width - this.sliderWidth;
284
+ }
285
+
286
+ /**
287
+ * Approximate size in ms of the interval to which bins are snapped.
288
+ */
289
+ private get snapInterval(): number {
290
+ const yearMS = 31_536_000_000; // A 365-day approximation of ms in a year
291
+ const monthMS = 2_592_000_000; // A 30-day approximation of ms in a month
292
+ switch (this.binSnapping) {
293
+ case 'year':
294
+ return yearMS;
295
+ case 'month':
296
+ return monthMS;
297
+ case 'none':
298
+ default:
299
+ return 0;
300
+ }
301
+ }
302
+
303
+ /**
304
+ * Offset added to the end of each bin to ensure disjoint intervals,
305
+ * depending on whether snapping is enabled and there are multiple bins.
306
+ */
307
+ private get snapEndOffset(): number {
308
+ return this.binSnapping !== 'none' && this._numBins > 1 ? -1 : 0;
309
+ }
310
+
311
+ /**
312
+ * Optional date format to use for tooltips only.
313
+ * Falls back to `dateFormat` if not provided.
314
+ */
315
+ @property({ type: String }) get tooltipDateFormat(): string {
316
+ return this._tooltipDateFormat ?? this.dateFormat;
317
+ }
318
+
319
+ set tooltipDateFormat(value: string) {
320
+ this._tooltipDateFormat = value;
321
+ }
322
+
323
+ /** component's loading (and disabled) state */
324
+ @property({ type: Boolean }) get loading(): boolean {
325
+ return this._isLoading;
326
+ }
327
+
328
+ set loading(value: boolean) {
329
+ this.disabled = value;
330
+ this._isLoading = value;
331
+ }
332
+
333
+ /** formatted minimum date of selected date range */
334
+ @property() get minSelectedDate(): string {
335
+ return this.formatDate(this.getMSFromString(this._minSelectedDate));
336
+ }
337
+
338
+ /** updates minSelectedDate if new date is valid */
339
+ set minSelectedDate(rawDate: string) {
340
+ if (!this._minSelectedDate) {
341
+ // because the values needed to calculate valid max/min values are not
342
+ // available during the lit init when it's populating properties from
343
+ // attributes, fall back to just the raw date if nothing is already set
344
+ this._minSelectedDate = rawDate;
345
+ return;
346
+ }
347
+ const proposedDateMS = this.getMSFromString(rawDate);
348
+ const isValidDate = !Number.isNaN(proposedDateMS);
349
+ const isNotTooRecent =
350
+ proposedDateMS <= this.getMSFromString(this.maxSelectedDate);
351
+ if (isValidDate && isNotTooRecent) {
352
+ this._minSelectedDate = this.formatDate(proposedDateMS);
353
+ }
354
+ this.requestUpdate();
355
+ }
356
+
357
+ /** formatted maximum date of selected date range */
358
+ @property() get maxSelectedDate(): string {
359
+ return this.formatDate(this.getMSFromString(this._maxSelectedDate));
360
+ }
361
+
362
+ /** updates maxSelectedDate if new date is valid */
363
+ set maxSelectedDate(rawDate: string) {
364
+ if (!this._maxSelectedDate) {
365
+ // because the values needed to calculate valid max/min values are not
366
+ // available during the lit init when it's populating properties from
367
+ // attributes, fall back to just the raw date if nothing is already set
368
+ this._maxSelectedDate = rawDate;
369
+ return;
370
+ }
371
+ const proposedDateMS = this.getMSFromString(rawDate);
372
+ const isValidDate = !Number.isNaN(proposedDateMS);
373
+ const isNotTooOld =
374
+ proposedDateMS >= this.getMSFromString(this.minSelectedDate);
375
+ if (isValidDate && isNotTooOld) {
376
+ this._maxSelectedDate = this.formatDate(proposedDateMS);
377
+ }
378
+ this.requestUpdate();
379
+ }
380
+
381
+ /** horizontal position of min date slider */
382
+ get minSliderX(): number {
383
+ const x = this.translateDateToPosition(this.minSelectedDate);
384
+ return this.validMinSliderX(x);
385
+ }
386
+
387
+ /** horizontal position of max date slider */
388
+ get maxSliderX(): number {
389
+ const maxSelectedDateMS = this.snapTimestamp(
390
+ this.getMSFromString(this.maxSelectedDate) + this.snapInterval
391
+ );
392
+ const x = this.translateDateToPosition(this.formatDate(maxSelectedDateMS));
393
+ return this.validMaxSliderX(x);
394
+ }
395
+
396
+ private get dateRangeMS(): number {
397
+ return this._maxDateMS - this._minDateMS;
398
+ }
399
+
400
+ private showTooltip(e: PointerEvent): void {
401
+ if (this._isDragging || this.disabled) {
402
+ return;
403
+ }
404
+ const target = e.currentTarget as SVGRectElement;
405
+ const x = target.x.baseVal.value + this.sliderWidth / 2;
406
+ const dataset = target.dataset as BarDataset;
407
+ const itemsText = `item${dataset.numItems !== '1' ? 's' : ''}`;
408
+ const formattedNumItems = Number(dataset.numItems).toLocaleString();
409
+
410
+ this._tooltipOffset =
411
+ x + (this._binWidth - this.sliderWidth - this.tooltipWidth) / 2;
412
+
413
+ this._tooltipContent = html`
414
+ ${formattedNumItems} ${itemsText}<br />
415
+ ${dataset.tooltip}
416
+ `;
417
+ this._tooltipVisible = true;
418
+ }
419
+
420
+ private hideTooltip(): void {
421
+ this._tooltipContent = undefined;
422
+ this._tooltipVisible = false;
423
+ }
424
+
425
+ // use arrow functions (rather than standard JS class instance methods) so
426
+ // that `this` is bound to the histogramDateRange object and not the event
427
+ // target. for more info see
428
+ // https://lit-element.polymer-project.org/guide/events#using-this-in-event-listeners
429
+ private drag = (e: PointerEvent): void => {
430
+ // prevent selecting text or other ranges while dragging, especially in Safari
431
+ e.preventDefault();
432
+ if (this.disabled) {
433
+ return;
434
+ }
435
+ this.setDragOffset(e);
436
+ this._isDragging = true;
437
+ this.addListeners();
438
+ this.cancelPendingUpdateEvent();
439
+ };
440
+
441
+ private drop = (): void => {
442
+ if (this._isDragging) {
443
+ this.removeListeners();
444
+ this.beginEmitUpdateProcess();
445
+ }
446
+ this._isDragging = false;
447
+ };
448
+
449
+ /**
450
+ * Adjust the date range based on slider movement
451
+ *
452
+ * @param e PointerEvent from the slider being moved
453
+ */
454
+ private move = (e: PointerEvent): void => {
455
+ const histogramClientX = this.getBoundingClientRect().x;
456
+ const newX = e.clientX - histogramClientX - this._dragOffset;
457
+ const slider = this._currentSlider as SVGRectElement;
458
+ if ((slider.id as SliderId) === 'slider-min') {
459
+ this.minSelectedDate = this.translatePositionToDate(
460
+ this.validMinSliderX(newX)
461
+ );
462
+ } else {
463
+ this.maxSelectedDate = this.translatePositionToDate(
464
+ this.validMaxSliderX(newX)
465
+ );
466
+ if (this.getMSFromString(this.maxSelectedDate) > this._maxDateMS) {
467
+ this.maxSelectedDate = this.maxDate;
468
+ }
469
+ }
470
+ };
471
+
472
+ /**
473
+ * Constrain a proposed value for the minimum (left) slider
474
+ *
475
+ * If the value is less than the leftmost valid position, then set it to the
476
+ * left edge of the histogram (ie the slider width). If the value is greater
477
+ * than the rightmost valid position (the position of the max slider), then
478
+ * set it to the position of the max slider
479
+ */
480
+ private validMinSliderX(newX: number): number {
481
+ // allow the left slider to go right only to the right slider, even if the
482
+ // max selected date is out of range
483
+ const rightLimit = Math.min(
484
+ this.translateDateToPosition(this.maxSelectedDate),
485
+ this.histogramRightEdgeX
486
+ );
487
+ newX = this.clamp(newX, this.histogramLeftEdgeX, rightLimit);
488
+ const isInvalid =
489
+ Number.isNaN(newX) || rightLimit < this.histogramLeftEdgeX;
490
+ return isInvalid ? this.histogramLeftEdgeX : newX;
491
+ }
492
+
493
+ /**
494
+ * Constrain a proposed value for the maximum (right) slider
495
+ *
496
+ * If the value is greater than the rightmost valid position, then set it to
497
+ * the right edge of the histogram (ie histogram width - slider width). If the
498
+ * value is less than the leftmost valid position (the position of the min
499
+ * slider), then set it to the position of the min slider
500
+ */
501
+ private validMaxSliderX(newX: number): number {
502
+ // allow the right slider to go left only to the left slider, even if the
503
+ // min selected date is out of range
504
+ const leftLimit = Math.max(
505
+ this.histogramLeftEdgeX,
506
+ this.translateDateToPosition(this.minSelectedDate)
507
+ );
508
+ newX = this.clamp(newX, leftLimit, this.histogramRightEdgeX);
509
+ const isInvalid =
510
+ Number.isNaN(newX) || leftLimit > this.histogramRightEdgeX;
511
+ return isInvalid ? this.histogramRightEdgeX : newX;
512
+ }
513
+
514
+ private addListeners(): void {
515
+ window.addEventListener('pointermove', this.move);
516
+ window.addEventListener('pointerup', this.drop);
517
+ window.addEventListener('pointercancel', this.drop);
518
+ }
519
+
520
+ private removeListeners(): void {
521
+ window.removeEventListener('pointermove', this.move);
522
+ window.removeEventListener('pointerup', this.drop);
523
+ window.removeEventListener('pointercancel', this.drop);
524
+ }
525
+
526
+ /**
527
+ * start a timer to emit an update event. this timer can be canceled (and the
528
+ * event not emitted) if user drags a slider or focuses a date input within
529
+ * the update delay
530
+ */
531
+ private beginEmitUpdateProcess(): void {
532
+ this.cancelPendingUpdateEvent();
533
+ this._emitUpdatedEventTimer = setTimeout(() => {
534
+ if (this.currentDateRangeString === this._previousDateRange) {
535
+ // don't emit duplicate event if no change since last emitted event
536
+ return;
537
+ }
538
+ this._previousDateRange = this.currentDateRangeString;
539
+ const options = {
540
+ detail: {
541
+ minDate: this.minSelectedDate,
542
+ maxDate: this.maxSelectedDate,
543
+ },
544
+ bubbles: true,
545
+ composed: true,
546
+ };
547
+ this.dispatchEvent(new CustomEvent('histogramDateRangeUpdated', options));
548
+ }, this.updateDelay);
549
+ }
550
+
551
+ private cancelPendingUpdateEvent(): void {
552
+ if (this._emitUpdatedEventTimer === undefined) {
553
+ return;
554
+ }
555
+ clearTimeout(this._emitUpdatedEventTimer);
556
+ this._emitUpdatedEventTimer = undefined;
557
+ }
558
+
559
+ /**
560
+ * find position of pointer in relation to the current slider
561
+ */
562
+ private setDragOffset(e: PointerEvent): void {
563
+ this._currentSlider = e.currentTarget as SVGRectElement;
564
+ const sliderX =
565
+ (this._currentSlider.id as SliderId) === 'slider-min'
566
+ ? this.minSliderX
567
+ : this.maxSliderX;
568
+ const histogramClientX = this.getBoundingClientRect().x;
569
+ this._dragOffset = e.clientX - histogramClientX - sliderX;
570
+ }
571
+
572
+ /**
573
+ * @param x horizontal position of slider
574
+ * @returns string representation of date
575
+ */
576
+ private translatePositionToDate(x: number): string {
577
+ // Snap to the nearest second, fixing the case where input like 1/1/2010
578
+ // would get translated to 12/31/2009 due to slight discrepancies from
579
+ // pixel boundaries and floating point error.
580
+ const milliseconds = this.snapToNextSecond(
581
+ ((x - this.sliderWidth) * this.dateRangeMS) / this._histWidth
582
+ );
583
+ return this.formatDate(this._minDateMS + milliseconds);
584
+ }
585
+
586
+ /**
587
+ * Returns slider x-position corresponding to given date
588
+ *
589
+ * @param date
590
+ * @returns x-position of slider
591
+ */
592
+ private translateDateToPosition(date: string): number {
593
+ const milliseconds = this.getMSFromString(date);
594
+ return (
595
+ this.sliderWidth +
596
+ ((milliseconds - this._minDateMS) * this._histWidth) / this.dateRangeMS
597
+ );
598
+ }
599
+
600
+ /** ensure that the returned value is between minValue and maxValue */
601
+ private clamp(x: number, minValue: number, maxValue: number): number {
602
+ return Math.min(Math.max(x, minValue), maxValue);
603
+ }
604
+
605
+ private handleInputFocus(): void {
606
+ if (!this.updateWhileFocused) {
607
+ this.cancelPendingUpdateEvent();
608
+ }
609
+ }
610
+
611
+ private handleMinDateInput(e: Event): void {
612
+ const target = e.currentTarget as HTMLInputElement;
613
+ if (target.value !== this.minSelectedDate) {
614
+ this.minSelectedDate = target.value;
615
+ this.beginEmitUpdateProcess();
616
+ }
617
+ }
618
+
619
+ private handleMaxDateInput(e: Event): void {
620
+ const target = e.currentTarget as HTMLInputElement;
621
+ if (target.value !== this.maxSelectedDate) {
622
+ this.maxSelectedDate = target.value;
623
+ this.beginEmitUpdateProcess();
624
+ }
625
+ }
626
+
627
+ private handleKeyUp(e: KeyboardEvent): void {
628
+ if (e.key === 'Enter') {
629
+ const target = e.currentTarget as HTMLInputElement;
630
+ target.blur();
631
+ if (target.id === 'date-min') {
632
+ this.handleMinDateInput(e);
633
+ } else if (target.id === 'date-max') {
634
+ this.handleMaxDateInput(e);
635
+ }
636
+ }
637
+ }
638
+
639
+ private get currentDateRangeString(): string {
640
+ return `${this.minSelectedDate}:${this.maxSelectedDate}`;
641
+ }
642
+
643
+ private getMSFromString(date: unknown): number {
644
+ // It's possible that `date` is not a string in certain situations.
645
+ // For instance if you use LitElement bindings and the date is `2000`,
646
+ // it will be treated as a number instead of a string. This just makes sure
647
+ // we're dealing with a string.
648
+ const stringified = typeof date === 'string' ? date : String(date);
649
+ const digitGroupCount = (stringified.split(/(\d+)/).length - 1) / 2;
650
+ if (digitGroupCount === 1) {
651
+ // if there's just a single set of digits, assume it's a year
652
+ const dateObj = new Date(0, 0); // start at January 1, 1900
653
+ dateObj.setFullYear(Number(stringified)); // override year
654
+ return dateObj.getTime(); // get time in milliseconds
655
+ }
656
+ return dayjs(stringified, [this.dateFormat, DATE_FORMAT]).valueOf();
657
+ }
658
+
659
+ /**
660
+ * expand or narrow the selected range by moving the slider nearest the
661
+ * clicked bar to the outer edge of the clicked bar
662
+ *
663
+ * @param e Event click event from a histogram bar
664
+ */
665
+ private handleBarClick(e: Event): void {
666
+ const dataset = (e.currentTarget as SVGRectElement).dataset as BarDataset;
667
+ // use the midpoint of the width of the clicked bar to determine which is
668
+ // the nearest slider
669
+ const clickPosition =
670
+ (this.getMSFromString(dataset.binStart) +
671
+ this.getMSFromString(dataset.binEnd)) /
672
+ 2;
673
+ const distanceFromMinSlider = Math.abs(
674
+ clickPosition - this.getMSFromString(this.minSelectedDate)
675
+ );
676
+ const distanceFromMaxSlider = Math.abs(
677
+ clickPosition - this.getMSFromString(this.maxSelectedDate)
678
+ );
679
+ // update the selected range by moving the nearer slider
680
+ if (distanceFromMinSlider < distanceFromMaxSlider) {
681
+ this.minSelectedDate = dataset.binStart;
682
+ } else {
683
+ this.maxSelectedDate = dataset.binEnd;
684
+ }
685
+ this.beginEmitUpdateProcess();
686
+ }
687
+
688
+ private get minSliderTemplate(): SVGTemplateResult {
689
+ // width/height in pixels of curved part of the sliders (like
690
+ // border-radius); used as part of a SVG quadratic curve. see
691
+ // https://developer.mozilla.org/en-US/docs/Web/SVG/Tutorial/Paths#curve_commands
692
+ const cs = SLIDER_CORNER_SIZE;
693
+
694
+ const sliderShape = `
695
+ M${this.minSliderX},0
696
+ h-${this.sliderWidth - cs}
697
+ q-${cs},0 -${cs},${cs}
698
+ v${this.height - cs * 2}
699
+ q0,${cs} ${cs},${cs}
700
+ h${this.sliderWidth - cs}
701
+ `;
702
+ return this.generateSliderSVG(this.minSliderX, 'slider-min', sliderShape);
703
+ }
704
+
705
+ private get maxSliderTemplate(): SVGTemplateResult {
706
+ const cs = SLIDER_CORNER_SIZE;
707
+ const sliderShape = `
708
+ M${this.maxSliderX},0
709
+ h${this.sliderWidth - cs}
710
+ q${cs},0 ${cs},${cs}
711
+ v${this.height - cs * 2}
712
+ q0,${cs} -${cs},${cs}
713
+ h-${this.sliderWidth - cs}
714
+ `;
715
+ return this.generateSliderSVG(this.maxSliderX, 'slider-max', sliderShape);
716
+ }
717
+
718
+ private generateSliderSVG(
719
+ sliderPositionX: number,
720
+ id: SliderId,
721
+ sliderShape: string
722
+ ): SVGTemplateResult {
723
+ // whether the curved part of the slider is facing towards the left (1), ie
724
+ // minimum, or facing towards the right (-1), ie maximum
725
+ const k = id === 'slider-min' ? 1 : -1;
726
+
727
+ const sliderClasses = classMap({
728
+ slider: true,
729
+ draggable: !this.disabled,
730
+ dragging: this._isDragging,
731
+ });
732
+
733
+ return svg`
734
+ <svg
735
+ id=${id}
736
+ class=${sliderClasses}
737
+ @pointerdown=${this.drag}
738
+ >
739
+ <path d="${sliderShape} z" fill="${sliderColor}" />
740
+ <rect
741
+ x="${
742
+ sliderPositionX - this.sliderWidth * k + this.sliderWidth * 0.4 * k
743
+ }"
744
+ y="${this.height / 3}"
745
+ width="1"
746
+ height="${this.height / 3}"
747
+ fill="white"
748
+ />
749
+ <rect
750
+ x="${
751
+ sliderPositionX - this.sliderWidth * k + this.sliderWidth * 0.6 * k
752
+ }"
753
+ y="${this.height / 3}"
754
+ width="1"
755
+ height="${this.height / 3}"
756
+ fill="white"
757
+ />
758
+ </svg>
759
+ `;
760
+ }
761
+
762
+ get selectedRangeTemplate(): SVGTemplateResult {
763
+ return svg`
764
+ <rect
765
+ x="${this.minSliderX}"
766
+ y="0"
767
+ width="${this.maxSliderX - this.minSliderX}"
768
+ height="${this.height}"
769
+ fill="${selectedRangeColor}"
770
+ />`;
771
+ }
772
+
773
+ get histogramTemplate(): SVGTemplateResult[] {
774
+ const xScale = this._histWidth / this._numBins;
775
+ const barWidth = xScale - 1;
776
+ let x = this.sliderWidth; // start at the left edge of the histogram
777
+
778
+ return this._histData.map(data => {
779
+ const { minSelectedDate, maxSelectedDate } = this;
780
+ const barHeight = data.height;
781
+
782
+ const binIsBeforeMin = this.isBefore(data.binEnd, minSelectedDate);
783
+ const binIsAfterMax = this.isAfter(data.binStart, maxSelectedDate);
784
+ const barFill =
785
+ binIsBeforeMin || binIsAfterMax ? barExcludedFill : barIncludedFill;
786
+
787
+ // the stroke-dasharray style below creates a transparent border around the
788
+ // right edge of the bar, which prevents user from encountering a gap
789
+ // between adjacent bars (eg when viewing the tooltips or when trying to
790
+ // extend the range by clicking on a bar)
791
+ const barStyle = `stroke-dasharray: 0 ${barWidth} ${barHeight} ${barWidth} 0 ${barHeight}`;
792
+
793
+ const bar = svg`
794
+ <rect
795
+ class="bar"
796
+ style=${barStyle}
797
+ x=${x}
798
+ y=${this.height - barHeight}
799
+ width=${barWidth}
800
+ height=${barHeight}
801
+ @pointerenter=${this.showTooltip}
802
+ @pointerleave=${this.hideTooltip}
803
+ @click=${this.handleBarClick}
804
+ fill=${barFill}
805
+ data-num-items=${data.value}
806
+ data-bin-start=${data.binStart}
807
+ data-bin-end=${data.binEnd}
808
+ data-tooltip=${data.tooltip}
809
+ />`;
810
+ x += xScale;
811
+ return bar;
812
+ });
813
+ }
814
+
815
+ /** Whether the first arg represents a date strictly before the second arg */
816
+ private isBefore(date1: string, date2: string): boolean {
817
+ const date1MS = this.getMSFromString(date1);
818
+ const date2MS = this.getMSFromString(date2);
819
+ return date1MS < date2MS;
820
+ }
821
+
822
+ /** Whether the first arg represents a date strictly after the second arg */
823
+ private isAfter(date1: string, date2: string): boolean {
824
+ const date1MS = this.getMSFromString(date1);
825
+ const date2MS = this.getMSFromString(date2);
826
+ return date1MS > date2MS;
827
+ }
828
+
829
+ private formatDate(dateMS: number, format: string = this.dateFormat): string {
830
+ if (Number.isNaN(dateMS)) {
831
+ return '';
832
+ }
833
+ const date = dayjs(dateMS);
834
+ if (date.year() < 1000) {
835
+ // years before 1000 don't play well with dayjs custom formatting, so fall
836
+ // back to displaying only the year
837
+ return String(date.year());
838
+ }
839
+ return date.format(format);
840
+ }
841
+
842
+ /**
843
+ * NOTE: we are relying on the lit `live` directive in the template to
844
+ * ensure that the change to minSelectedDate is noticed and the input value
845
+ * gets properly re-rendered. see
846
+ * https://lit.dev/docs/templates/directives/#live
847
+ */
848
+ get minInputTemplate(): TemplateResult {
849
+ return html`
850
+ <input
851
+ id="date-min"
852
+ placeholder=${this.dateFormat}
853
+ type="text"
854
+ @focus=${this.handleInputFocus}
855
+ @blur=${this.handleMinDateInput}
856
+ @keyup=${this.handleKeyUp}
857
+ .value=${live(this.minSelectedDate)}
858
+ ?disabled=${this.disabled}
859
+ />
860
+ `;
861
+ }
862
+
863
+ get maxInputTemplate(): TemplateResult {
864
+ return html`
865
+ <input
866
+ id="date-max"
867
+ placeholder=${this.dateFormat}
868
+ type="text"
869
+ @focus=${this.handleInputFocus}
870
+ @blur=${this.handleMaxDateInput}
871
+ @keyup=${this.handleKeyUp}
872
+ .value=${live(this.maxSelectedDate)}
873
+ ?disabled=${this.disabled}
874
+ />
875
+ `;
876
+ }
877
+
878
+ get minLabelTemplate(): TemplateResult {
879
+ return html`<label for="date-min" class="sr-only">Minimum date:</label>`;
880
+ }
881
+
882
+ get maxLabelTemplate(): TemplateResult {
883
+ return html`<label for="date-max" class="sr-only">Maximum date:</label>`;
884
+ }
885
+
886
+ get tooltipTemplate(): TemplateResult {
887
+ return html`
888
+ <style>
889
+ #tooltip {
890
+ width: ${this.tooltipWidth}px;
891
+ height: ${this.tooltipHeight}px;
892
+ top: ${-9 - this.tooltipHeight}px;
893
+ left: ${this._tooltipOffset}px;
894
+ display: ${this._tooltipVisible ? 'block' : 'none'};
895
+ }
896
+ #tooltip:after {
897
+ left: ${this.tooltipWidth / 2}px;
898
+ }
899
+ </style>
900
+ <div id="tooltip">${this._tooltipContent}</div>
901
+ `;
902
+ }
903
+
904
+ private get noDataTemplate(): TemplateResult {
905
+ return html`
906
+ <div class="missing-data-message">${this.missingDataMessage}</div>
907
+ `;
908
+ }
909
+
910
+ private get activityIndicatorTemplate(): TemplateResult | typeof nothing {
911
+ if (!this.loading) {
912
+ return nothing;
913
+ }
914
+ return html`
915
+ <ia-activity-indicator mode="processing"> </ia-activity-indicator>
916
+ `;
917
+ }
918
+
919
+ static styles = css`
920
+ .missing-data-message {
921
+ text-align: center;
922
+ }
923
+ #container {
924
+ margin: 0;
925
+ touch-action: none;
926
+ position: relative;
927
+ }
928
+ .disabled {
929
+ opacity: 0.3;
930
+ }
931
+ ia-activity-indicator {
932
+ position: absolute;
933
+ left: calc(50% - 10px);
934
+ top: 10px;
935
+ width: 20px;
936
+ height: 20px;
937
+ --activityIndicatorLoadingDotColor: rgba(0, 0, 0, 0);
938
+ --activityIndicatorLoadingRingColor: ${activityIndicatorColor};
939
+ }
940
+
941
+ /* prevent selection from interfering with tooltip, especially on mobile */
942
+ /* https://stackoverflow.com/a/4407335/1163042 */
943
+ .noselect {
944
+ -webkit-touch-callout: none; /* iOS Safari */
945
+ -webkit-user-select: none; /* Safari */
946
+ -moz-user-select: none; /* Old versions of Firefox */
947
+ -ms-user-select: none; /* Internet Explorer/Edge */
948
+ user-select: none; /* current Chrome, Edge, Opera and Firefox */
949
+ }
950
+ .bar {
951
+ /* create a transparent border around the hist bars to prevent "gaps" and
952
+ flickering when moving around between bars. this also helps with handling
953
+ clicks on the bars, preventing users from being able to click in between
954
+ bars */
955
+ stroke: rgba(0, 0, 0, 0);
956
+ /* ensure transparent stroke wide enough to cover gap between bars */
957
+ stroke-width: 2px;
958
+ }
959
+ .bar:hover {
960
+ /* highlight currently hovered bar */
961
+ fill-opacity: 0.7;
962
+ }
963
+ .disabled .bar:hover {
964
+ /* ensure no visual hover interaction when disabled */
965
+ fill-opacity: 1;
966
+ }
967
+ /****** histogram ********/
968
+ #tooltip {
969
+ position: absolute;
970
+ background: ${tooltipBackgroundColor};
971
+ color: ${tooltipTextColor};
972
+ text-align: center;
973
+ border-radius: 3px;
974
+ padding: 2px;
975
+ font-size: ${tooltipFontSize};
976
+ font-family: ${tooltipFontFamily};
977
+ touch-action: none;
978
+ pointer-events: none;
979
+ }
980
+ #tooltip:after {
981
+ content: '';
982
+ position: absolute;
983
+ margin-left: -5px;
984
+ top: 100%;
985
+ /* arrow */
986
+ border: 5px solid ${tooltipTextColor};
987
+ border-color: ${tooltipBackgroundColor} transparent transparent
988
+ transparent;
989
+ }
990
+ /****** slider ********/
991
+ .slider {
992
+ shape-rendering: crispEdges; /* So the slider doesn't get blurry if dragged between pixels */
993
+ }
994
+ .draggable:hover {
995
+ cursor: grab;
996
+ }
997
+ .dragging {
998
+ cursor: grabbing !important;
999
+ }
1000
+ /****** inputs ********/
1001
+ #inputs {
1002
+ display: flex;
1003
+ justify-content: center;
1004
+ margin: ${inputRowMargin};
1005
+ }
1006
+ #inputs .dash {
1007
+ position: relative;
1008
+ bottom: -1px;
1009
+ align-self: center; /* Otherwise the dash sticks to the top while the inputs grow */
1010
+ }
1011
+ input {
1012
+ width: ${inputWidth};
1013
+ margin: 0 3px;
1014
+ border: ${inputBorder};
1015
+ border-radius: 2px !important;
1016
+ text-align: center;
1017
+ font-size: ${inputFontSize};
1018
+ font-family: ${inputFontFamily};
1019
+ }
1020
+ .sr-only {
1021
+ position: absolute !important;
1022
+ width: 1px !important;
1023
+ height: 1px !important;
1024
+ margin: 0 !important;
1025
+ padding: 0 !important;
1026
+ border: 0 !important;
1027
+ overflow: hidden !important;
1028
+ white-space: nowrap !important;
1029
+ clip: rect(1px, 1px, 1px, 1px) !important;
1030
+ -webkit-clip-path: inset(50%) !important;
1031
+ clip-path: inset(50%) !important;
1032
+ }
1033
+ `;
1034
+
1035
+ render(): TemplateResult {
1036
+ if (!this.hasBinData) {
1037
+ return this.noDataTemplate;
1038
+ }
1039
+ return html`
1040
+ <div
1041
+ id="container"
1042
+ class="
1043
+ noselect
1044
+ ${this._isDragging ? 'dragging' : ''}
1045
+ "
1046
+ style="width: ${this.width}px"
1047
+ >
1048
+ ${this.activityIndicatorTemplate} ${this.tooltipTemplate}
1049
+ <div
1050
+ class="inner-container
1051
+ ${this.disabled ? 'disabled' : ''}"
1052
+ >
1053
+ <svg
1054
+ width="${this.width}"
1055
+ height="${this.height}"
1056
+ @pointerleave="${this.drop}"
1057
+ >
1058
+ ${this.selectedRangeTemplate}
1059
+ <svg id="histogram">${this.histogramTemplate}</svg>
1060
+ ${this.minSliderTemplate} ${this.maxSliderTemplate}
1061
+ </svg>
1062
+ <div id="inputs">
1063
+ ${this.minLabelTemplate} ${this.minInputTemplate}
1064
+ <div class="dash">-</div>
1065
+ ${this.maxLabelTemplate} ${this.maxInputTemplate}
1066
+ <slot name="inputs-right-side"></slot>
1067
+ </div>
1068
+ </div>
1069
+ </div>
1070
+ `;
1071
+ }
1072
+ }
1073
+
1074
+ // help TypeScript provide strong typing when interacting with DOM APIs
1075
+ // https://stackoverflow.com/questions/65148695/lit-element-typescript-project-global-interface-declaration-necessary
1076
+ declare global {
1077
+ interface HTMLElementTagNameMap {
1078
+ 'histogram-date-range': HistogramDateRange;
1079
+ }
1080
+ }