@internetarchive/histogram-date-range 0.1.10-alpha.2 → 0.1.10

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