Skip to content

Instantly share code, notes, and snippets.

@thw0rted
Created December 13, 2018 13:05
Show Gist options
  • Save thw0rted/bcea45d0652a7f45f40390cb5cc25c8e to your computer and use it in GitHub Desktop.
Save thw0rted/bcea45d0652a7f45f40390cb5cc25c8e to your computer and use it in GitHub Desktop.
Cesium TimeIntervalColleciton that doesn't need individual instances of TimeInterval
import {
Event as CesiumEvent,
GregorianDate,
JulianDate,
RuntimeError,
TimeInterval,
TimeIntervalCollection,
} from "cesium";
export interface ConstructorOptions {
isStartIncluded?: boolean;
isStopIncluded?: boolean;
}
// Given a selected Interval, what parameter(s) should be sent to the service?
const DEFAULT_CALLBACK: TimeIntervalCollection.DataCallback =
x => ({time: TimeInterval.toIso8601(x, 0)});
// Some malformed WMS services return time bounds of `START/STOP/` with no
// duration specified; we need to pick something, so use this.
const DEFAULT_DURATION = new GregorianDate(0, 0, 0, 1); // 1 hour
/**
* This class implements a TimeIntervalCollection that doesn't actually create
* any TimeIntervals when constructed, but rather represents a WMS-style range,
* specified with start, stop, and period. The various find/get methods
* defined by TimeIntervalCollection perform on-demand creation of a new
* TimeInterval that matches the request criteria.
*/
export class ISO8601TimeIntervalCollection implements TimeIntervalCollection {
public readonly startMsec: number;
public readonly stopMsec: number;
public readonly periodMsec: number;
public readonly isStartIncluded: boolean;
public readonly isStopIncluded: boolean;
public readonly length: number;
// Immutable, never fired
public changedEvent: CesiumEvent = new CesiumEvent();
// Can't make an empty one
public isEmpty: boolean = false;
public constructor(
public readonly start: JulianDate,
public readonly stop: JulianDate,
public readonly period: GregorianDate,
private readonly dataCallback: TimeIntervalCollection.DataCallback = DEFAULT_CALLBACK,
opts: ConstructorOptions = {}
) {
this.isStartIncluded = !!opts.isStartIncluded;
this.isStopIncluded = !!opts.isStopIncluded;
this.startMsec = JulianDate.toDate(start).valueOf();
this.stopMsec = JulianDate.toDate(stop).valueOf();
const periodSec = ISO8601TimeIntervalCollection.durationToSeconds(this.period);
this.periodMsec = periodSec * 1000;
this.length = JulianDate.secondsDifference(this.stop, this.start) / periodSec;
}
static fromString(
str: string,
dataCallback: TimeIntervalCollection.DataCallback = DEFAULT_CALLBACK,
opts: ConstructorOptions = {}
): ISO8601TimeIntervalCollection {
const parts = str.split("/");
if (parts.length !== 3) {
throw new RuntimeError("Invalid time span specified: " + str);
}
const start = JulianDate.fromIso8601(parts[0]);
const stop = parseStop(parts[1]);
let period = ISO8601TimeIntervalCollection.stringToDuration(parts[2]);
if (!period) { period = DEFAULT_DURATION; }
return new ISO8601TimeIntervalCollection(start, stop, period, dataCallback, opts);
}
// Create an ISO8601 time span string from separate start, stop, period
// WORKAROUND: calling this "toString" breaks typescript, see GH #27276
static stringify(coll: ISO8601TimeIntervalCollection): string {
return JulianDate.toIso8601(coll.start) + "/" +
JulianDate.toIso8601(coll.stop) + "/" +
ISO8601TimeIntervalCollection.durationToString(coll.period);
}
// Reverse the above, turn a GregorianDate containing time values into an ISO8601 Duration string
static durationToString(duration: GregorianDate): string {
let ret = "P";
if (duration.year) { ret += duration.year + "Y"; }
if (duration.month) { ret += duration.month + "M"; }
if (duration.day) { ret += duration.day + "D"; }
let time = "";
if (duration.hour) { time += duration.hour + "H"; }
if (duration.minute) { time += duration.minute + "M"; }
if (duration.second) { time += duration.second + "S"; }
if (time) { return ret + "T" + time; }
else { return ret; }
}
// Total number of seconds in the supplied duration
static durationToSeconds(duration: GregorianDate): number {
return ((duration.year || 0) * (365 * 24 * 60 * 60)) +
((duration.month || 0) * (30 * 24 * 60 * 60)) +
((duration.day || 0) * (24 * 60 * 60)) +
((duration.hour || 0) * (60 * 60)) +
((duration.minute || 0) * 60) +
(duration.second || 0);
}
// Populate a GregorianDate with the passed number of seconds represented
// as year/month/day/hour/minute/second duration
static secondsToDuration(sec: number): GregorianDate {
if (sec <= 0) { return new GregorianDate(); }
const year = Math.floor(sec / (365 * 24 * 60 * 60));
sec -= year * 365 * 24 * 60 * 60;
const month = Math.floor(sec / (30 * 24 * 60 * 60));
sec -= month * 30 * 24 * 60 * 60;
const day = Math.floor(sec / (24 * 60 * 60));
sec -= day * 24 * 60 * 60;
const hour = Math.floor(sec / (60 * 60));
sec -= hour * 60 * 60;
const minute = Math.floor(sec / 60);
sec -= minute * 60;
return new GregorianDate(year, month, day, hour, minute, sec);
}
// Cribbed from Cesium TimeIntervalCollection's parseDuration
static stringToDuration(iso8601: string): GregorianDate | undefined {
if (!iso8601) { return; }
// Reset object
const result = new GregorianDate(0,0,0,0,0,0,0,false);
if (iso8601[0] === 'P') {
var matches = iso8601.match(durationRegex);
if (!matches) {
return;
}
if (matches[1]) { // Years
result.year = Number(matches[1].replace(',', '.'));
}
if (matches[2]) { // Months
result.month = Number(matches[2].replace(',', '.'));
}
if (matches[3]) { // Weeks
result.day = Number(matches[3].replace(',', '.')) * 7;
}
if (matches[4]) { // Days
result.day += Number(matches[4].replace(',', '.'));
}
if (matches[5]) { // Hours
result.hour = Number(matches[5].replace(',', '.'));
}
if (matches[6]) { // Weeks
result.minute = Number(matches[6].replace(',', '.'));
}
if (matches[7]) { // Seconds
var seconds = Number(matches[7].replace(',', '.'));
result.second = Math.floor(seconds);
result.millisecond = (seconds % 1) * 1000;
}
} else {
// They can technically specify the duration as a normal date with some caveats. Try our best to load it.
if (iso8601[iso8601.length - 1] !== 'Z') { // It's not a date, its a duration, so it always has to be UTC
iso8601 += 'Z';
}
JulianDate.toGregorianDate(JulianDate.fromIso8601(iso8601), result);
}
// A duration of 0 will cause an infinite loop, so just make sure something is non-zero
if (result.year || result.month || result.day || result.hour ||
result.minute || result.second || result.millisecond) {
return result;
}
}
// Returns true if this date is in our collection
contains(date: JulianDate): boolean { return this.indexOf(date) >= 0; }
// Returns true if the provided collection is equal to us
equals(right?: TimeIntervalCollection, dataComparer?: TimeInterval.DataComparer): boolean {
if (!(right instanceof ISO8601TimeIntervalCollection)) { return false; }
const us = JulianDate.fromGregorianDate(this.period);
const them = JulianDate.fromGregorianDate(right.period);
return right.start.equals(this.start) &&
right.stop.equals(this.stop) &&
us.equals(them);
}
// Get the data from a TimeInterval that contains the supplied date.
findDataForIntervalContainingDate(date: JulianDate): {} | undefined {
const found = this.findIntervalContainingDate(date);
if (!found) { return; }
return found.data;
}
// Return the first matching interval, if any.
findInterval(options?: TimeIntervalCollection.FindOptions): TimeInterval | undefined {
if (!options || !options.start) { return this.get(0); }
if (this.isAfterStop(options.start)) { return undefined; }
return this.get(this.indexOf(options.start));
}
// Get a TimeInterval that contains the specified date
findIntervalContainingDate(date: JulianDate): TimeInterval | undefined {
return this.findInterval({start: date});
}
/**
* Return the interval at the specified index, or undefined if no interval
* exists as that index.
* @param index
*/
get(index: number): TimeInterval | undefined {
if (index < 0 || index >= this.length) { return }
const periodSeconds = this.periodMsec / 1000;
const begin = this.start.clone();
begin.secondsOfDay += (index * periodSeconds);
const end = begin.clone();
end.secondsOfDay += periodSeconds;
const ret = new TimeInterval({
start: begin,
stop: end,
});
ret.data = this.dataCallback(ret, index);
return ret;
}
/**
* Figure out what interval this date would fall into. Per the spec, dates
* that don't fall into an interval return bitwise complement of the next
* interval that starts after the date.
* @param date
*/
indexOf(date: JulianDate): number {
if (this.isBeforeStart(date)) { return ~0; }
else if (this.isAfterStop(date)) { return ~this.length; }
return Math.floor(
JulianDate.secondsDifference(date, this.start) / (this.periodMsec / 1000)
);
}
// Return a TimeIntervalCollection representing the intersection of ours
// with the one passed as an argument.
intersect(
other: TimeIntervalCollection,
dataComparer: TimeInterval.DataComparer = (x,y) => x === y,
mergeCallback?: TimeInterval.MergeCallback
): TimeIntervalCollection {
if (other instanceof ISO8601TimeIntervalCollection) {
return this.intersect8601(other, mergeCallback);
}
// If it's not like us, then use our start/end to filter the owned intervals
const keptIntervals: TimeInterval[] = [];
for (let i=0; i < other.length; ++i) {
const interval = other.get(i);
// If the interval falls outside our range, don't include it
if (!interval ||
this.isBeforeStart(interval.start) ||
this.isAfterStop(interval.stop)
) { continue; }
// TODO: maybe use dataComparer? Seems like a poor fit because
// our intervals' data is unlikely to line up exactly with that of
// the passed intervals
keptIntervals.push(TimeInterval.clone(interval));
}
return new TimeIntervalCollection(keptIntervals);
}
// WMS-compliant "periodic interval" ("range") string, as start/end/duration
toString() { return ISO8601TimeIntervalCollection.stringify(this); }
///////////////////////////////////////////////////////////////////////////
// This collection is immutable so any calls to a method that would
// attempt to modify it should throw.
addInterval(interval: TimeInterval, dataComparer?: TimeInterval.DataComparer): void {
throw new Error("This TimeIntervalCollection is immutable.");
}
removeAll(): void {
throw new Error("This TimeIntervalCollection is immutable.");
}
removeInterval(interval: TimeInterval): void {
throw new Error("This TimeIntervalCollection is immutable.");
}
//
///////////////////////////////////////////////////////////////////////////
// Special handling for intersecting two 8601 collections, to avoid
// creating unnecessary additional intervals.
// Note that the returned collection will have a data callback that runs
// our data callback *and* the other collection's data callback, then
// passes both results to the supplied merge callback. If no merge
// callback is supplied, just return
private intersect8601(
other: ISO8601TimeIntervalCollection,
mergeCallback?: TimeInterval.MergeCallback
): ISO8601TimeIntervalCollection {
// Find the later start and the earlier end
let begin: JulianDate, end: JulianDate;
let includeStart: boolean, includeStop: boolean;
if (this.isBeforeStart(other.start)) {
begin = this.start;
includeStart = this.isStartIncluded;
} else {
begin = other.start;
includeStart = other.isStartIncluded;
}
if (this.isAfterStop(other.stop)) {
end = this.stop;
includeStop = this.isStopIncluded;
} else {
end = other.stop;
includeStop = other.isStopIncluded;
}
// See which period is the most granular
const us = JulianDate.fromGregorianDate(this.period);
const them = JulianDate.fromGregorianDate(other.period);
let finest: GregorianDate;
if (JulianDate.compare(us, them) >= 0) {
finest = this.period;
} else {
finest = other.period;
}
if (mergeCallback) {
// If we have a merge callback, the new collection's callback
// should call ours, theirs, then merge the two.
const cb: TimeIntervalCollection.DataCallback = (interval, index) => {
const ours = this.dataCallback(interval, index);
const theirs = other.dataCallback(interval, index);
return mergeCallback(ours, theirs);
};
return new ISO8601TimeIntervalCollection(begin, end, finest,
cb, {isStartIncluded: includeStart, isStopIncluded: includeStop});
} else {
return new ISO8601TimeIntervalCollection(begin, end, finest,
this.dataCallback, {isStartIncluded: includeStart, isStopIncluded: includeStop});
}
}
// Return true if the supplied date is before our range
private isBeforeStart(other: JulianDate) {
if (this.isStartIncluded) { return JulianDate.lessThan(other, this.start); }
else { return JulianDate.lessThanOrEquals(other, this.start); }
}
// Return true if the supplied date is after our range
private isAfterStop(other: JulianDate) {
if (this.isStopIncluded) { return JulianDate.greaterThan(other, this.stop); }
else { return JulianDate.greaterThanOrEquals(other, this.stop); }
}
}
// Like JulianDate.fromISO8601, except it should return the *latest* date that
// could still truncate to the supplied value, to single-second precision.
// E.g.: "2018-01-01T13" => Jan 1 2018, 13:59:59
function parseStop(str: string): JulianDate {
const ret = JulianDate.fromIso8601(str);
// String included YYYY-MM-DDTHH:MM:SS
if (str.length >= 4 + 3 + 3 + 3 + 3 + 3) { return ret; }
// String did not include seconds
JulianDate.addSeconds(ret, 59, ret);
if (str.length >= 4 + 3 + 3 + 3 + 3) { return ret; }
// String did not include minutes
JulianDate.addMinutes(ret, 59, ret);
if (str.length >= 4 + 3 + 3 + 3) { return ret; }
// String did not include hours
JulianDate.addHours(ret, 23, ret);
return ret;
}
// Copied from Cesium source
const durationRegex = /P(?:([\d.,]+)Y)?(?:([\d.,]+)M)?(?:([\d.,]+)W)?(?:([\d.,]+)D)?(?:T(?:([\d.,]+)H)?(?:([\d.,]+)M)?(?:([\d.,]+)S)?)?/;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment