@internetarchive/histogram-date-range 1.2.2-alpha-webdev7377.1 → 1.2.2-alpha-webdev7377.2

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