UNPKG

@bokeh/bokehjs

Version:

Interactive, novel data visualization

271 lines 11.6 kB
import { ContextWhich, Location, ResolutionType } from "../../core/enums"; import { assert } from "../../core/util/assert"; import { datetime, sprintf } from "../../core/util/templating"; import { isString, isArray, isBoolean, is_undefined } from "../../core/util/types"; import { TickFormatter } from "./tick_formatter"; import { ONE_DAY, ONE_HOUR, ONE_MILLI, ONE_MINUTE, ONE_MONTH, ONE_SECOND, ONE_YEAR } from "../tickers/util"; // Labels of time units, from finest to coarsest. export const resolution_order = [ "microseconds", "milliseconds", "seconds", "minsec", "minutes", "hourmin", "hours", "days", "months", "years", ]; // This dictionary maps the name of a time resolution (in @resolution_order) // to its index in a time.localtime() time tuple. The default is to map // everything to index 0, which is year. This is not ideal; it might cause // a problem with the tick at midnight, january 1st, 0 a.d. being incorrectly // promoted at certain tick resolutions. export const tm_index_for_resolution = { microseconds: 0, milliseconds: 0, seconds: 5, minsec: 4, minutes: 4, hourmin: 3, hours: 3, days: 0, months: 0, years: 0, }; export function _get_resolution(resolution_secs, span_secs) { // Our resolution boundaries should not be round numbers, because we want // them to fall between the possible tick intervals (which *are* round // numbers, as we've worked hard to ensure). Consequently, we adjust the // resolution upwards a small amount (less than any possible step in // scales) to make the effective boundaries slightly lower. const adjusted_ms = resolution_secs * 1.1 * 1000; const span_ms = span_secs * 1000; if (adjusted_ms < ONE_MILLI) { return "microseconds"; } if (adjusted_ms < ONE_SECOND) { return "milliseconds"; } if (adjusted_ms < ONE_MINUTE) { return span_ms >= ONE_MINUTE ? "minsec" : "seconds"; } if (adjusted_ms < ONE_HOUR) { return span_ms >= ONE_HOUR ? "hourmin" : "minutes"; } if (adjusted_ms < ONE_DAY) { return "hours"; } if (adjusted_ms < ONE_MONTH) { return "days"; } if (adjusted_ms < ONE_YEAR) { return "months"; } return "years"; } export function _mktime(t) { return datetime(t, "%Y %m %d %H %M %S").split(/\s+/).map(e => parseInt(e, 10)); } export function _strftime(t, format) { // Python's datetime library augments the microsecond directive %f, which is not // supported by the javascript library timezone: http://bigeasy.github.io/timezone/. // Use a regular expression to replace %f directive with microseconds. const microsecond_replacement_string = sprintf("$1%06d", _us(t)); format = format.replace(/((^|[^%])(%%)*)%f/, microsecond_replacement_string); format = format.replace(/((^|[^%])(%%)*)%[0-9]*N/, match => { // By default use 9 digits for nanoseconds, this is applied not only in case of no padding // (%N) but also for padding out of range (i.e. %0N, %15N) let padding = 9; const str_padding_matched = match.match(/%([1-9])N/); if (str_padding_matched != null) { const padding_parsed = parseInt(str_padding_matched[1], 10); if (!Number.isNaN(padding_parsed)) { padding = padding_parsed; } } const ns = _ns(t); const nanosecond_replacement_string = sprintf("%09d", ns).substring(0, padding); return match.replace(/%[0-9]*N/, nanosecond_replacement_string); }); // timezone seems to ignore any strings without any formatting directives, // and just return the time argument back instead of the string argument. // But we want the string argument, in case a user supplies a format string // which doesn't contain a formatting directive or is only using %f. if (format.indexOf("%") == -1) { return format; } return datetime(t, format); } export function _us(t) { // From double-precision unix (millisecond) timestamp, get microseconds since // last second. Precision seems to run out around the hundreds of nanoseconds // scale, so rounding to the nearest microsecond should round to a nice // microsecond / millisecond tick. // Note: for negative timestamps (pre epoch) the microsecond scale needs to be // inverted as we are counting backwards. let us = Math.round(((t / 1000) % 1) * 1000000); if (t < 0.0) { us = (1000000 + us) % 1000000; } return us; } export function _ns(t) { // Use rounded microsecond result as a baseline as the precision is not sufficient // for the sub microsecond range anyway. return _us(t) * 1000; } export class DatetimeTickFormatter extends TickFormatter { static __name__ = "DatetimeTickFormatter"; constructor(attrs) { super(attrs); } static { this.define(({ Bool, Nullable, Or, Ref, Str, Arrayable }) => ({ microseconds: [Str, "%fus"], milliseconds: [Str, "%3Nms"], seconds: [Str, "%Ss"], minsec: [Str, ":%M:%S"], minutes: [Str, ":%M"], hourmin: [Str, "%H:%M"], hours: [Str, "%Hh"], days: [Str, "%m/%d"], months: [Str, "%m/%Y"], years: [Str, "%Y"], strip_leading_zeros: [Or(Bool, Arrayable(ResolutionType)), false], boundary_scaling: [Bool, true], hide_repeats: [Bool, false], context: [Nullable(Or(Str, Ref(DatetimeTickFormatter))), null], context_which: [ContextWhich, "start"], context_location: [Location, "below"], })); } doFormat(ticks, _opts, _resolution) { if (ticks.length == 0) { return []; } const span = Math.abs(ticks[ticks.length - 1] - ticks[0]) / 1000.0; const r = span / (ticks.length - 1); const resolution = is_undefined(_resolution) ? _get_resolution(r, span) : _resolution; let base_labels = []; for (const tick of ticks) { const base_label = this._compute_label(tick, resolution); base_labels.push(base_label); } if (this.hide_repeats) { base_labels = this._hide_repeating_labels(base_labels); } if (this.context == null) { return base_labels; } const context_labels = this._compute_context_labels(ticks, resolution); return this._build_full_labels(base_labels, context_labels); } _compute_label(t, resolution) { const s0 = _strftime(t, this[resolution]); const tm = _mktime(t); const resolution_index = resolution_order.indexOf(resolution); let final_resolution = resolution; let s = s0; if (this.boundary_scaling) { let hybrid_handled = false; let next_index = resolution_index; let next_resolution = resolution; // As we format each tick, check to see if we are at a boundary of the // next higher unit of time. If so, replace the current format with one // from that resolution. This is not the best heuristic but it works. while (tm[tm_index_for_resolution[resolution_order[next_index]]] == 0) { next_index += 1; if (next_index == resolution_order.length) { break; } // The way to check that we are at the boundary of the next unit of // time is by checking that we have 0 units of the resolution, i.e. // we are at zero minutes, so display hours, or we are at zero seconds, // so display minutes (and if that is zero as well, then display hours). if ((resolution == "minsec" || resolution == "hourmin") && !hybrid_handled) { if ((resolution == "minsec" && tm[4] == 0 && tm[5] != 0) || (resolution == "hourmin" && tm[3] == 0 && tm[4] != 0)) { next_resolution = resolution_order[resolution_index - 1]; s = _strftime(t, this[next_resolution]); break; } else { hybrid_handled = true; } } next_resolution = resolution_order[next_index]; s = _strftime(t, this[next_resolution]); } final_resolution = next_resolution; } const { strip_leading_zeros } = this; if ((isBoolean(strip_leading_zeros) && strip_leading_zeros) || (isArray(strip_leading_zeros) && strip_leading_zeros.includes(final_resolution))) { const ss = s.replace(/^0+/g, ""); if (ss != s && !Number.isInteger(Number(ss[0]))) { // If the string can now be parsed as starting with an integer, then // leave all zeros stripped, otherwise start with a zero. return `0${ss}`; } return ss; } return s; } _compute_context_labels(ticks, resolution) { const { context } = this; assert(context != null); const context_labels = []; if (isString(context)) { for (const tick of ticks) { context_labels.push(_strftime(tick, context)); } } else { context_labels.push(...context.doFormat(ticks, { loc: 0 }, resolution)); } const which = this.context_which; const N = context_labels.length; for (let i = 0; i < context_labels.length; i++) { if ((which == "start" && i != 0) || (which == "end" && i != N - 1) || (which == "center" && i != Math.floor(N / 2))) { context_labels[i] = ""; } } return context_labels; } _build_full_labels(base_labels, context_labels) { const loc = this.context_location; const full_labels = []; if (context_labels.every(v => v === "")) { return base_labels; } for (let i = 0; i < base_labels.length; i++) { const label = base_labels[i]; const context = context_labels[i]; // In case of above and below blank strings are not trimmed in order to // keep the same visual format across all ticks. const full_label = (() => { switch (loc) { case "above": return `${context}\n${label}`; case "below": return `${label}\n${context}`; case "left": return context == "" ? label : `${context} ${label}`; case "right": return context == "" ? label : `${label} ${context}`; } })(); full_labels.push(full_label); } return full_labels; } _hide_repeating_labels(labels) { // For repeating labels only utilize the first entry if (labels.length <= 1) { return labels; } const labels_h = [labels[0]]; let index_first_entry = 0; for (let i = 1; i < labels.length; i++) { if (labels[index_first_entry] == labels[i]) { labels_h.push(""); } else { labels_h.push(labels[i]); index_first_entry = i; } } return labels_h; } } //# sourceMappingURL=datetime_tick_formatter.js.map