@internetarchive/histogram-date-range 1.2.2-alpha-webdev7377.6 → 1.2.2-alpha-webdev7377.7

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.
@@ -47,13 +47,14 @@ const tooltipFontFamily = css`var(--histogramDateRangeTooltipFontFamily, sans-se
47
47
 
48
48
  type SliderId = 'slider-min' | 'slider-max';
49
49
 
50
- export type BinRoundingInterval = 'month' | 'year';
50
+ export type BinSnappingInterval = 'none' | 'month' | 'year';
51
51
 
52
52
  interface HistogramItem {
53
53
  value: number;
54
54
  height: number;
55
55
  binStart: string;
56
56
  binEnd: string;
57
+ tooltip: string;
57
58
  }
58
59
 
59
60
  interface BarDataset extends DOMStringMap {
@@ -74,8 +75,6 @@ export class HistogramDateRange extends LitElement {
74
75
  @property({ type: Number }) tooltipHeight = TOOLTIP_HEIGHT;
75
76
  @property({ type: Number }) updateDelay = UPDATE_DEBOUNCE_DELAY_MS;
76
77
  @property({ type: String }) dateFormat = DATE_FORMAT;
77
- /** Optional; falls back to `dateFormat` if not provided */
78
- @property({ type: String }) tooltipDateFormat?: string;
79
78
  @property({ type: String }) missingDataMessage = MISSING_DATA;
80
79
  @property({ type: String }) minDate = '';
81
80
  @property({ type: String }) maxDate = '';
@@ -83,13 +82,14 @@ export class HistogramDateRange extends LitElement {
83
82
  @property({ type: Array }) bins: number[] = [];
84
83
  /** If true, update events will not be canceled by the date inputs receiving focus */
85
84
  @property({ type: Boolean }) updateWhileFocused = false;
86
- /** What interval bins should be rounded to for tooltip display */
87
- @property({ type: String }) binRounding: BinRoundingInterval = 'year';
85
+ /** What interval bins should be rounded to for display */
86
+ @property({ type: String }) binSnapping: BinSnappingInterval = 'none';
88
87
 
89
88
  // internal reactive properties not exposed as attributes
90
89
  @state() private _tooltipOffset = 0;
91
90
  @state() private _tooltipContent?: TemplateResult;
92
91
  @state() private _tooltipVisible = false;
92
+ @state() private _tooltipDateFormat?: string;
93
93
  @state() private _isDragging = false;
94
94
  @state() private _isLoading = false;
95
95
 
@@ -122,7 +122,8 @@ export class HistogramDateRange extends LitElement {
122
122
  changedProps.has('minSelectedDate') ||
123
123
  changedProps.has('maxSelectedDate') ||
124
124
  changedProps.has('width') ||
125
- changedProps.has('height')
125
+ changedProps.has('height') ||
126
+ changedProps.has('binSnapping')
126
127
  ) {
127
128
  this.handleDataUpdate();
128
129
  }
@@ -140,8 +141,13 @@ export class HistogramDateRange extends LitElement {
140
141
  return;
141
142
  }
142
143
  this._histWidth = this.width - this.sliderWidth * 2;
143
- this._minDateMS = this.getMSFromString(this.minDate);
144
- this._maxDateMS = this.getMSFromString(this.maxDate);
144
+ this._minDateMS = this.snapTimestamp(this.getMSFromString(this.minDate));
145
+ // NB: The max date string, converted as-is to ms, represents the _start_ of the final date interval; we want the _end_.
146
+ this._maxDateMS =
147
+ this.snapTimestamp(
148
+ this.getMSFromString(this.maxDate) + this.snapInterval
149
+ ) + this.snapEndOffset;
150
+
145
151
  this._binWidth = this._histWidth / this._numBins;
146
152
  this._previousDateRange = this.currentDateRangeString;
147
153
  this._histData = this.calculateHistData();
@@ -153,16 +159,23 @@ export class HistogramDateRange extends LitElement {
153
159
  : this.maxDate;
154
160
  }
155
161
 
162
+ /**
163
+ * Rounds the given timestamp to the next full second.
164
+ */
165
+ private snapToNextSecond(timestamp: number): number {
166
+ return Math.ceil(timestamp / 1000) * 1000;
167
+ }
168
+
156
169
  /**
157
170
  * Rounds the given timestamp to the (approximate) nearest start of a month,
158
- * such that dates up to the 15th of the month are rounded down, while dates
159
- * past the 15th are rounded up.
171
+ * such that dates up to and including the 15th of the month are rounded down,
172
+ * while dates past the 15th are rounded up.
160
173
  */
161
- private roundToMonth(timestamp: number): number {
174
+ private snapToMonth(timestamp: number): number {
162
175
  const d = new Date(timestamp);
163
176
  const [year, month, day] = [d.getFullYear(), d.getMonth(), d.getDate()];
164
177
 
165
- return day < 16 // Obviously only an approximation, but good enough
178
+ return day < 16 // Obviously only an approximation, but good enough for snapping
166
179
  ? new Date(year, month, 1).getTime()
167
180
  : new Date(year, month + 1, 1).getTime();
168
181
  }
@@ -172,21 +185,29 @@ export class HistogramDateRange extends LitElement {
172
185
  * such that dates up to the end of June are rounded down, while dates in
173
186
  * July or later are rounded up.
174
187
  */
175
- private roundToYear(timestamp: number): number {
188
+ private snapToYear(timestamp: number): number {
176
189
  const d = new Date(timestamp);
177
190
  const [year, month] = [d.getFullYear(), d.getMonth()];
178
191
 
179
192
  return month < 6 // NB: months are 0-indexed, so 6 = July
180
- ? new Date(year, 1, 1).getTime()
181
- : new Date(year + 1, 1, 1).getTime();
193
+ ? new Date(year, 0, 1).getTime()
194
+ : new Date(year + 1, 0, 1).getTime();
182
195
  }
183
196
 
184
197
  /**
185
- * Rounds the given timestamp according to the `binRounding` property.
198
+ * Rounds the given timestamp according to the `binSnapping` property.
199
+ * Default is simply to snap to the nearest full second.
186
200
  */
187
- private roundBin(timestamp: number): number {
188
- if (this.binRounding === 'month') return this.roundToMonth(timestamp);
189
- return this.roundToYear(timestamp);
201
+ private snapTimestamp(timestamp: number): number {
202
+ switch (this.binSnapping) {
203
+ case 'year':
204
+ return this.snapToYear(timestamp);
205
+ case 'month':
206
+ return this.snapToMonth(timestamp);
207
+ case 'none':
208
+ default:
209
+ return this.snapToNextSecond(timestamp);
210
+ }
190
211
  }
191
212
 
192
213
  private calculateHistData(): HistogramItem[] {
@@ -198,16 +219,32 @@ export class HistogramDateRange extends LitElement {
198
219
  const valueRange = minValue === maxValue ? 1 : Math.log1p(maxValue);
199
220
  const valueScale = height / valueRange;
200
221
  const dateScale = dateRangeMS / _numBins;
222
+
201
223
  return bins.map((v: number, i: number) => {
202
- const binStartMS = this.roundBin(i * dateScale + _minDateMS);
203
- const binEndMS = this.roundBin((i + 1) * dateScale + _minDateMS) - 1;
224
+ const binStartMS = this.snapTimestamp(i * dateScale + _minDateMS);
225
+ const binStart = this.formatDate(binStartMS);
226
+
227
+ const binEndMS =
228
+ this.snapTimestamp((i + 1) * dateScale + _minDateMS) +
229
+ this.snapEndOffset;
230
+ const binEnd = this.formatDate(binEndMS);
231
+
232
+ const tooltipStart = this.formatDate(binStartMS, this.tooltipDateFormat);
233
+ const tooltipEnd = this.formatDate(binEndMS, this.tooltipDateFormat);
234
+ // If start/end are the same, just render a single value
235
+ const tooltip =
236
+ tooltipStart === tooltipEnd
237
+ ? tooltipStart
238
+ : `${tooltipStart} - ${tooltipEnd}`;
239
+
204
240
  return {
205
241
  value: v,
206
242
  // use log scaling for the height of the bar to prevent tall bars from
207
243
  // making the smaller ones too small to see
208
244
  height: Math.floor(Math.log1p(v) * valueScale),
209
- binStart: this.formatDate(binStartMS, this.resolvedTooltipDateFormat),
210
- binEnd: this.formatDate(binEndMS, this.resolvedTooltipDateFormat),
245
+ binStart,
246
+ binEnd,
247
+ tooltip,
211
248
  };
212
249
  });
213
250
  }
@@ -232,11 +269,39 @@ export class HistogramDateRange extends LitElement {
232
269
  }
233
270
 
234
271
  /**
235
- * Returns the consumer-specified tooltip date format if set, falling back to
236
- * the general `dateFormat` property and then the default format if none is set.
272
+ * Approximate size in ms of the interval to which bins are snapped.
273
+ */
274
+ private get snapInterval(): number {
275
+ const yearMS = 31_536_000_000; // A 365-day approximation of ms in a year
276
+ const monthMS = 2_592_000_000; // A 30-day approximation of ms in a month
277
+ switch (this.binSnapping) {
278
+ case 'year':
279
+ return yearMS;
280
+ case 'month':
281
+ return monthMS;
282
+ case 'none':
283
+ default:
284
+ return 0;
285
+ }
286
+ }
287
+
288
+ /**
289
+ * Offset added to the end of each bin to ensure disjoin intervals, if applicable.
290
+ */
291
+ private get snapEndOffset(): number {
292
+ return this.binSnapping !== 'none' && this._numBins > 1 ? -1 : 0;
293
+ }
294
+
295
+ /**
296
+ * Optional date format to use for tooltips only.
297
+ * Falls back to `dateFormat` if not provided.
237
298
  */
238
- private get resolvedTooltipDateFormat(): string {
239
- return this.tooltipDateFormat ?? this.dateFormat ?? DATE_FORMAT;
299
+ @property({ type: String }) get tooltipDateFormat(): string {
300
+ return this._tooltipDateFormat ?? this.dateFormat ?? DATE_FORMAT;
301
+ }
302
+
303
+ set tooltipDateFormat(value: string) {
304
+ this._tooltipDateFormat = value;
240
305
  }
241
306
 
242
307
  /** component's loading (and disabled) state */
@@ -305,7 +370,10 @@ export class HistogramDateRange extends LitElement {
305
370
 
306
371
  /** horizontal position of max date slider */
307
372
  get maxSliderX(): number {
308
- const x = this.translateDateToPosition(this.maxSelectedDate);
373
+ const maxSelectedDateMS = this.snapTimestamp(
374
+ this.getMSFromString(this.maxSelectedDate) + this.snapInterval
375
+ );
376
+ const x = this.translateDateToPosition(this.formatDate(maxSelectedDateMS));
309
377
  return this.validMaxSliderX(x);
310
378
  }
311
379
 
@@ -321,10 +389,6 @@ export class HistogramDateRange extends LitElement {
321
389
  const x = target.x.baseVal.value + this.sliderWidth / 2;
322
390
  const dataset = target.dataset as BarDataset;
323
391
  const itemsText = `item${dataset.numItems !== '1' ? 's' : ''}`;
324
- const datesText =
325
- dataset.binStart === dataset.binEnd
326
- ? `${dataset.binStart}` // If start/end are the same, just render a single value
327
- : `${dataset.binStart} - ${dataset.binEnd}`;
328
392
  const formattedNumItems = Number(dataset.numItems).toLocaleString();
329
393
 
330
394
  this._tooltipOffset =
@@ -332,7 +396,7 @@ export class HistogramDateRange extends LitElement {
332
396
 
333
397
  this._tooltipContent = html`
334
398
  ${formattedNumItems} ${itemsText}<br />
335
- ${datesText}
399
+ ${dataset.tooltip}
336
400
  `;
337
401
  this._tooltipVisible = true;
338
402
  }
@@ -383,6 +447,9 @@ export class HistogramDateRange extends LitElement {
383
447
  this.maxSelectedDate = this.translatePositionToDate(
384
448
  this.validMaxSliderX(newX)
385
449
  );
450
+ if (this.getMSFromString(this.maxSelectedDate) > this._maxDateMS) {
451
+ this.maxSelectedDate = this.maxDate;
452
+ }
386
453
  }
387
454
  };
388
455
 
@@ -491,9 +558,10 @@ export class HistogramDateRange extends LitElement {
491
558
  * @returns string representation of date
492
559
  */
493
560
  private translatePositionToDate(x: number): string {
494
- // use Math.ceil to round up to fix case where input like 1/1/2010 would get
495
- // translated to 12/31/2009
496
- const milliseconds = Math.ceil(
561
+ // Snap to the nearest second, fixing the case where input like 1/1/2010
562
+ // would get translated to 12/31/2009 due to slight discrepancies from
563
+ // pixel boundaries and floating point error.
564
+ const milliseconds = this.snapToNextSecond(
497
565
  ((x - this.sliderWidth) * this.dateRangeMS) / this._histWidth
498
566
  );
499
567
  return this.formatDate(this._minDateMS + milliseconds);
@@ -684,41 +752,60 @@ export class HistogramDateRange extends LitElement {
684
752
 
685
753
  get histogramTemplate(): SVGTemplateResult[] {
686
754
  const xScale = this._histWidth / this._numBins;
687
- const barWidth = xScale - 0.5;
755
+ const barWidth = xScale - 1;
688
756
  let x = this.sliderWidth; // start at the left edge of the histogram
689
757
 
690
- // the stroke-dasharray style below creates a transparent border around the
691
- // right edge of the bar, which prevents user from encountering a gap
692
- // between adjacent bars (eg when viewing the tooltips or when trying to
693
- // extend the range by clicking on a bar)
694
758
  return this._histData.map(data => {
759
+ const { minSelectedDate, maxSelectedDate } = this;
760
+ const barHeight = data.height;
761
+
762
+ const binIsBeforeMin = this.isBefore(data.binEnd, minSelectedDate);
763
+ const binIsAfterMax = this.isAfter(data.binStart, maxSelectedDate);
764
+ const barFill =
765
+ binIsBeforeMin || binIsAfterMax ? barExcludedFill : barIncludedFill;
766
+
767
+ // the stroke-dasharray style below creates a transparent border around the
768
+ // right edge of the bar, which prevents user from encountering a gap
769
+ // between adjacent bars (eg when viewing the tooltips or when trying to
770
+ // extend the range by clicking on a bar)
771
+ const barStyle = `stroke-dasharray: 0 ${barWidth} ${barHeight} ${barWidth} 0 ${barHeight}`;
772
+
695
773
  const bar = svg`
696
774
  <rect
697
775
  class="bar"
698
- style='stroke-dasharray: 0 ${barWidth} ${data.height} ${barWidth} 0 ${
699
- data.height
700
- };'
701
- x="${x}"
702
- y="${this.height - data.height}"
703
- width="${barWidth}"
704
- height="${data.height}"
705
- @pointerenter="${this.showTooltip}"
706
- @pointerleave="${this.hideTooltip}"
707
- @click="${this.handleBarClick}"
708
- fill="${
709
- x + barWidth >= this.minSliderX && x <= this.maxSliderX
710
- ? barIncludedFill
711
- : barExcludedFill
712
- }"
713
- data-num-items="${data.value}"
714
- data-bin-start="${data.binStart}"
715
- data-bin-end="${data.binEnd}"
776
+ style=${barStyle}
777
+ x=${x}
778
+ y=${this.height - barHeight}
779
+ width=${barWidth}
780
+ height=${barHeight}
781
+ @pointerenter=${this.showTooltip}
782
+ @pointerleave=${this.hideTooltip}
783
+ @click=${this.handleBarClick}
784
+ fill=${barFill}
785
+ data-num-items=${data.value}
786
+ data-bin-start=${data.binStart}
787
+ data-bin-end=${data.binEnd}
788
+ data-tooltip=${data.tooltip}
716
789
  />`;
717
790
  x += xScale;
718
791
  return bar;
719
792
  });
720
793
  }
721
794
 
795
+ /** Whether the first arg represents a date strictly before the second arg */
796
+ private isBefore(date1: string, date2: string): boolean {
797
+ const date1MS = this.getMSFromString(date1);
798
+ const date2MS = this.getMSFromString(date2);
799
+ return date1MS < date2MS;
800
+ }
801
+
802
+ /** Whether the first arg represents a date strictly after the second arg */
803
+ private isAfter(date1: string, date2: string): boolean {
804
+ const date1MS = this.getMSFromString(date1);
805
+ const date2MS = this.getMSFromString(date2);
806
+ return date1MS > date2MS;
807
+ }
808
+
722
809
  private formatDate(dateMS: number, format: string = this.dateFormat): string {
723
810
  if (Number.isNaN(dateMS)) {
724
811
  return '';