diff --git a/config.js b/config.js
index ff1899a..c610c88 100644
--- a/config.js
+++ b/config.js
@@ -38,8 +38,25 @@ module.exports = {
{ label: "Zoom to 1 month", duration: 31 * one_day },
{ label: "Zoom to 3 months", duration: 3 * 31 * one_day },
],
- range: [new Date(Date.now() - 31 * (60 * 60 * 1000 * 24)), new Date()],
- // rangeLimits: []
+ range: {
+ /**
+ * Initial date range shown on map load.
+ * Use [start, end] (strings in ISO 8601 format) for a fixed range.
+ * Use undefined for a dynamic initial range based on the browser time.
+ */
+ initial: undefined,
+ /** The number of days to show when using a dynamic initial range */
+ initialDaysShown: 31,
+ limits: {
+ /** Required. The lower bound of the range that can be accessed on the map. (ISO 8601) */
+ lower: "2022-02-01T00:00:00.000Z",
+ /**
+ * The upper bound of the range that can be accessed on the map.
+ * Defaults to current browser time if undefined.
+ */
+ upper: undefined,
+ },
+ },
},
intro: [
'
',
diff --git a/package-lock.json b/package-lock.json
index b3f72fa..f3e64bf 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -96,6 +96,7 @@
"devDependencies": {
"@babel/highlight": "^7.14.5",
"ava": "1.0.0-beta.8",
+ "jest-date-mock": "^1.0.8",
"mocha": "^5.2.0",
"node-sass": "^6.0",
"redux-devtools": "^3.4.0"
@@ -13365,6 +13366,12 @@
"node": ">=8"
}
},
+ "node_modules/jest-date-mock": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/jest-date-mock/-/jest-date-mock-1.0.8.tgz",
+ "integrity": "sha512-0Lyp+z9xvuNmLbK+5N6FOhSiBeux05Lp5bbveFBmYo40Aggl2wwxFoIrZ+rOWC8nDNcLeBoDd2miQdEDSf3iQw==",
+ "dev": true
+ },
"node_modules/jest-diff": {
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-27.5.1.tgz",
@@ -36604,6 +36611,12 @@
}
}
},
+ "jest-date-mock": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/jest-date-mock/-/jest-date-mock-1.0.8.tgz",
+ "integrity": "sha512-0Lyp+z9xvuNmLbK+5N6FOhSiBeux05Lp5bbveFBmYo40Aggl2wwxFoIrZ+rOWC8nDNcLeBoDd2miQdEDSf3iQw==",
+ "dev": true
+ },
"jest-diff": {
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-27.5.1.tgz",
diff --git a/package.json b/package.json
index 6495aae..c9e170a 100644
--- a/package.json
+++ b/package.json
@@ -103,6 +103,7 @@
"devDependencies": {
"@babel/highlight": "^7.14.5",
"ava": "1.0.0-beta.8",
+ "jest-date-mock": "^1.0.8",
"mocha": "^5.2.0",
"node-sass": "^6.0",
"redux-devtools": "^3.4.0"
diff --git a/src/components/Layout.js b/src/components/Layout.js
index 7b9398c..0f3e358 100644
--- a/src/components/Layout.js
+++ b/src/components/Layout.js
@@ -85,7 +85,6 @@ class Dashboard extends React.Component {
matchedEvents.push(events[idx]);
}
-
// check events before
let ptr = idx - 1;
while (
@@ -98,7 +97,6 @@ class Dashboard extends React.Component {
ptr -= 1;
}
-
// check events after
ptr = idx + 1;
while (
@@ -279,7 +277,7 @@ class Dashboard extends React.Component {
}
render() {
- const { actions, app, domain, features } = this.props;
+ const { actions, app, domain, timeline, features } = this.props;
const dateHeight = 80;
const padding = 2;
const checkMobile = isMobileOnly || window.innerWidth < 600;
@@ -290,14 +288,14 @@ class Dashboard extends React.Component {
width: checkMobile
? "100vw"
: window.innerWidth > 768
- ? "60vw"
- : "calc(100vw - var(--toolbar-width))",
+ ? "60vw"
+ : "calc(100vw - var(--toolbar-width))",
maxWidth: checkMobile ? "100vw" : 600,
maxHeight: checkMobile
? "100vh"
: window.innerHeight > 768
- ? `calc(100vh - ${app.timeline.dimensions.height}px - ${dateHeight}px)`
- : "100vh",
+ ? `calc(100vh - ${timeline.dimensions.height}px - ${dateHeight}px)`
+ : "100vh",
left: checkMobile ? padding : "var(--toolbar-width)",
top: 0,
overflowY: "scroll",
@@ -344,7 +342,7 @@ class Dashboard extends React.Component {
/>
)}
null
@@ -357,9 +355,9 @@ class Dashboard extends React.Component {
narrative={
app.associations.narrative
? {
- ...app.associations.narrative,
- current: this.props.narrativeIdx,
- }
+ ...app.associations.narrative,
+ current: this.props.narrativeIdx,
+ }
: null
}
methods={{
@@ -427,6 +425,9 @@ function mapDispatchToProps(dispatch) {
export default connect(
(state) => ({
...state,
+ timeline: {
+ dimensions: selectors.selectDimensions(state),
+ },
narrativeIdx: selectors.selectNarrativeIdx(state),
narratives: selectors.selectNarratives(state),
selected: selectors.selectSelected(state),
diff --git a/src/components/time/Timeline.js b/src/components/time/Timeline.js
index 4eda796..887cfc8 100644
--- a/src/components/time/Timeline.js
+++ b/src/components/time/Timeline.js
@@ -34,7 +34,7 @@ class Timeline extends React.Component {
dims: props.dimensions,
scaleX: null,
scaleY: null,
- timerange: [null, null], // two datetimes
+ timerange: [null, null], // two Dates
dragPos0: null,
transitionDuration: 300,
};
@@ -47,7 +47,7 @@ class Timeline extends React.Component {
componentWillReceiveProps(nextProps) {
if (hash(nextProps) !== hash(this.props)) {
this.setState({
- timerange: nextProps.app.timeline.range,
+ timerange: nextProps.timeline.range,
scaleX: this.makeScaleX(),
});
}
@@ -219,7 +219,7 @@ class Timeline extends React.Component {
this.state.scaleX.domain()[0],
extent / 2
);
- const { rangeLimits } = this.props.app.timeline;
+ const { rangeLimits } = this.props.timeline;
let newDomain0 = d3.timeMinute.offset(newCentralTime, -zoom.duration / 2);
let newDomainF = d3.timeMinute.offset(newCentralTime, zoom.duration / 2);
@@ -278,7 +278,7 @@ class Timeline extends React.Component {
const dragNow = this.state.scaleX.invert(d3.event.x).getTime();
const timeShift = (drag0 - dragNow) / 1000;
- const { range, rangeLimits } = this.props.app.timeline;
+ const { range, rangeLimits } = this.props.timeline;
let newDomain0 = d3.timeSecond.offset(range[0], timeShift);
let newDomainF = d3.timeSecond.offset(range[1], timeShift);
@@ -361,7 +361,7 @@ class Timeline extends React.Component {
}
render() {
- const { isNarrative, app, domain } = this.props;
+ const { isNarrative, app, timeline, domain } = this.props;
let classes = `timeline-wrapper ${this.state.isFolded ? " folded" : ""}`;
classes += app.narrative !== null ? " narrative-mode" : "";
@@ -405,7 +405,7 @@ class Timeline extends React.Component {
- {app.timeline.dimensions.ticks === 1 && (
+ {timeline.dimensions.ticks === 1 && (
{
@@ -442,7 +442,7 @@ class Timeline extends React.Component {
)}
@@ -504,10 +504,16 @@ function mapStateToProps(state) {
app: {
selected: state.app.selected,
language: state.app.language,
- timeline: state.app.timeline,
narrative: state.app.associations.narrative,
coloringSet: state.app.associations.coloringSet,
},
+ timeline: {
+ zoomLevels: state.app.timeline.zoomLevels,
+ dimensions: selectors.selectDimensions(state),
+ ticks: state.app.timeline.ticks,
+ range: selectors.selectTimeRange(state),
+ rangeLimits: selectors.selectTimeRangeLimits(state),
+ },
ui: {
dom: state.ui.dom,
styles: state.ui.style.selectedEvents,
diff --git a/src/reducers/__tests__/index.spec.js b/src/reducers/__tests__/index.spec.js
new file mode 100644
index 0000000..b007d73
--- /dev/null
+++ b/src/reducers/__tests__/index.spec.js
@@ -0,0 +1,16 @@
+import { updateTimeRange } from "../../actions";
+import initial from "../../store/initial.js";
+import reduce from "../app";
+
+describe("app reducer", () => {
+ it("can update the selected time range", () => {
+ const result = reduce(
+ initial.app,
+ updateTimeRange(["2022-01-01T00:00:00.000Z", "2022-03-01T00:30:00.000Z"])
+ );
+ expect(result.timeline.range.current).toEqual([
+ "2022-01-01T00:00:00.000Z",
+ "2022-03-01T00:30:00.000Z",
+ ]);
+ });
+});
diff --git a/src/reducers/app.js b/src/reducers/app.js
index be85652..41cda86 100644
--- a/src/reducers/app.js
+++ b/src/reducers/app.js
@@ -1,6 +1,7 @@
import initial from "../store/initial.js";
import { ASSOCIATION_MODES } from "../common/constants";
import { toggleFlagAC } from "../common/utilities";
+import * as selectors from "../selectors";
import {
UPDATE_HIGHLIGHTED,
@@ -68,17 +69,14 @@ function updateColoringSet(appState, action) {
}
function updateNarrative(appState, action) {
- let minTime = appState.timeline.range[0];
- let maxTime = appState.timeline.range[1];
+ let [minTime, maxTime] = selectors.selectTimeRange(appState);
const cornerBound0 = [180, 180];
const cornerBound1 = [-180, -180];
// Compute narrative time range and map bounds
if (action.narrative) {
- // Forced to comment out min and max time changes, not sure why?
- minTime = appState.timeline.rangeLimits[0];
- maxTime = appState.timeline.rangeLimits[1];
+ [minTime, maxTime] = selectors.selectTimeRangeLimits(appState);
// Find max and mins coordinates of narrative events
action.narrative.steps.forEach((step) => {
@@ -119,6 +117,7 @@ function updateNarrative(appState, action) {
minTime = minTime - Math.abs((maxTime - minTime) / 10);
maxTime = maxTime + Math.abs((maxTime - minTime) / 10);
}
+
return {
...appState,
associations: {
@@ -131,7 +130,10 @@ function updateNarrative(appState, action) {
},
timeline: {
...appState.timeline,
- range: [minTime, maxTime],
+ range: {
+ ...appState.timeline.range,
+ current: [minTime, maxTime],
+ },
},
};
}
@@ -200,7 +202,13 @@ function updateTimeRange(appState, action) {
...appState,
timeline: {
...appState.timeline,
- range: action.timerange,
+ range: {
+ ...appState.timeline.range,
+ current: [
+ new Date(action.timerange[0]).toISOString(),
+ new Date(action.timerange[1]).toISOString(),
+ ],
+ },
},
};
}
diff --git a/src/selectors/__tests__/timeline.spec.js b/src/selectors/__tests__/timeline.spec.js
new file mode 100644
index 0000000..1d690ba
--- /dev/null
+++ b/src/selectors/__tests__/timeline.spec.js
@@ -0,0 +1,142 @@
+import initial from "../../store/initial";
+import { advanceTo, clear } from "jest-date-mock";
+import * as selectors from "../";
+
+describe("timeline selectors", () => {
+ beforeAll(() => {
+ advanceTo(new Date("2022-02-01T00:00:00.000Z"));
+ });
+
+ afterAll(() => {
+ clear();
+ });
+
+ const state = (range) => ({
+ ...initial,
+ app: {
+ ...initial.app,
+ timeline: {
+ ...initial.app.timeline,
+ range,
+ },
+ },
+ });
+
+ describe("selectTimeRange", () => {
+ it("returns the currently selected time range", () => {
+ expect(
+ selectors.selectTimeRange(
+ state({
+ initial: ["2020-03-03T00:00:00.000Z", "2024-01-04T00:00:00.000Z"],
+ current: ["2021-03-03T00:00:00.000Z", "2023-01-04T00:00:00.000Z"],
+ initialDaysShown: 31,
+ limits: {
+ lower: "2022-02-01T00:00:00.000Z",
+ upper: undefined,
+ },
+ })
+ )
+ ).toEqual([
+ new Date("2021-03-03T00:00:00.000Z"),
+ new Date("2023-01-04T00:00:00.000Z"),
+ ]);
+ });
+
+ it("falls back to a fixed default time range when no current range is set", () => {
+ expect(
+ selectors.selectTimeRange(
+ state({
+ current: undefined,
+ initial: ["2020-03-03T00:00:00.000Z", "2024-01-04T00:00:00.000Z"],
+ initialDaysShown: 31,
+ limits: {
+ lower: "2022-02-01T00:00:00.000Z",
+ upper: undefined,
+ },
+ })
+ )
+ ).toEqual([
+ new Date("2020-03-03T00:00:00.000Z"),
+ new Date("2024-01-04T00:00:00.000Z"),
+ ]);
+ });
+
+ it("falls back to a dynamic default time range when no fixed default range or current range is set", () => {
+ expect(
+ selectors.selectTimeRange(
+ state({
+ current: undefined,
+ initial: undefined,
+ initialDaysShown: 31,
+ limits: {
+ lower: "2022-02-01T00:00:00.000Z",
+ upper: undefined,
+ },
+ })
+ )
+ ).toEqual([
+ new Date("2022-01-01T00:00:00.000Z"),
+ new Date("2022-02-01T00:00:00.000Z"),
+ ]);
+ });
+
+ it("falls back to a dynamic default if an invalid default range is passed in", () => {
+ expect(
+ selectors.selectTimeRange(
+ state({
+ current: undefined,
+ initial: "some garbage data",
+ initialDaysShown: 31,
+ limits: {
+ lower: "2022-02-01T00:00:00.000Z",
+ upper: undefined,
+ },
+ })
+ )
+ ).toEqual([
+ new Date("2022-01-01T00:00:00.000Z"),
+ new Date("2022-02-01T00:00:00.000Z"),
+ ]);
+ });
+ });
+
+ describe("selectTimeRangeLimits", () => {
+ it("returns fixed time range limits", () => {
+ expect(
+ selectors.selectTimeRangeLimits(
+ state({
+ current: undefined,
+ initial: undefined,
+ initialDaysShown: 31,
+ limits: {
+ lower: "2021-02-01T00:00:00.000Z",
+ upper: "2023-03-03T00:00:00.000Z",
+ },
+ })
+ )
+ ).toEqual([
+ new Date("2021-02-01T00:00:00.000Z"),
+ new Date("2023-03-03T00:00:00.000Z"),
+ ]);
+ });
+
+ it("returns limits from a given lower bound to the current date, when no upper bound is passed in", () => {
+ expect(
+ selectors.selectTimeRangeLimits(
+ state({
+ current: undefined,
+ initial: undefined,
+ initialDaysShown: 31,
+ limits: {
+ lower: "2021-02-01T00:00:00.000Z",
+ upper: undefined,
+ },
+ })
+ )
+ ).toEqual([
+ new Date("2021-02-01T00:00:00.000Z"),
+ new Date("2022-02-01T00:00:00.000Z"),
+ ]);
+ });
+ });
+});
diff --git a/src/selectors/index.js b/src/selectors/index.js
index 61c1e93..6227b51 100644
--- a/src/selectors/index.js
+++ b/src/selectors/index.js
@@ -34,8 +34,6 @@ export const getNotifications = (state) => state.domain.notifications;
export const getActiveFilters = (state) => state.app.associations.filters;
export const getActiveCategories = (state) => state.app.associations.categories;
export const getActiveShapes = (state) => state.app.shapes;
-export const getTimeRange = (state) => state.app.timeline.range;
-export const getTimelineDimensions = (state) => state.app.timeline.dimensions;
export const selectNarrative = (state) => state.app.associations.narrative;
export const getFeatures = (state) => state.features;
export const getEventRadius = (state) => state.ui.eventRadius;
@@ -67,6 +65,47 @@ export const selectRegions = createSelector(
}
);
+const getTimeRange = (state) => state.app.timeline.range.current;
+const getInitialTimeRange = (state) => state.app.timeline.range.initial;
+const getInitialDaysShown = (state) =>
+ state.app.timeline.range.initialDaysShown;
+export const selectTimeRange = createSelector(
+ [getTimeRange, getInitialTimeRange, getInitialDaysShown],
+ (range, initialRange, initialDaysShown) => {
+ let start, end;
+
+ if (Array.isArray(range) && range.length === 2) {
+ [start, end] = range;
+ } else if (Array.isArray(initialRange) && initialRange.length === 2) {
+ [start, end] = initialRange;
+ } else {
+ end = new Date();
+ start = new Date(end.getTime() - initialDaysShown * 24 * 60 * 60 * 1000);
+ }
+
+ return [new Date(start), new Date(end)];
+ }
+);
+
+const getTimeRangeLimits = (state) => state.app.timeline.range.limits;
+export const selectTimeRangeLimits = createSelector(
+ getTimeRangeLimits,
+ (limits) => {
+ return [new Date(limits.lower), new Date(limits.upper || Date.now())];
+ }
+);
+
+const getTimelineDimensions = (state) => state.app.timeline.dimensions;
+export const selectDimensions = createSelector(
+ getTimelineDimensions,
+ (dimensions) => {
+ return {
+ ...dimensions,
+ trackHeight: dimensions.contentHeight - 50, // height of time labels
+ };
+ }
+);
+
/**
* Of all available events, selects those that
* 1. fall in time range
@@ -79,7 +118,7 @@ export const selectEvents = createSelector(
getActiveFilters,
getActiveCategories,
getActiveShapes,
- getTimeRange,
+ selectTimeRange,
getFeatures,
],
(
@@ -353,13 +392,3 @@ export const selectSelected = createSelector(
return selected.map(insetSourceFrom(sources));
}
);
-
-export const selectDimensions = createSelector(
- [getTimelineDimensions],
- (dimensions) => {
- return {
- ...dimensions,
- trackHeight: dimensions.contentHeight - 50, // height of time labels
- };
- }
-);
diff --git a/src/store/initial.js b/src/store/initial.js
index 11cae15..36bb5cf 100644
--- a/src/store/initial.js
+++ b/src/store/initial.js
@@ -6,7 +6,7 @@ import { language } from "../common/utilities";
import { DEFAULT_TAB_ICONS } from "../common/constants";
const isSmallLaptop = window.innerHeight < 800;
-const mapIniital = {
+const mapInitial = {
anchor: [31.356397, 34.784818],
startZoom: 11,
minZoom: 2,
@@ -83,8 +83,9 @@ const initial = {
contentHeight: isSmallLaptop ? 160 : 200,
width_controls: 100,
},
- range: [new Date(2001, 2, 23, 12), new Date(2021, 2, 23, 12)],
- rangeLimits: [new Date(1, 1, 1, 1), new Date()],
+ range: {
+ current: null,
+ },
zoomLevels: copy[language].timeline.zoomLevels || [
{ label: "20 years", duration: 10512000 },
{ label: "2 years", duration: 1051200 },
@@ -210,13 +211,10 @@ if (process.env.store) {
appStore = initial;
}
-// NB: config.js dates get implicitly converted to strings in mergeDeepLeft
-appStore.app.timeline.range[0] = new Date(appStore.app.timeline.range[0]);
-appStore.app.timeline.range[1] = new Date(appStore.app.timeline.range[1]);
appStore.app.flags.isIntropopup = !!appStore.app.intro;
if ("map" in appStore.app) {
- appStore.app.map = mergeDeepLeft(appStore.app.map, mapIniital);
+ appStore.app.map = mergeDeepLeft(appStore.app.map, mapInitial);
}
if ("space3d" in appStore.app) {