@internetarchive/elements 1.0.0 → 1.0.1-webdev-9121.1

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