UNPKG

@d3fc/d3fc-discontinuous-scale

Version:

A D3 scale that supports domains that are not continuous, allowing 'breaks' in the scale, for example skipping weekends for a date scale.

202 lines (163 loc) 9.58 kB
import { timeDay, timeMillisecond } from 'd3-time'; import { tradingDay } from './skipWeeklyPattern/tradingDay'; import { dayBoundary, millisPerDay } from './skipWeeklyPattern/constants'; import { dateTimeUtility } from './skipWeeklyPattern/dateTimeUtility'; export const localDateTimeUtility = dateTimeUtility( (date, hh, mm, ss, ms) => new Date(date.getFullYear(), date.getMonth(), date.getDate(), hh, mm, ss, ms), date => date.getDay(), date => [date.getHours(), date.getMinutes(), date.getSeconds(), date.getMilliseconds()], timeDay, timeMillisecond ); /** * Discontinuity provider implemenation that works with 'non-trading' periods during a trading day * @typedef { Object } WeeklyPatternDiscontinuityProvider * @property { function(Date): Date } clampUp - When given a value, if it falls within a discontinuity (i.e. an excluded domain range) it should be shifted forwards to the discontinuity boundary. Otherwise, it should be returned unchanged. * @property { function(Date): Date } clampDown - When given a value, if it falls within a discontinuity it should be shifted backwards to the discontinuity boundary. Otherwise, it should be returned unchanged. * @property { function(Date, Date): number } distance - When given a pair of values, this function returns the distance between the, in domain units, minus any discontinuities. discontinuities. * @property { function(Date, number): Date } offset - When given a value and an offset, the value should be advanced by the offset value, skipping any discontinuities, to return the final value. * @property { function(): WeeklyPatternDiscontinuityProvider } copy - Creates a copy of the discontinuity provider. */ /** * Creates WeeklyPatternDiscontinuityProvider * @param {Object} nonTradingPattern - contains raw 'non-trading' time ranges for each day of the week * @param {DateTimeUtility} dateTimeUtility - uses local or utc dates * @returns { WeeklyPatternDiscontinuityProvider } WeeklyPatternDiscontinuityProvider */ export const base = (nonTradingPattern, dateTimeUtility) => { const getDayPatternOrDefault = (day) => nonTradingPattern[day] === undefined ? [] : nonTradingPattern[day]; const tradingDays = [ tradingDay(getDayPatternOrDefault('Sunday'), dateTimeUtility), tradingDay(getDayPatternOrDefault('Monday'), dateTimeUtility), tradingDay(getDayPatternOrDefault('Tuesday'), dateTimeUtility), tradingDay(getDayPatternOrDefault('Wednesday'), dateTimeUtility), tradingDay(getDayPatternOrDefault('Thursday'), dateTimeUtility), tradingDay(getDayPatternOrDefault('Friday'), dateTimeUtility), tradingDay(getDayPatternOrDefault('Saturday'), dateTimeUtility)]; const totalTradingWeekMilliseconds = tradingDays.reduce((total, tradingDay) => total + tradingDay.totalTradingTimeInMiliseconds, 0); if (totalTradingWeekMilliseconds === 0) { throw 'Trading pattern must yield at least 1 ms of trading time'; } const instance = { tradingDays, totalTradingWeekMilliseconds }; /** * When given a value falls within a discontinuity (i.e. an excluded domain range) it should be shifted forwards to the discontinuity boundary. * Otherwise, it should be returns unchanged. * @param {Date} date - date to clamp up * @returns {Date} */ instance.clampUp = (date) => { const tradingDay = tradingDays[dateTimeUtility.getDay(date)]; for (const range of tradingDay.nonTradingTimeRanges) { if (range.isInRange(date)) { return range.endTime === dayBoundary ? instance.clampUp(dateTimeUtility.getStartOfNextDay(date)) : dateTimeUtility.setTime(date, range.endTime); } } return date; }; /** * When given a value, if it falls within a discontinuity it should be shifted backwards to the discontinuity boundary. Otherwise, it should be returned unchanged. * @param {Date} date - date to clamp down * @returns {Date} */ instance.clampDown = (date) => { const tradingDay = tradingDays[dateTimeUtility.getDay(date)]; for (const range of tradingDay.nonTradingTimeRanges) { if (range.isInRange(date)) { return range.startTime === dayBoundary ? instance.clampDown(dateTimeUtility.getEndOfPreviousDay(date)) : dateTimeUtility.setTime(date, range.startTime, -1); } } return date; }; /** * When given a pair of values, this function returns the distance between the, in domain units, minus any discontinuities. discontinuities. * @param {Date} startDate * @param {Date} endDate * @returns {number} - the number of milliseconds between the dates */ instance.distance = (startDate, endDate) => { if (startDate.getTime() === endDate.getTime()) { return 0; } let [start, end, factor] = startDate <= endDate ? [startDate, endDate, 1] : [endDate, startDate, -1]; // same day distance if (dateTimeUtility.dayInterval(start).getTime() === dateTimeUtility.dayInterval(end).getTime()) { return instance.tradingDays[dateTimeUtility.getDay(start)].totalTradingMillisecondsBetween(start, end); } // combine any trading time left in the day after startDate // and any trading time from midnight up until the endDate let total = instance.tradingDays[dateTimeUtility.getDay(start)].totalTradingMillisecondsBetween(start, dateTimeUtility.dayInterval.offset(dateTimeUtility.dayInterval(start), 1)) + instance.tradingDays[dateTimeUtility.getDay(end)].totalTradingMillisecondsBetween(dateTimeUtility.dayInterval(end), end); // startDate and endDate are consecutive days if (dateTimeUtility.dayInterval.count(start, end) === 1) { return total; } // move the start date to following day start = dateTimeUtility.dayInterval.offset(dateTimeUtility.dayInterval(start), 1); // floor endDate to remove 'time component' end = dateTimeUtility.dayInterval(end); return factor * dateTimeUtility.dayInterval.range(start, end) .reduce((runningTotal, currentDay, currentIndex, arr) => { const nextDay = currentIndex < arr.length - 1 ? arr[currentIndex + 1] : dateTimeUtility.dayInterval.offset(currentDay, 1); const isDstBoundary = (nextDay - currentDay) !== millisPerDay; const tradingDay = instance.tradingDays[dateTimeUtility.getDay(currentDay)]; return runningTotal += isDstBoundary ? tradingDay.totalTradingMillisecondsBetween(currentDay, nextDay) : tradingDay.totalTradingTimeInMiliseconds; }, total); }; /** * When given a value and an offset in milliseconds, the value should be advanced by the offset value, skipping any discontinuities, to return the final value. * @param {Date} date * @param {number} ms */ instance.offset = (date, ms) => { date = ms >= 0 ? instance.clampUp(date) : instance.clampDown(date); const isDstBoundary = (d) => (dateTimeUtility.dayInterval.offset(d) - dateTimeUtility.dayInterval(d)) !== millisPerDay; const moveToDayBoundary = (tradingDay, date, ms) => { if (ms < 0) { const dateFloor = dateTimeUtility.dayInterval(date); const distanceToStartOfDay = tradingDay.totalTradingMillisecondsBetween(dateFloor, date); return Math.abs(ms) <= distanceToStartOfDay ? tradingDay.offset(date, ms) : [instance.clampDown(dateTimeUtility.msInterval.offset(dateFloor, -1)), ms + distanceToStartOfDay + 1]; } else { const nextDate = dateTimeUtility.getStartOfNextDay(date); const distanceToDayBoundary = tradingDay.totalTradingMillisecondsBetween(date, nextDate); return ms < distanceToDayBoundary ? tradingDay.offset(date, ms) : [instance.clampUp(nextDate), ms - distanceToDayBoundary]; } }; if (ms === 0) return date; const moveDateDelegate = ms < 0 ? (date, remainingMs, tradingDayMs) => [instance.clampDown(dateTimeUtility.dayInterval.offset(date, -1)), remainingMs + tradingDayMs] : (date, remainingMs, tradingDayMs) => [instance.clampUp(dateTimeUtility.dayInterval.offset(date)), remainingMs - tradingDayMs]; let tradingDay = instance.tradingDays[dateTimeUtility.getDay(date)]; [date, ms] = moveToDayBoundary(tradingDay, date, ms); while (ms !== 0) { tradingDay = instance.tradingDays[dateTimeUtility.getDay(date)]; if (isDstBoundary(date)) { [date, ms] = moveToDayBoundary(tradingDay, date, ms); } else { [date, ms] = Math.abs(ms) >= tradingDay.totalTradingTimeInMiliseconds ? moveDateDelegate(date, ms, tradingDay.totalTradingTimeInMiliseconds) : moveToDayBoundary(tradingDay, date, ms); } } return date; }; instance.copy = () => instance; return instance; }; export default (nonTradingHoursPattern) => base(nonTradingHoursPattern, localDateTimeUtility);