@internetarchive/histogram-date-range 0.1.0-beta → 0.1.3-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
 
@@ -123,8 +128,8 @@ export class HistogramDateRange extends LitElement {
123
128
  return;
124
129
  }
125
130
  this._histWidth = this.width - this.sliderWidth * 2;
126
- this._minDateMS = dayjs(this.minDate).valueOf();
127
- this._maxDateMS = dayjs(this.maxDate).valueOf();
131
+ this._minDateMS = this.getMSFromString(this.minDate);
132
+ this._maxDateMS = this.getMSFromString(this.maxDate);
128
133
  this._binWidth = this._histWidth / this._numBins;
129
134
  this._previousDateRange = this.currentDateRangeString;
130
135
  this._histData = this.calculateHistData();
@@ -140,11 +145,17 @@ export class HistogramDateRange extends LitElement {
140
145
  private calculateHistData(): HistogramItem[] {
141
146
  const minValue = Math.min(...this.bins);
142
147
  const maxValue = Math.max(...this.bins);
143
- 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;
144
153
  const dateScale = this.dateRangeMS / this._numBins;
145
154
  return this.bins.map((v: number, i: number) => {
146
155
  return {
147
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
148
159
  height: Math.floor(Math.log1p(v) * valueScale),
149
160
  binStart: `${this.formatDate(i * dateScale + this._minDateMS)}`,
150
161
  binEnd: `${this.formatDate((i + 1) * dateScale + this._minDateMS)}`,
@@ -163,6 +174,14 @@ export class HistogramDateRange extends LitElement {
163
174
  return this.bins.length;
164
175
  }
165
176
 
177
+ private get histogramLeftEdgeX(): number {
178
+ return this.sliderWidth;
179
+ }
180
+
181
+ private get histogramRightEdgeX(): number {
182
+ return this.width - this.sliderWidth;
183
+ }
184
+
166
185
  /** component's loading (and disabled) state */
167
186
  @property({ type: Boolean }) get loading(): boolean {
168
187
  return this._isLoading;
@@ -175,56 +194,62 @@ export class HistogramDateRange extends LitElement {
175
194
 
176
195
  /** formatted minimum date of selected date range */
177
196
  @property() get minSelectedDate(): string {
178
- return this.formatDate(this._minSelectedDate);
197
+ return this.formatDate(this.getMSFromString(this._minSelectedDate));
179
198
  }
180
199
 
200
+ /** updates minSelectedDate if new date is valid */
181
201
  set minSelectedDate(rawDate: string) {
182
202
  if (!this._minSelectedDate) {
183
203
  // because the values needed to calculate valid max/min values are not
184
204
  // available during the lit init when it's populating properties from
185
205
  // attributes, fall back to just the raw date if nothing is already set
186
206
  this._minSelectedDate = rawDate;
207
+ return;
187
208
  }
188
- const x = this.translateDateToPosition(rawDate);
189
- if (x) {
190
- const validX = this.validMinSliderX(x);
191
- this._minSelectedDate = this.translatePositionToDate(validX);
209
+ const proposedDateMS = this.getMSFromString(rawDate);
210
+ const isValidDate = !Number.isNaN(proposedDateMS);
211
+ const isNotTooRecent =
212
+ proposedDateMS <= this.getMSFromString(this.maxSelectedDate);
213
+ if (isValidDate && isNotTooRecent) {
214
+ this._minSelectedDate = this.formatDate(proposedDateMS);
192
215
  }
193
216
  this.requestUpdate();
194
217
  }
195
218
 
196
219
  /** formatted maximum date of selected date range */
197
220
  @property() get maxSelectedDate(): string {
198
- return this.formatDate(this._maxSelectedDate);
221
+ return this.formatDate(this.getMSFromString(this._maxSelectedDate));
199
222
  }
200
223
 
224
+ /** updates maxSelectedDate if new date is valid */
201
225
  set maxSelectedDate(rawDate: string) {
202
226
  if (!this._maxSelectedDate) {
203
- // see comment above in the minSelectedDate setter
227
+ // because the values needed to calculate valid max/min values are not
228
+ // available during the lit init when it's populating properties from
229
+ // attributes, fall back to just the raw date if nothing is already set
204
230
  this._maxSelectedDate = rawDate;
231
+ return;
205
232
  }
206
- const x = this.translateDateToPosition(rawDate);
207
- if (x) {
208
- const validX = this.validMaxSliderX(x);
209
- this._maxSelectedDate = this.translatePositionToDate(validX);
233
+ const proposedDateMS = this.getMSFromString(rawDate);
234
+ const isValidDate = !Number.isNaN(proposedDateMS);
235
+ const isNotTooOld =
236
+ proposedDateMS >= this.getMSFromString(this.minSelectedDate);
237
+ if (isValidDate && isNotTooOld) {
238
+ this._maxSelectedDate = this.formatDate(proposedDateMS);
210
239
  }
211
240
  this.requestUpdate();
212
241
  }
242
+
213
243
  /** horizontal position of min date slider */
214
244
  get minSliderX(): number {
215
- return (
216
- // default to leftmost position if missing or invalid min position
217
- this.translateDateToPosition(this.minSelectedDate) ?? this.sliderWidth
218
- );
245
+ const x = this.translateDateToPosition(this.minSelectedDate);
246
+ return this.validMinSliderX(x);
219
247
  }
220
248
 
221
249
  /** horizontal position of max date slider */
222
250
  get maxSliderX(): number {
223
- return (
224
- // default to rightmost position if missing or invalid max position
225
- this.translateDateToPosition(this.maxSelectedDate) ??
226
- this.width - this.sliderWidth
227
- );
251
+ const x = this.translateDateToPosition(this.maxSelectedDate);
252
+ return this.validMaxSliderX(x);
228
253
  }
229
254
 
230
255
  private get dateRangeMS(): number {
@@ -239,12 +264,13 @@ export class HistogramDateRange extends LitElement {
239
264
  const x = target.x.baseVal.value + this.sliderWidth / 2;
240
265
  const dataset = target.dataset as BarDataset;
241
266
  const itemsText = `item${dataset.numItems !== '1' ? 's' : ''}`;
267
+ const formattedNumItems = Number(dataset.numItems).toLocaleString();
242
268
 
243
269
  this._tooltipOffset =
244
270
  x + (this._binWidth - this.sliderWidth - this.tooltipWidth) / 2;
245
271
 
246
272
  this._tooltipContent = html`
247
- ${dataset.numItems} ${itemsText}<br />
273
+ ${formattedNumItems} ${itemsText}<br />
248
274
  ${dataset.binStart} - ${dataset.binEnd}
249
275
  `;
250
276
  this._tooltipVisible = true;
@@ -287,11 +313,14 @@ export class HistogramDateRange extends LitElement {
287
313
  private move = (e: PointerEvent): void => {
288
314
  const newX = e.offsetX - this._dragOffset;
289
315
  const slider = this._currentSlider as SVGRectElement;
290
- const date = this.translatePositionToDate(newX);
291
316
  if ((slider.id as SliderId) === 'slider-min') {
292
- this.minSelectedDate = date;
317
+ this.minSelectedDate = this.translatePositionToDate(
318
+ this.validMinSliderX(newX)
319
+ );
293
320
  } else {
294
- this.maxSelectedDate = date;
321
+ this.maxSelectedDate = this.translatePositionToDate(
322
+ this.validMaxSliderX(newX)
323
+ );
295
324
  }
296
325
  };
297
326
 
@@ -299,26 +328,42 @@ export class HistogramDateRange extends LitElement {
299
328
  * Constrain a proposed value for the minimum (left) slider
300
329
  *
301
330
  * If the value is less than the leftmost valid position, then set it to the
302
- * left edge of the widget (ie the slider width). If the value is greater than
303
- * the rightmost valid position (the position of the max slider), then set it
304
- * to the position of the max slider
331
+ * left edge of the histogram (ie the slider width). If the value is greater
332
+ * than the rightmost valid position (the position of the max slider), then
333
+ * set it to the position of the max slider
305
334
  */
306
335
  private validMinSliderX(newX: number): number {
307
- const validX = Math.max(newX, this.sliderWidth);
308
- return Math.min(validX, this.maxSliderX);
336
+ // allow the left slider to go right only to the right slider, even if the
337
+ // max selected date is out of range
338
+ const rightLimit = Math.min(
339
+ this.translateDateToPosition(this.maxSelectedDate),
340
+ this.histogramRightEdgeX
341
+ );
342
+ newX = this.clamp(newX, this.histogramLeftEdgeX, rightLimit);
343
+ const isInvalid =
344
+ Number.isNaN(newX) || rightLimit < this.histogramLeftEdgeX;
345
+ return isInvalid ? this.histogramLeftEdgeX : newX;
309
346
  }
310
347
 
311
348
  /**
312
349
  * Constrain a proposed value for the maximum (right) slider
313
350
  *
314
351
  * If the value is greater than the rightmost valid position, then set it to
315
- * the right edge of the widget (ie widget width - slider width). If the value
316
- * is less than the leftmost valid position (the position of the min slider),
317
- * then set it to the position of the min slider
352
+ * the right edge of the histogram (ie histogram width - slider width). If the
353
+ * value is less than the leftmost valid position (the position of the min
354
+ * slider), then set it to the position of the min slider
318
355
  */
319
356
  private validMaxSliderX(newX: number): number {
320
- const validX = Math.max(newX, this.minSliderX);
321
- return Math.min(validX, this.width - this.sliderWidth);
357
+ // allow the right slider to go left only to the left slider, even if the
358
+ // min selected date is out of range
359
+ const leftLimit = Math.max(
360
+ this.histogramLeftEdgeX,
361
+ this.translateDateToPosition(this.minSelectedDate)
362
+ );
363
+ newX = this.clamp(newX, leftLimit, this.histogramRightEdgeX);
364
+ const isInvalid =
365
+ Number.isNaN(newX) || leftLimit > this.histogramRightEdgeX;
366
+ return isInvalid ? this.histogramRightEdgeX : newX;
322
367
  }
323
368
 
324
369
  private addListeners(): void {
@@ -400,54 +445,87 @@ export class HistogramDateRange extends LitElement {
400
445
  }
401
446
 
402
447
  /**
403
- * Returns slider x-position corresponding to given date (or null if invalid
404
- * date)
448
+ * Returns slider x-position corresponding to given date
405
449
  *
406
450
  * @param date
407
451
  * @returns x-position of slider
408
452
  */
409
- private translateDateToPosition(date: string): number | null {
410
- const milliseconds = dayjs(date).valueOf();
411
- const xPosition =
453
+ private translateDateToPosition(date: string): number {
454
+ const milliseconds = this.getMSFromString(date);
455
+ return (
412
456
  this.sliderWidth +
413
- ((milliseconds - this._minDateMS) * this._histWidth) / this.dateRangeMS;
414
- return isNaN(milliseconds) || isNaN(xPosition) ? null : xPosition;
457
+ ((milliseconds - this._minDateMS) * this._histWidth) / this.dateRangeMS
458
+ );
415
459
  }
416
460
 
417
- private handleMinDateInput(e: InputEvent): void {
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 handleMinDateInput(e: Event): void {
418
467
  const target = e.currentTarget as HTMLInputElement;
419
468
  this.minSelectedDate = target.value;
420
469
  this.beginEmitUpdateProcess();
421
470
  }
422
471
 
423
- private handleMaxDateInput(e: InputEvent): void {
472
+ private handleMaxDateInput(e: Event): void {
424
473
  const target = e.currentTarget as HTMLInputElement;
425
474
  this.maxSelectedDate = target.value;
426
475
  this.beginEmitUpdateProcess();
427
476
  }
428
477
 
429
- private get currentDateRangeString(): string {
430
- return `${this.minSelectedDate}:${this.maxSelectedDate}`;
478
+ private handleKeyUp(e: KeyboardEvent): void {
479
+ if (e.key === 'Enter') {
480
+ const target = e.currentTarget as HTMLInputElement;
481
+ target.blur();
482
+ if (target.id === 'date-min') {
483
+ this.handleMinDateInput(e);
484
+ } else if (target.id === 'date-max') {
485
+ this.handleMaxDateInput(e);
486
+ }
487
+ }
431
488
  }
432
489
 
433
- /** minimum selected date in milliseconds */
434
- private get minSelectedDateMS(): number {
435
- return dayjs(this.minSelectedDate).valueOf();
490
+ private get currentDateRangeString(): string {
491
+ return `${this.minSelectedDate}:${this.maxSelectedDate}`;
436
492
  }
437
493
 
438
- /** maximum selected date in milliseconds */
439
- private get maxSelectedDateMS(): number {
440
- return dayjs(this.maxSelectedDate).valueOf();
494
+ private getMSFromString(date: string): number {
495
+ const digitGroupCount = (date.split(/(\d+)/).length - 1) / 2;
496
+ if (digitGroupCount === 1) {
497
+ // if there's just a single set of digits, assume it's a year
498
+ const dateObj = new Date(0, 0); // start at January 1, 1900
499
+ dateObj.setFullYear(Number(date)); // override year
500
+ return dateObj.getTime(); // get time in milliseconds
501
+ }
502
+ return dayjs(date, [this.dateFormat, DATE_FORMAT]).valueOf();
441
503
  }
442
504
 
443
- private handleBarClick(e: InputEvent): void {
505
+ /**
506
+ * expand or narrow the selected range by moving the slider nearest the
507
+ * clicked bar to the outer edge of the clicked bar
508
+ *
509
+ * @param e Event click event from a histogram bar
510
+ */
511
+ private handleBarClick(e: Event): void {
444
512
  const dataset = (e.currentTarget as SVGRectElement).dataset as BarDataset;
445
- const binStartDateMS = dayjs(dataset.binStart).valueOf();
446
- if (binStartDateMS < this.minSelectedDateMS) {
513
+ // use the midpoint of the width of the clicked bar to determine which is
514
+ // the nearest slider
515
+ const clickPosition =
516
+ (this.getMSFromString(dataset.binStart) +
517
+ this.getMSFromString(dataset.binEnd)) /
518
+ 2;
519
+ const distanceFromMinSlider = Math.abs(
520
+ clickPosition - this.getMSFromString(this.minSelectedDate)
521
+ );
522
+ const distanceFromMaxSlider = Math.abs(
523
+ clickPosition - this.getMSFromString(this.maxSelectedDate)
524
+ );
525
+ // update the selected range by moving the nearer slider
526
+ if (distanceFromMinSlider < distanceFromMaxSlider) {
447
527
  this.minSelectedDate = dataset.binStart;
448
- }
449
- const binEndDateMS = dayjs(dataset.binEnd).valueOf();
450
- if (binEndDateMS > this.maxSelectedDateMS) {
528
+ } else {
451
529
  this.maxSelectedDate = dataset.binEnd;
452
530
  }
453
531
  this.beginEmitUpdateProcess();
@@ -500,7 +578,7 @@ export class HistogramDateRange extends LitElement {
500
578
  ${this._isDragging ? 'dragging' : ''}"
501
579
  @pointerdown="${this.drag}"
502
580
  >
503
- <path d="${sliderShape} z" fill="${sliderFill}" />
581
+ <path d="${sliderShape} z" fill="${sliderColor}" />
504
582
  <rect
505
583
  x="${
506
584
  sliderPositionX - this.sliderWidth * k + this.sliderWidth * 0.4 * k
@@ -530,7 +608,7 @@ export class HistogramDateRange extends LitElement {
530
608
  y="0"
531
609
  width="${this.maxSliderX - this.minSliderX}"
532
610
  height="${this.height}"
533
- fill="${selectedRangeFill}"
611
+ fill="${selectedRangeColor}"
534
612
  />`;
535
613
  }
536
614
 
@@ -558,7 +636,7 @@ export class HistogramDateRange extends LitElement {
558
636
  @pointerleave="${this.hideTooltip}"
559
637
  @click="${this.handleBarClick}"
560
638
  fill="${
561
- x >= this.minSliderX && x <= this.maxSliderX
639
+ x + barWidth >= this.minSliderX && x <= this.maxSliderX
562
640
  ? barIncludedFill
563
641
  : barExcludedFill
564
642
  }"
@@ -571,9 +649,17 @@ export class HistogramDateRange extends LitElement {
571
649
  });
572
650
  }
573
651
 
574
- private formatDate(rawDate: string | number): string {
575
- const date = dayjs(rawDate);
576
- return date.isValid() ? date.format(this.dateFormat) : '';
652
+ private formatDate(dateMS: number): string {
653
+ if (Number.isNaN(dateMS)) {
654
+ return '';
655
+ }
656
+ const date = dayjs(dateMS);
657
+ if (date.year() < 1000) {
658
+ // years before 1000 don't play well with dayjs custom formatting, so fall
659
+ // back to displaying only the year
660
+ return String(date.year());
661
+ }
662
+ return date.format(this.dateFormat);
577
663
  }
578
664
 
579
665
  /**
@@ -590,6 +676,7 @@ export class HistogramDateRange extends LitElement {
590
676
  type="text"
591
677
  @focus="${this.cancelPendingUpdateEvent}"
592
678
  @blur="${this.handleMinDateInput}"
679
+ @keyup="${this.handleKeyUp}"
593
680
  .value="${live(this.minSelectedDate)}"
594
681
  ?disabled="${this.disabled}"
595
682
  />
@@ -604,6 +691,7 @@ export class HistogramDateRange extends LitElement {
604
691
  type="text"
605
692
  @focus="${this.cancelPendingUpdateEvent}"
606
693
  @blur="${this.handleMaxDateInput}"
694
+ @keyup="${this.handleKeyUp}"
607
695
  .value="${live(this.maxSelectedDate)}"
608
696
  ?disabled="${this.disabled}"
609
697
  />
@@ -634,9 +722,9 @@ export class HistogramDateRange extends LitElement {
634
722
  `;
635
723
  }
636
724
 
637
- private get activityIndicatorTemplate(): TemplateResult {
725
+ private get activityIndicatorTemplate(): TemplateResult | typeof nothing {
638
726
  if (!this.loading) {
639
- return html``;
727
+ return nothing;
640
728
  }
641
729
  return html`
642
730
  <ia-activity-indicator mode="processing"> </ia-activity-indicator>
@@ -700,7 +788,7 @@ export class HistogramDateRange extends LitElement {
700
788
  border-radius: 3px;
701
789
  padding: 2px;
702
790
  font-size: ${tooltipFontSize};
703
- font-family: sans-serif;
791
+ font-family: ${tooltipFontFamily};
704
792
  touch-action: none;
705
793
  pointer-events: none;
706
794
  }
@@ -737,6 +825,7 @@ export class HistogramDateRange extends LitElement {
737
825
  border-radius: 2px !important;
738
826
  text-align: center;
739
827
  font-size: ${inputFontSize};
828
+ font-family: ${inputFontFamily};
740
829
  }
741
830
  `;
742
831
 
@@ -777,6 +866,9 @@ export class HistogramDateRange extends LitElement {
777
866
  `;
778
867
  }
779
868
  }
869
+
870
+ // help TypeScript provide strong typing when interacting with DOM APIs
871
+ // https://stackoverflow.com/questions/65148695/lit-element-typescript-project-global-interface-declaration-necessary
780
872
  declare global {
781
873
  interface HTMLElementTagNameMap {
782
874
  'histogram-date-range': HistogramDateRange;