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