@internetarchive/histogram-date-range 1.2.0 → 1.2.1

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