@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.
- package/demo/index.html +27 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/src/histogram-date-range.d.ts +31 -14
- package/dist/src/histogram-date-range.js +129 -56
- package/dist/src/histogram-date-range.js.map +1 -1
- package/docs/demo/index.html +27 -0
- package/docs/dist/src/histogram-date-range.js +95 -38
- package/index.ts +4 -1
- package/package.json +1 -1
- package/src/histogram-date-range.ts +146 -59
|
@@ -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
|
|
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
|
|
87
|
-
@property({ type: String })
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
|
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,
|
|
181
|
-
: new Date(year + 1,
|
|
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 `
|
|
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
|
|
188
|
-
|
|
189
|
-
|
|
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.
|
|
203
|
-
const
|
|
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
|
|
210
|
-
binEnd
|
|
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
|
-
*
|
|
236
|
-
|
|
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
|
-
|
|
239
|
-
return this.
|
|
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
|
|
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
|
-
${
|
|
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
|
-
//
|
|
495
|
-
// translated to 12/31/2009
|
|
496
|
-
|
|
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 -
|
|
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
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
@
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
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 '';
|