@internetarchive/histogram-date-range 1.3.1 → 1.3.2-alpha-webdev7713.0

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