@internetarchive/histogram-date-range 0.0.10-beta → 0.1.1-alpha

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,6 +1,7 @@
1
1
  import {
2
2
  css,
3
3
  html,
4
+ nothing,
4
5
  LitElement,
5
6
  PropertyValues,
6
7
  svg,
@@ -10,6 +11,8 @@ import {
10
11
  import { property, state, customElement } from 'lit/decorators.js';
11
12
  import { live } from 'lit/directives/live.js';
12
13
  import dayjs from 'dayjs/esm/index.js';
14
+ import customParseFormat from 'dayjs/esm/plugin/customParseFormat';
15
+ dayjs.extend(customParseFormat);
13
16
  import '@internetarchive/ia-activity-indicator/ia-activity-indicator';
14
17
 
15
18
  // these values can be overridden via the component's HTML (camelCased) attributes
@@ -20,23 +23,25 @@ const TOOLTIP_WIDTH = 125;
20
23
  const TOOLTIP_HEIGHT = 30;
21
24
  const DATE_FORMAT = 'YYYY';
22
25
  const MISSING_DATA = 'no data';
23
- const UPDATE_DEBOUNCE_DELAY_MS = 1000;
26
+ const UPDATE_DEBOUNCE_DELAY_MS = 0;
24
27
 
25
28
  // this constant is not set up to be overridden
26
29
  const SLIDER_CORNER_SIZE = 4;
27
30
 
28
31
  // these CSS custom props can be overridden from the HTML that is invoking this component
29
- const sliderFill = css`var(--histogramDateRangeSliderFill, #4B65FE)`;
30
- const selectedRangeFill = css`var(--histogramDateRangeSelectedRangeFill, #DBE0FF)`;
32
+ const sliderColor = css`var(--histogramDateRangeSliderColor, #4B65FE)`;
33
+ const selectedRangeColor = css`var(--histogramDateRangeSelectedRangeColor, #DBE0FF)`;
31
34
  const barIncludedFill = css`var(--histogramDateRangeBarIncludedFill, #2C2C2C)`;
32
35
  const activityIndicatorColor = css`var(--histogramDateRangeActivityIndicator, #2C2C2C)`;
33
36
  const barExcludedFill = css`var(--histogramDateRangeBarExcludedFill, #CCCCCC)`;
34
37
  const inputBorder = css`var(--histogramDateRangeInputBorder, 0.5px solid #2C2C2C)`;
35
38
  const inputWidth = css`var(--histogramDateRangeInputWidth, 35px)`;
36
39
  const inputFontSize = css`var(--histogramDateRangeInputFontSize, 1.2rem)`;
40
+ const inputFontFamily = css`var(--histogramDateRangeInputFontFamily, sans-serif)`;
37
41
  const tooltipBackgroundColor = css`var(--histogramDateRangeTooltipBackgroundColor, #2C2C2C)`;
38
42
  const tooltipTextColor = css`var(--histogramDateRangeTooltipTextColor, #FFFFFF)`;
39
43
  const tooltipFontSize = css`var(--histogramDateRangeTooltipFontSize, 1.1rem)`;
44
+ const tooltipFontFamily = css`var(--histogramDateRangeTooltipFontFamily, sans-serif)`;
40
45
 
41
46
  type SliderId = 'slider-min' | 'slider-max';
42
47
 
@@ -47,6 +52,12 @@ interface HistogramItem {
47
52
  binEnd: string;
48
53
  }
49
54
 
55
+ interface BarDataset extends DOMStringMap {
56
+ numItems: string;
57
+ binStart: string;
58
+ binEnd: string;
59
+ }
60
+
50
61
  @customElement('histogram-date-range')
51
62
  export class HistogramDateRange extends LitElement {
52
63
  /* eslint-disable lines-between-class-members */
@@ -93,7 +104,14 @@ export class HistogramDateRange extends LitElement {
93
104
  }
94
105
 
95
106
  updated(changedProps: PropertyValues): void {
96
- if (changedProps.has('bins') || changedProps.has('Date')) {
107
+ // check for changes that would affect bin data calculations
108
+ if (
109
+ changedProps.has('bins') ||
110
+ changedProps.has('minDate') ||
111
+ changedProps.has('maxDate') ||
112
+ changedProps.has('minSelectedDate') ||
113
+ changedProps.has('maxSelectedDate')
114
+ ) {
97
115
  this.handleDataUpdate();
98
116
  }
99
117
  }
@@ -110,8 +128,8 @@ export class HistogramDateRange extends LitElement {
110
128
  return;
111
129
  }
112
130
  this._histWidth = this.width - this.sliderWidth * 2;
113
- this._minDateMS = dayjs(this.minDate).valueOf();
114
- this._maxDateMS = dayjs(this.maxDate).valueOf();
131
+ this._minDateMS = this.getMSFromString(this.minDate);
132
+ this._maxDateMS = this.getMSFromString(this.maxDate);
115
133
  this._binWidth = this._histWidth / this._numBins;
116
134
  this._previousDateRange = this.currentDateRangeString;
117
135
  this._histData = this.calculateHistData();
@@ -127,11 +145,17 @@ export class HistogramDateRange extends LitElement {
127
145
  private calculateHistData(): HistogramItem[] {
128
146
  const minValue = Math.min(...this.bins);
129
147
  const maxValue = Math.max(...this.bins);
130
- const valueScale = this.height / Math.log1p(maxValue - minValue);
148
+ // if there is no difference between the min and max values, use a range of
149
+ // 1 because log scaling will fail if the range is 0
150
+ const valueRange =
151
+ minValue === maxValue ? 1 : Math.log1p(maxValue - minValue);
152
+ const valueScale = this.height / valueRange;
131
153
  const dateScale = this.dateRangeMS / this._numBins;
132
154
  return this.bins.map((v: number, i: number) => {
133
155
  return {
134
156
  value: v,
157
+ // use log scaling for the height of the bar to prevent tall bars from
158
+ // making the smaller ones too small to see
135
159
  height: Math.floor(Math.log1p(v) * valueScale),
136
160
  binStart: `${this.formatDate(i * dateScale + this._minDateMS)}`,
137
161
  binEnd: `${this.formatDate((i + 1) * dateScale + this._minDateMS)}`,
@@ -162,56 +186,60 @@ export class HistogramDateRange extends LitElement {
162
186
 
163
187
  /** formatted minimum date of selected date range */
164
188
  @property() get minSelectedDate(): string {
165
- return this.formatDate(this._minSelectedDate);
189
+ return this.formatDate(this.getMSFromString(this._minSelectedDate));
166
190
  }
167
191
 
192
+ /** updates minSelectedDate if new date is valid */
168
193
  set minSelectedDate(rawDate: string) {
169
194
  if (!this._minSelectedDate) {
170
195
  // because the values needed to calculate valid max/min values are not
171
196
  // available during the lit init when it's populating properties from
172
197
  // attributes, fall back to just the raw date if nothing is already set
173
198
  this._minSelectedDate = rawDate;
199
+ return;
174
200
  }
175
- const x = this.translateDateToPosition(rawDate);
176
- if (x) {
177
- const validX = this.validMinSliderX(x);
178
- this._minSelectedDate = this.translatePositionToDate(validX);
201
+ let ms = this.getMSFromString(rawDate);
202
+ if (!Number.isNaN(ms)) {
203
+ ms = Math.min(ms, this.getMSFromString(this._maxSelectedDate));
204
+ this._minSelectedDate = this.formatDate(ms);
179
205
  }
180
206
  this.requestUpdate();
181
207
  }
182
208
 
183
209
  /** formatted maximum date of selected date range */
184
210
  @property() get maxSelectedDate(): string {
185
- return this.formatDate(this._maxSelectedDate);
211
+ return this.formatDate(this.getMSFromString(this._maxSelectedDate));
186
212
  }
187
213
 
214
+ /** updates maxSelectedDate if new date is valid */
188
215
  set maxSelectedDate(rawDate: string) {
189
216
  if (!this._maxSelectedDate) {
190
- // see comment above in the minSelectedDate setter
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
191
220
  this._maxSelectedDate = rawDate;
221
+ return;
192
222
  }
193
- const x = this.translateDateToPosition(rawDate);
194
- if (x) {
195
- const validX = this.validMaxSliderX(x);
196
- this._maxSelectedDate = this.translatePositionToDate(validX);
223
+ let ms = this.getMSFromString(rawDate);
224
+ if (!Number.isNaN(ms)) {
225
+ ms = Math.max(this.getMSFromString(this._minSelectedDate), ms);
226
+ this._maxSelectedDate = this.formatDate(ms);
197
227
  }
198
228
  this.requestUpdate();
199
229
  }
230
+
200
231
  /** horizontal position of min date slider */
201
232
  get minSliderX(): number {
202
- return (
203
- // default to leftmost position if missing or invalid min position
204
- this.translateDateToPosition(this.minSelectedDate) ?? this.sliderWidth
205
- );
233
+ // default to leftmost position if missing or invalid min position
234
+ const x = this.translateDateToPosition(this.minSelectedDate);
235
+ return this.validMinSliderX(x);
206
236
  }
207
237
 
208
238
  /** horizontal position of max date slider */
209
239
  get maxSliderX(): number {
210
- return (
211
- // default to rightmost position if missing or invalid max position
212
- this.translateDateToPosition(this.maxSelectedDate) ??
213
- this.width - this.sliderWidth
214
- );
240
+ // default to rightmost position if missing or invalid max position
241
+ const x = this.translateDateToPosition(this.maxSelectedDate);
242
+ return this.validMaxSliderX(x);
215
243
  }
216
244
 
217
245
  private get dateRangeMS(): number {
@@ -224,14 +252,15 @@ export class HistogramDateRange extends LitElement {
224
252
  }
225
253
  const target = e.currentTarget as SVGRectElement;
226
254
  const x = target.x.baseVal.value + this.sliderWidth / 2;
227
- const dataset = target.dataset;
255
+ const dataset = target.dataset as BarDataset;
228
256
  const itemsText = `item${dataset.numItems !== '1' ? 's' : ''}`;
257
+ const formattedNumItems = Number(dataset.numItems).toLocaleString();
229
258
 
230
259
  this._tooltipOffset =
231
260
  x + (this._binWidth - this.sliderWidth - this.tooltipWidth) / 2;
232
261
 
233
262
  this._tooltipContent = html`
234
- ${dataset.numItems} ${itemsText}<br />
263
+ ${formattedNumItems} ${itemsText}<br />
235
264
  ${dataset.binStart} - ${dataset.binEnd}
236
265
  `;
237
266
  this._tooltipVisible = true;
@@ -274,11 +303,14 @@ export class HistogramDateRange extends LitElement {
274
303
  private move = (e: PointerEvent): void => {
275
304
  const newX = e.offsetX - this._dragOffset;
276
305
  const slider = this._currentSlider as SVGRectElement;
277
- const date = this.translatePositionToDate(newX);
278
306
  if ((slider.id as SliderId) === 'slider-min') {
279
- this.minSelectedDate = date;
307
+ this.minSelectedDate = this.translatePositionToDate(
308
+ this.validMinSliderX(newX)
309
+ );
280
310
  } else {
281
- this.maxSelectedDate = date;
311
+ this.maxSelectedDate = this.translatePositionToDate(
312
+ this.validMaxSliderX(newX)
313
+ );
282
314
  }
283
315
  };
284
316
 
@@ -291,8 +323,14 @@ export class HistogramDateRange extends LitElement {
291
323
  * to the position of the max slider
292
324
  */
293
325
  private validMinSliderX(newX: number): number {
294
- const validX = Math.max(newX, this.sliderWidth);
295
- return Math.min(validX, this.maxSliderX);
326
+ if (Number.isNaN(newX)) {
327
+ return this.sliderWidth;
328
+ }
329
+ return this.clamp(
330
+ newX,
331
+ this.sliderWidth,
332
+ this.translateDateToPosition(this.maxSelectedDate)
333
+ );
296
334
  }
297
335
 
298
336
  /**
@@ -304,8 +342,14 @@ export class HistogramDateRange extends LitElement {
304
342
  * then set it to the position of the min slider
305
343
  */
306
344
  private validMaxSliderX(newX: number): number {
307
- const validX = Math.max(newX, this.minSliderX);
308
- return Math.min(validX, this.width - this.sliderWidth);
345
+ if (Number.isNaN(newX)) {
346
+ return this.width - this.sliderWidth;
347
+ }
348
+ return this.clamp(
349
+ newX,
350
+ this.translateDateToPosition(this.minSelectedDate),
351
+ this.width - this.sliderWidth
352
+ );
309
353
  }
310
354
 
311
355
  private addListeners(): void {
@@ -393,12 +437,17 @@ export class HistogramDateRange extends LitElement {
393
437
  * @param date
394
438
  * @returns x-position of slider
395
439
  */
396
- private translateDateToPosition(date: string): number | null {
397
- const milliseconds = dayjs(date).valueOf();
398
- const xPosition =
440
+ private translateDateToPosition(date: string): number {
441
+ const milliseconds = this.getMSFromString(date);
442
+ return (
399
443
  this.sliderWidth +
400
- ((milliseconds - this._minDateMS) * this._histWidth) / this.dateRangeMS;
401
- return isNaN(milliseconds) || isNaN(xPosition) ? null : xPosition;
444
+ ((milliseconds - this._minDateMS) * this._histWidth) / this.dateRangeMS
445
+ );
446
+ }
447
+
448
+ /** ensure that the returned value is between minValue and maxValue */
449
+ private clamp(x: number, minValue: number, maxValue: number): number {
450
+ return Math.min(Math.max(x, minValue), maxValue);
402
451
  }
403
452
 
404
453
  private handleMinDateInput(e: InputEvent): void {
@@ -413,29 +462,41 @@ export class HistogramDateRange extends LitElement {
413
462
  this.beginEmitUpdateProcess();
414
463
  }
415
464
 
416
- private get currentDateRangeString(): string {
417
- return `${this.minSelectedDate}:${this.maxSelectedDate}`;
465
+ private handleKeyUp(e: KeyboardEvent): void {
466
+ if (e.key === 'Enter') {
467
+ const target = e.currentTarget as HTMLInputElement;
468
+ target.blur();
469
+ }
418
470
  }
419
471
 
420
- /** minimum selected date in milliseconds */
421
- private get minSelectedDateMS(): number {
422
- return dayjs(this.minSelectedDate).valueOf();
472
+ private get currentDateRangeString(): string {
473
+ return `${this.minSelectedDate}:${this.maxSelectedDate}`;
423
474
  }
424
475
 
425
- /** maximum selected date in milliseconds */
426
- private get maxSelectedDateMS(): number {
427
- return dayjs(this.maxSelectedDate).valueOf();
476
+ private getMSFromString(date: string): number {
477
+ const digitGroupCount = (date.split(/(\d+)/).length - 1) / 2;
478
+ if (digitGroupCount === 1) {
479
+ // if there's just a single set of digits, assume it's a year
480
+ const dateObj = new Date(0, 0); // start at January 1, 1900
481
+ dateObj.setFullYear(Number(date)); // override year (=> 0099-01-01) = 99 CE
482
+ return dateObj.getTime(); // get time in milliseconds
483
+ }
484
+ return dayjs(date, [this.dateFormat, DATE_FORMAT]).valueOf();
428
485
  }
429
486
 
430
487
  private handleBarClick(e: InputEvent): void {
431
- const dataset = (e.currentTarget as SVGRectElement).dataset as DOMStringMap;
432
- const binStartDateMS = dayjs(dataset.binStart).valueOf();
433
- if (binStartDateMS < this.minSelectedDateMS) {
434
- this.minSelectedDate = dataset.binStart ?? '';
435
- }
436
- const binEndDateMS = dayjs(dataset.binEnd).valueOf();
437
- if (binEndDateMS > this.maxSelectedDateMS) {
438
- this.maxSelectedDate = dataset.binEnd ?? '';
488
+ const dataset = (e.currentTarget as SVGRectElement).dataset as BarDataset;
489
+ const distanceFromMinSlider =
490
+ this.getMSFromString(dataset.binStart) -
491
+ this.getMSFromString(this.minSelectedDate);
492
+ const distanceFromMaxSlider =
493
+ this.getMSFromString(this.maxSelectedDate) -
494
+ this.getMSFromString(dataset.binEnd);
495
+ // update the selection by moving the nearer slider
496
+ if (distanceFromMinSlider < distanceFromMaxSlider) {
497
+ this.minSelectedDate = dataset.binStart;
498
+ } else {
499
+ this.maxSelectedDate = dataset.binEnd;
439
500
  }
440
501
  this.beginEmitUpdateProcess();
441
502
  }
@@ -482,9 +543,12 @@ export class HistogramDateRange extends LitElement {
482
543
  return svg`
483
544
  <svg
484
545
  id="${id}"
546
+ class="
547
+ ${this.disabled ? '' : 'draggable'}
548
+ ${this._isDragging ? 'dragging' : ''}"
485
549
  @pointerdown="${this.drag}"
486
550
  >
487
- <path d="${sliderShape} z" fill="${sliderFill}" />
551
+ <path d="${sliderShape} z" fill="${sliderColor}" />
488
552
  <rect
489
553
  x="${
490
554
  sliderPositionX - this.sliderWidth * k + this.sliderWidth * 0.4 * k
@@ -514,7 +578,7 @@ export class HistogramDateRange extends LitElement {
514
578
  y="0"
515
579
  width="${this.maxSliderX - this.minSliderX}"
516
580
  height="${this.height}"
517
- fill="${selectedRangeFill}"
581
+ fill="${selectedRangeColor}"
518
582
  />`;
519
583
  }
520
584
 
@@ -542,7 +606,7 @@ export class HistogramDateRange extends LitElement {
542
606
  @pointerleave="${this.hideTooltip}"
543
607
  @click="${this.handleBarClick}"
544
608
  fill="${
545
- x >= this.minSliderX && x <= this.maxSliderX
609
+ x + barWidth >= this.minSliderX && x <= this.maxSliderX
546
610
  ? barIncludedFill
547
611
  : barExcludedFill
548
612
  }"
@@ -555,9 +619,17 @@ export class HistogramDateRange extends LitElement {
555
619
  });
556
620
  }
557
621
 
558
- private formatDate(rawDate: string | number): string {
559
- const date = dayjs(rawDate);
560
- return date.isValid() ? date.format(this.dateFormat) : '';
622
+ private formatDate(dateMS: number): string {
623
+ if (Number.isNaN(dateMS)) {
624
+ return '';
625
+ }
626
+ const date = dayjs(dateMS);
627
+ if (date.year() < 1000) {
628
+ // years before 1000 don't play well with dayjs custom formatting, so fall
629
+ // back to displaying only the year
630
+ return String(date.year());
631
+ }
632
+ return date.format(this.dateFormat);
561
633
  }
562
634
 
563
635
  /**
@@ -574,6 +646,7 @@ export class HistogramDateRange extends LitElement {
574
646
  type="text"
575
647
  @focus="${this.cancelPendingUpdateEvent}"
576
648
  @blur="${this.handleMinDateInput}"
649
+ @keyup="${this.handleKeyUp}"
577
650
  .value="${live(this.minSelectedDate)}"
578
651
  ?disabled="${this.disabled}"
579
652
  />
@@ -588,6 +661,7 @@ export class HistogramDateRange extends LitElement {
588
661
  type="text"
589
662
  @focus="${this.cancelPendingUpdateEvent}"
590
663
  @blur="${this.handleMaxDateInput}"
664
+ @keyup="${this.handleKeyUp}"
591
665
  .value="${live(this.maxSelectedDate)}"
592
666
  ?disabled="${this.disabled}"
593
667
  />
@@ -618,9 +692,9 @@ export class HistogramDateRange extends LitElement {
618
692
  `;
619
693
  }
620
694
 
621
- private get activityIndicatorTemplate(): TemplateResult {
695
+ private get activityIndicatorTemplate(): TemplateResult | typeof nothing {
622
696
  if (!this.loading) {
623
- return html``;
697
+ return nothing;
624
698
  }
625
699
  return html`
626
700
  <ia-activity-indicator mode="processing"> </ia-activity-indicator>
@@ -664,11 +738,17 @@ export class HistogramDateRange extends LitElement {
664
738
  clicks on the bars, preventing users from being able to click in between
665
739
  bars */
666
740
  stroke: rgba(0, 0, 0, 0);
741
+ /* ensure transparent stroke wide enough to cover gap between bars */
667
742
  stroke-width: 2px;
668
743
  }
669
744
  .bar:hover {
745
+ /* highlight currently hovered bar */
670
746
  fill-opacity: 0.7;
671
747
  }
748
+ .disabled .bar:hover {
749
+ /* ensure no visual hover interaction when disabled */
750
+ fill-opacity: 1;
751
+ }
672
752
  /****** histogram ********/
673
753
  #tooltip {
674
754
  position: absolute;
@@ -678,7 +758,7 @@ export class HistogramDateRange extends LitElement {
678
758
  border-radius: 3px;
679
759
  padding: 2px;
680
760
  font-size: ${tooltipFontSize};
681
- font-family: sans-serif;
761
+ font-family: ${tooltipFontFamily};
682
762
  touch-action: none;
683
763
  pointer-events: none;
684
764
  }
@@ -715,6 +795,7 @@ export class HistogramDateRange extends LitElement {
715
795
  border-radius: 2px !important;
716
796
  text-align: center;
717
797
  font-size: ${inputFontSize};
798
+ font-family: ${inputFontFamily};
718
799
  }
719
800
  `;
720
801
 
@@ -755,6 +836,9 @@ export class HistogramDateRange extends LitElement {
755
836
  `;
756
837
  }
757
838
  }
839
+
840
+ // help TypeScript provide strong typing when interacting with DOM APIs
841
+ // https://stackoverflow.com/questions/65148695/lit-element-typescript-project-global-interface-declaration-necessary
758
842
  declare global {
759
843
  interface HTMLElementTagNameMap {
760
844
  'histogram-date-range': HistogramDateRange;