time.js 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286
  1. import {bisector, max, pairs, timeFormat, utcFormat} from "d3";
  2. import {utcSecond, utcMinute, utcHour, unixDay, utcWeek, utcMonth, utcYear} from "d3";
  3. import {utcMonday, utcTuesday, utcWednesday, utcThursday, utcFriday, utcSaturday, utcSunday} from "d3";
  4. import {timeSecond, timeMinute, timeHour, timeDay, timeWeek, timeMonth, timeYear} from "d3";
  5. import {timeMonday, timeTuesday, timeWednesday, timeThursday, timeFriday, timeSaturday, timeSunday} from "d3";
  6. import {orderof} from "./order.js";
  7. const durationSecond = 1000;
  8. const durationMinute = durationSecond * 60;
  9. const durationHour = durationMinute * 60;
  10. const durationDay = durationHour * 24;
  11. const durationWeek = durationDay * 7;
  12. const durationMonth = durationDay * 30;
  13. const durationYear = durationDay * 365;
  14. // See https://github.com/d3/d3-time/blob/9e8dc940f38f78d7588aad68a54a25b1f0c2d97b/src/ticks.js#L14-L33
  15. const tickIntervals = [
  16. ["millisecond", 1],
  17. ["2 milliseconds", 2],
  18. ["5 milliseconds", 5],
  19. ["10 milliseconds", 10],
  20. ["20 milliseconds", 20],
  21. ["50 milliseconds", 50],
  22. ["100 milliseconds", 100],
  23. ["200 milliseconds", 200],
  24. ["500 milliseconds", 500],
  25. ["second", durationSecond],
  26. ["5 seconds", 5 * durationSecond],
  27. ["15 seconds", 15 * durationSecond],
  28. ["30 seconds", 30 * durationSecond],
  29. ["minute", durationMinute],
  30. ["5 minutes", 5 * durationMinute],
  31. ["15 minutes", 15 * durationMinute],
  32. ["30 minutes", 30 * durationMinute],
  33. ["hour", durationHour],
  34. ["3 hours", 3 * durationHour],
  35. ["6 hours", 6 * durationHour],
  36. ["12 hours", 12 * durationHour],
  37. ["day", durationDay],
  38. ["2 days", 2 * durationDay],
  39. ["week", durationWeek],
  40. ["2 weeks", 2 * durationWeek], // https://github.com/d3/d3-time/issues/46
  41. ["month", durationMonth],
  42. ["3 months", 3 * durationMonth],
  43. ["6 months", 6 * durationMonth], // https://github.com/d3/d3-time/issues/46
  44. ["year", durationYear],
  45. ["2 years", 2 * durationYear],
  46. ["5 years", 5 * durationYear],
  47. ["10 years", 10 * durationYear],
  48. ["20 years", 20 * durationYear],
  49. ["50 years", 50 * durationYear],
  50. ["100 years", 100 * durationYear] // TODO generalize to longer time scales
  51. ];
  52. const durations = new Map([
  53. ["second", durationSecond],
  54. ["minute", durationMinute],
  55. ["hour", durationHour],
  56. ["day", durationDay],
  57. ["monday", durationWeek],
  58. ["tuesday", durationWeek],
  59. ["wednesday", durationWeek],
  60. ["thursday", durationWeek],
  61. ["friday", durationWeek],
  62. ["saturday", durationWeek],
  63. ["sunday", durationWeek],
  64. ["week", durationWeek],
  65. ["month", durationMonth],
  66. ["year", durationYear]
  67. ]);
  68. const timeIntervals = new Map([
  69. ["second", timeSecond],
  70. ["minute", timeMinute],
  71. ["hour", timeHour],
  72. ["day", timeDay], // https://github.com/d3/d3-time/issues/62
  73. ["monday", timeMonday],
  74. ["tuesday", timeTuesday],
  75. ["wednesday", timeWednesday],
  76. ["thursday", timeThursday],
  77. ["friday", timeFriday],
  78. ["saturday", timeSaturday],
  79. ["sunday", timeSunday],
  80. ["week", timeWeek],
  81. ["month", timeMonth],
  82. ["year", timeYear]
  83. ]);
  84. const utcIntervals = new Map([
  85. ["second", utcSecond],
  86. ["minute", utcMinute],
  87. ["hour", utcHour],
  88. ["day", unixDay],
  89. ["monday", utcMonday],
  90. ["tuesday", utcTuesday],
  91. ["wednesday", utcWednesday],
  92. ["thursday", utcThursday],
  93. ["friday", utcFriday],
  94. ["saturday", utcSaturday],
  95. ["sunday", utcSunday],
  96. ["week", utcWeek],
  97. ["month", utcMonth],
  98. ["year", utcYear]
  99. ]);
  100. // These hidden fields describe standard intervals so that we can, for example,
  101. // generalize a scale’s time interval to a larger ticks time interval to reduce
  102. // the number of displayed ticks. TODO We could instead allow the interval
  103. // implementation to expose a “generalize” method that returns a larger, aligned
  104. // interval; that would allow us to move this logic to D3, and allow
  105. // generalization even when a custom interval is provided.
  106. export const intervalDuration = Symbol("intervalDuration");
  107. export const intervalType = Symbol("intervalType");
  108. // We greedily mutate D3’s standard intervals on load so that the hidden fields
  109. // are available even if specified as e.g. d3.utcMonth instead of "month".
  110. for (const [name, interval] of timeIntervals) {
  111. interval[intervalDuration] = durations.get(name);
  112. interval[intervalType] = "time";
  113. }
  114. for (const [name, interval] of utcIntervals) {
  115. interval[intervalDuration] = durations.get(name);
  116. interval[intervalType] = "utc";
  117. }
  118. const utcFormatIntervals = [
  119. ["year", utcYear, "utc"],
  120. ["month", utcMonth, "utc"],
  121. ["day", unixDay, "utc", 6 * durationMonth],
  122. ["hour", utcHour, "utc", 3 * durationDay],
  123. ["minute", utcMinute, "utc", 6 * durationHour],
  124. ["second", utcSecond, "utc", 30 * durationMinute]
  125. ];
  126. const timeFormatIntervals = [
  127. ["year", timeYear, "time"],
  128. ["month", timeMonth, "time"],
  129. ["day", timeDay, "time", 6 * durationMonth],
  130. ["hour", timeHour, "time", 3 * durationDay],
  131. ["minute", timeMinute, "time", 6 * durationHour],
  132. ["second", timeSecond, "time", 30 * durationMinute]
  133. ];
  134. // An interleaved array of UTC and local time intervals, in descending order
  135. // from largest to smallest, used to determine the most specific standard time
  136. // format for a given array of dates. This is a subset of the tick intervals
  137. // listed above; we only need the breakpoints where the format changes.
  138. const formatIntervals = [
  139. utcFormatIntervals[0],
  140. timeFormatIntervals[0],
  141. utcFormatIntervals[1],
  142. timeFormatIntervals[1],
  143. utcFormatIntervals[2],
  144. timeFormatIntervals[2],
  145. // Below day, local time typically has an hourly offset from UTC and hence the
  146. // two are aligned and indistinguishable; therefore, we only consider UTC, and
  147. // we don’t consider these if the domain only has a single value.
  148. ...utcFormatIntervals.slice(3)
  149. ];
  150. export function parseTimeInterval(input) {
  151. let name = `${input}`.toLowerCase();
  152. if (name.endsWith("s")) name = name.slice(0, -1); // drop plural
  153. let period = 1;
  154. const match = /^(?:(\d+)\s+)/.exec(name);
  155. if (match) {
  156. name = name.slice(match[0].length);
  157. period = +match[1];
  158. }
  159. switch (name) {
  160. case "quarter":
  161. name = "month";
  162. period *= 3;
  163. break;
  164. case "half":
  165. name = "month";
  166. period *= 6;
  167. break;
  168. }
  169. let interval = utcIntervals.get(name);
  170. if (!interval) throw new Error(`unknown interval: ${input}`);
  171. if (period > 1 && !interval.every) throw new Error(`non-periodic interval: ${name}`);
  172. return [name, period];
  173. }
  174. export function timeInterval(input) {
  175. return asInterval(parseTimeInterval(input), "time");
  176. }
  177. export function utcInterval(input) {
  178. return asInterval(parseTimeInterval(input), "utc");
  179. }
  180. function asInterval([name, period], type) {
  181. let interval = (type === "time" ? timeIntervals : utcIntervals).get(name);
  182. if (period > 1) {
  183. interval = interval.every(period);
  184. interval[intervalDuration] = durations.get(name) * period;
  185. interval[intervalType] = type;
  186. }
  187. return interval;
  188. }
  189. // If the given interval is a standard time interval, we may be able to promote
  190. // it a larger aligned time interval, rather than showing every nth tick.
  191. export function generalizeTimeInterval(interval, n) {
  192. if (!(n > 1)) return; // no need to generalize
  193. const duration = interval[intervalDuration];
  194. if (!tickIntervals.some(([, d]) => d === duration)) return; // nonstandard or unknown interval
  195. if (duration % durationDay === 0 && durationDay < duration && duration < durationMonth) return; // not generalizable
  196. const [i] = tickIntervals[bisector(([, step]) => Math.log(step)).center(tickIntervals, Math.log(duration * n))];
  197. return (interval[intervalType] === "time" ? timeInterval : utcInterval)(i);
  198. }
  199. function formatTimeInterval(name, type, anchor) {
  200. const format = type === "time" ? timeFormat : utcFormat;
  201. // For tips and legends, use a format that doesn’t require context.
  202. if (anchor == null) {
  203. return format(
  204. name === "year"
  205. ? "%Y"
  206. : name === "month"
  207. ? "%Y-%m"
  208. : name === "day"
  209. ? "%Y-%m-%d"
  210. : name === "hour" || name === "minute"
  211. ? "%Y-%m-%dT%H:%M"
  212. : name === "second"
  213. ? "%Y-%m-%dT%H:%M:%S"
  214. : "%Y-%m-%dT%H:%M:%S.%L"
  215. );
  216. }
  217. // Otherwise, assume that this is for axis ticks.
  218. const template = getTimeTemplate(anchor);
  219. switch (name) {
  220. case "millisecond":
  221. return formatConditional(format(".%L"), format(":%M:%S"), template);
  222. case "second":
  223. return formatConditional(format(":%S"), format("%-I:%M"), template);
  224. case "minute":
  225. return formatConditional(format("%-I:%M"), format("%p"), template);
  226. case "hour":
  227. return formatConditional(format("%-I %p"), format("%b %-d"), template);
  228. case "day":
  229. return formatConditional(format("%-d"), format("%b"), template);
  230. case "month":
  231. return formatConditional(format("%b"), format("%Y"), template);
  232. case "year":
  233. return format("%Y");
  234. }
  235. throw new Error("unable to format time ticks");
  236. }
  237. function getTimeTemplate(anchor) {
  238. return anchor === "left" || anchor === "right"
  239. ? (f1, f2) => `\n${f1}\n${f2}` // extra newline to keep f1 centered
  240. : anchor === "top"
  241. ? (f1, f2) => `${f2}\n${f1}`
  242. : (f1, f2) => `${f1}\n${f2}`;
  243. }
  244. function getFormatIntervals(type) {
  245. return type === "time" ? timeFormatIntervals : type === "utc" ? utcFormatIntervals : formatIntervals;
  246. }
  247. // Given an array of dates, returns the largest compatible standard time
  248. // interval. If no standard interval is compatible (other than milliseconds,
  249. // which is universally compatible), returns undefined.
  250. export function inferTimeFormat(type, dates, anchor) {
  251. const step = max(pairs(dates, (a, b) => Math.abs(b - a))); // maybe undefined!
  252. if (step < 1000) return formatTimeInterval("millisecond", "utc", anchor);
  253. for (const [name, interval, intervalType, maxStep] of getFormatIntervals(type)) {
  254. if (step > maxStep) break; // e.g., 52 weeks
  255. if (name === "hour" && !step) break; // e.g., domain with a single date
  256. if (dates.every((d) => interval.floor(d) >= d)) return formatTimeInterval(name, intervalType, anchor);
  257. }
  258. }
  259. function formatConditional(format1, format2, template) {
  260. return (x, i, X) => {
  261. const f1 = format1(x, i); // always shown
  262. const f2 = format2(x, i); // only shown if different
  263. const j = i - orderof(X); // detect reversed domains
  264. return i !== j && X[j] !== undefined && f2 === format2(X[j], j) ? f1 : template(f1, f2);
  265. };
  266. }