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