@internetarchive/histogram-date-range 0.0.11-beta → 0.1.2-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)}`,
@@ -150,6 +174,14 @@ export class HistogramDateRange extends LitElement {
150
174
  return this.bins.length;
151
175
  }
152
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
+
153
185
  /** component's loading (and disabled) state */
154
186
  @property({ type: Boolean }) get loading(): boolean {
155
187
  return this._isLoading;
@@ -162,56 +194,62 @@ export class HistogramDateRange extends LitElement {
162
194
 
163
195
  /** formatted minimum date of selected date range */
164
196
  @property() get minSelectedDate(): string {
165
- return this.formatDate(this._minSelectedDate);
197
+ return this.formatDate(this.getMSFromString(this._minSelectedDate));
166
198
  }
167
199
 
200
+ /** updates minSelectedDate if new date is valid */
168
201
  set minSelectedDate(rawDate: string) {
169
202
  if (!this._minSelectedDate) {
170
203
  // because the values needed to calculate valid max/min values are not
171
204
  // available during the lit init when it's populating properties from
172
205
  // attributes, fall back to just the raw date if nothing is already set
173
206
  this._minSelectedDate = rawDate;
207
+ return;
174
208
  }
175
- const x = this.translateDateToPosition(rawDate);
176
- if (x) {
177
- const validX = this.validMinSliderX(x);
178
- 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);
179
215
  }
180
216
  this.requestUpdate();
181
217
  }
182
218
 
183
219
  /** formatted maximum date of selected date range */
184
220
  @property() get maxSelectedDate(): string {
185
- return this.formatDate(this._maxSelectedDate);
221
+ return this.formatDate(this.getMSFromString(this._maxSelectedDate));
186
222
  }
187
223
 
224
+ /** updates maxSelectedDate if new date is valid */
188
225
  set maxSelectedDate(rawDate: string) {
189
226
  if (!this._maxSelectedDate) {
190
- // 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
191
230
  this._maxSelectedDate = rawDate;
231
+ return;
192
232
  }
193
- const x = this.translateDateToPosition(rawDate);
194
- if (x) {
195
- const validX = this.validMaxSliderX(x);
196
- 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);
197
239
  }
198
240
  this.requestUpdate();
199
241
  }
242
+
200
243
  /** horizontal position of min date slider */
201
244
  get minSliderX(): number {
202
- return (
203
- // default to leftmost position if missing or invalid min position
204
- this.translateDateToPosition(this.minSelectedDate) ?? this.sliderWidth
205
- );
245
+ const x = this.translateDateToPosition(this.minSelectedDate);
246
+ return this.validMinSliderX(x);
206
247
  }
207
248
 
208
249
  /** horizontal position of max date slider */
209
250
  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
- );
251
+ const x = this.translateDateToPosition(this.maxSelectedDate);
252
+ return this.validMaxSliderX(x);
215
253
  }
216
254
 
217
255
  private get dateRangeMS(): number {
@@ -224,14 +262,15 @@ export class HistogramDateRange extends LitElement {
224
262
  }
225
263
  const target = e.currentTarget as SVGRectElement;
226
264
  const x = target.x.baseVal.value + this.sliderWidth / 2;
227
- const dataset = target.dataset;
265
+ const dataset = target.dataset as BarDataset;
228
266
  const itemsText = `item${dataset.numItems !== '1' ? 's' : ''}`;
267
+ const formattedNumItems = Number(dataset.numItems).toLocaleString();
229
268
 
230
269
  this._tooltipOffset =
231
270
  x + (this._binWidth - this.sliderWidth - this.tooltipWidth) / 2;
232
271
 
233
272
  this._tooltipContent = html`
234
- ${dataset.numItems} ${itemsText}<br />
273
+ ${formattedNumItems} ${itemsText}<br />
235
274
  ${dataset.binStart} - ${dataset.binEnd}
236
275
  `;
237
276
  this._tooltipVisible = true;
@@ -274,11 +313,14 @@ export class HistogramDateRange extends LitElement {
274
313
  private move = (e: PointerEvent): void => {
275
314
  const newX = e.offsetX - this._dragOffset;
276
315
  const slider = this._currentSlider as SVGRectElement;
277
- const date = this.translatePositionToDate(newX);
278
316
  if ((slider.id as SliderId) === 'slider-min') {
279
- this.minSelectedDate = date;
317
+ this.minSelectedDate = this.translatePositionToDate(
318
+ this.validMinSliderX(newX)
319
+ );
280
320
  } else {
281
- this.maxSelectedDate = date;
321
+ this.maxSelectedDate = this.translatePositionToDate(
322
+ this.validMaxSliderX(newX)
323
+ );
282
324
  }
283
325
  };
284
326
 
@@ -286,26 +328,42 @@ export class HistogramDateRange extends LitElement {
286
328
  * Constrain a proposed value for the minimum (left) slider
287
329
  *
288
330
  * If the value is less than the leftmost valid position, then set it to the
289
- * left edge of the widget (ie the slider width). If the value is greater than
290
- * the rightmost valid position (the position of the max slider), then set it
291
- * 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
292
334
  */
293
335
  private validMinSliderX(newX: number): number {
294
- const validX = Math.max(newX, this.sliderWidth);
295
- 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;
296
346
  }
297
347
 
298
348
  /**
299
349
  * Constrain a proposed value for the maximum (right) slider
300
350
  *
301
351
  * If the value is greater than the rightmost valid position, then set it to
302
- * the right edge of the widget (ie widget width - slider width). If the value
303
- * is less than the leftmost valid position (the position of the min slider),
304
- * 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
305
355
  */
306
356
  private validMaxSliderX(newX: number): number {
307
- const validX = Math.max(newX, this.minSliderX);
308
- 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;
309
367
  }
310
368
 
311
369
  private addListeners(): void {
@@ -387,18 +445,22 @@ export class HistogramDateRange extends LitElement {
387
445
  }
388
446
 
389
447
  /**
390
- * Returns slider x-position corresponding to given date (or null if invalid
391
- * date)
448
+ * Returns slider x-position corresponding to given date
392
449
  *
393
450
  * @param date
394
451
  * @returns x-position of slider
395
452
  */
396
- private translateDateToPosition(date: string): number | null {
397
- const milliseconds = dayjs(date).valueOf();
398
- const xPosition =
453
+ private translateDateToPosition(date: string): number {
454
+ const milliseconds = this.getMSFromString(date);
455
+ return (
399
456
  this.sliderWidth +
400
- ((milliseconds - this._minDateMS) * this._histWidth) / this.dateRangeMS;
401
- return isNaN(milliseconds) || isNaN(xPosition) ? null : xPosition;
457
+ ((milliseconds - this._minDateMS) * this._histWidth) / this.dateRangeMS
458
+ );
459
+ }
460
+
461
+ /** ensure that the returned value is between minValue and maxValue */
462
+ private clamp(x: number, minValue: number, maxValue: number): number {
463
+ return Math.min(Math.max(x, minValue), maxValue);
402
464
  }
403
465
 
404
466
  private handleMinDateInput(e: InputEvent): void {
@@ -413,29 +475,51 @@ export class HistogramDateRange extends LitElement {
413
475
  this.beginEmitUpdateProcess();
414
476
  }
415
477
 
416
- private get currentDateRangeString(): string {
417
- 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
+ }
418
483
  }
419
484
 
420
- /** minimum selected date in milliseconds */
421
- private get minSelectedDateMS(): number {
422
- return dayjs(this.minSelectedDate).valueOf();
485
+ private get currentDateRangeString(): string {
486
+ return `${this.minSelectedDate}:${this.maxSelectedDate}`;
423
487
  }
424
488
 
425
- /** maximum selected date in milliseconds */
426
- private get maxSelectedDateMS(): number {
427
- return dayjs(this.maxSelectedDate).valueOf();
489
+ private getMSFromString(date: string): number {
490
+ const digitGroupCount = (date.split(/(\d+)/).length - 1) / 2;
491
+ if (digitGroupCount === 1) {
492
+ // if there's just a single set of digits, assume it's a year
493
+ const dateObj = new Date(0, 0); // start at January 1, 1900
494
+ dateObj.setFullYear(Number(date)); // override year (=> 0099-01-01) = 99 CE
495
+ return dateObj.getTime(); // get time in milliseconds
496
+ }
497
+ return dayjs(date, [this.dateFormat, DATE_FORMAT]).valueOf();
428
498
  }
429
499
 
430
- 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 ?? '';
500
+ /**
501
+ * expand or narrow the selected range by moving the slider nearest the
502
+ * clicked bar to the outer edge of the clicked bar
503
+ *
504
+ * @param e Event click event from a histogram bar
505
+ */
506
+ private handleBarClick(e: Event): void {
507
+ const dataset = (e.currentTarget as SVGRectElement).dataset as BarDataset;
508
+ const clickPosition =
509
+ (this.getMSFromString(dataset.binStart) +
510
+ this.getMSFromString(dataset.binEnd)) /
511
+ 2;
512
+ const distanceFromMinSlider = Math.abs(
513
+ clickPosition - this.getMSFromString(this.minSelectedDate)
514
+ );
515
+ const distanceFromMaxSlider = Math.abs(
516
+ clickPosition - this.getMSFromString(this.maxSelectedDate)
517
+ );
518
+ // update the selected range by moving the nearer slider
519
+ if (distanceFromMinSlider < distanceFromMaxSlider) {
520
+ this.minSelectedDate = dataset.binStart;
521
+ } else {
522
+ this.maxSelectedDate = dataset.binEnd;
439
523
  }
440
524
  this.beginEmitUpdateProcess();
441
525
  }
@@ -482,10 +566,12 @@ export class HistogramDateRange extends LitElement {
482
566
  return svg`
483
567
  <svg
484
568
  id="${id}"
485
- class="draggable ${this._isDragging ? 'dragging' : ''}"
569
+ class="
570
+ ${this.disabled ? '' : 'draggable'}
571
+ ${this._isDragging ? 'dragging' : ''}"
486
572
  @pointerdown="${this.drag}"
487
573
  >
488
- <path d="${sliderShape} z" fill="${sliderFill}" />
574
+ <path d="${sliderShape} z" fill="${sliderColor}" />
489
575
  <rect
490
576
  x="${
491
577
  sliderPositionX - this.sliderWidth * k + this.sliderWidth * 0.4 * k
@@ -515,7 +601,7 @@ export class HistogramDateRange extends LitElement {
515
601
  y="0"
516
602
  width="${this.maxSliderX - this.minSliderX}"
517
603
  height="${this.height}"
518
- fill="${selectedRangeFill}"
604
+ fill="${selectedRangeColor}"
519
605
  />`;
520
606
  }
521
607
 
@@ -543,7 +629,7 @@ export class HistogramDateRange extends LitElement {
543
629
  @pointerleave="${this.hideTooltip}"
544
630
  @click="${this.handleBarClick}"
545
631
  fill="${
546
- x >= this.minSliderX && x <= this.maxSliderX
632
+ x + barWidth >= this.minSliderX && x <= this.maxSliderX
547
633
  ? barIncludedFill
548
634
  : barExcludedFill
549
635
  }"
@@ -556,9 +642,17 @@ export class HistogramDateRange extends LitElement {
556
642
  });
557
643
  }
558
644
 
559
- private formatDate(rawDate: string | number): string {
560
- const date = dayjs(rawDate);
561
- return date.isValid() ? date.format(this.dateFormat) : '';
645
+ private formatDate(dateMS: number): string {
646
+ if (Number.isNaN(dateMS)) {
647
+ return '';
648
+ }
649
+ const date = dayjs(dateMS);
650
+ if (date.year() < 1000) {
651
+ // years before 1000 don't play well with dayjs custom formatting, so fall
652
+ // back to displaying only the year
653
+ return String(date.year());
654
+ }
655
+ return date.format(this.dateFormat);
562
656
  }
563
657
 
564
658
  /**
@@ -575,6 +669,7 @@ export class HistogramDateRange extends LitElement {
575
669
  type="text"
576
670
  @focus="${this.cancelPendingUpdateEvent}"
577
671
  @blur="${this.handleMinDateInput}"
672
+ @keyup="${this.handleKeyUp}"
578
673
  .value="${live(this.minSelectedDate)}"
579
674
  ?disabled="${this.disabled}"
580
675
  />
@@ -589,6 +684,7 @@ export class HistogramDateRange extends LitElement {
589
684
  type="text"
590
685
  @focus="${this.cancelPendingUpdateEvent}"
591
686
  @blur="${this.handleMaxDateInput}"
687
+ @keyup="${this.handleKeyUp}"
592
688
  .value="${live(this.maxSelectedDate)}"
593
689
  ?disabled="${this.disabled}"
594
690
  />
@@ -619,9 +715,9 @@ export class HistogramDateRange extends LitElement {
619
715
  `;
620
716
  }
621
717
 
622
- private get activityIndicatorTemplate(): TemplateResult {
718
+ private get activityIndicatorTemplate(): TemplateResult | typeof nothing {
623
719
  if (!this.loading) {
624
- return html``;
720
+ return nothing;
625
721
  }
626
722
  return html`
627
723
  <ia-activity-indicator mode="processing"> </ia-activity-indicator>
@@ -685,7 +781,7 @@ export class HistogramDateRange extends LitElement {
685
781
  border-radius: 3px;
686
782
  padding: 2px;
687
783
  font-size: ${tooltipFontSize};
688
- font-family: sans-serif;
784
+ font-family: ${tooltipFontFamily};
689
785
  touch-action: none;
690
786
  pointer-events: none;
691
787
  }
@@ -722,6 +818,7 @@ export class HistogramDateRange extends LitElement {
722
818
  border-radius: 2px !important;
723
819
  text-align: center;
724
820
  font-size: ${inputFontSize};
821
+ font-family: ${inputFontFamily};
725
822
  }
726
823
  `;
727
824
 
@@ -762,6 +859,9 @@ export class HistogramDateRange extends LitElement {
762
859
  `;
763
860
  }
764
861
  }
862
+
863
+ // help TypeScript provide strong typing when interacting with DOM APIs
864
+ // https://stackoverflow.com/questions/65148695/lit-element-typescript-project-global-interface-declaration-necessary
765
865
  declare global {
766
866
  interface HTMLElementTagNameMap {
767
867
  'histogram-date-range': HistogramDateRange;