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) {