@internetarchive/histogram-date-range 1.4.0-alpha-webdev7756.0 → 1.4.0

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