mirror of
https://github.com/bellingcat/ukraine-timemap.git
synced 2026-06-07 19:08:37 +03:00
Adds "feat: serialize platform state to the url" to ukraine-timemap (#59)
Co-authored-by: Felix Spöttel <1682504+fspoettel@users.noreply.github.com> Co-authored-by: msramalho <19508417+msramalho@users.noreply.github.com>
This commit is contained in:
27002
package-lock.json
generated
27002
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -407,3 +407,17 @@ export function fetchSourceError(msg) {
|
||||
msg,
|
||||
};
|
||||
}
|
||||
|
||||
export const TOGGLE_SATELLITE_VIEW = "TOGGLE_SATELLITE_VIEW";
|
||||
export function toggleSatelliteView() {
|
||||
return {
|
||||
type: TOGGLE_SATELLITE_VIEW,
|
||||
};
|
||||
}
|
||||
|
||||
export const REHYDRATE_STATE = "REHYDRATE_STATE";
|
||||
export function rehydrateState() {
|
||||
return {
|
||||
type: REHYDRATE_STATE,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -48,25 +48,6 @@ export function zipColorsToPercentages(colors, percentages) {
|
||||
}, {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get URI params to start with predefined set of
|
||||
* https://stackoverflow.com/questions/901115/how-can-i-get-query-string-values-in-javascript
|
||||
* @param {string} name: name of paramater to search
|
||||
* @param {string} url: url passed as variable, defaults to window.location.href
|
||||
*/
|
||||
export function getParameterByName(name, url) {
|
||||
if (!url) url = window.location.href;
|
||||
name = name.replace(/[[\]]/g, "\\$&");
|
||||
|
||||
const regex = new RegExp(`[?&]${name}(=([^&#]*)|&|#|$)`);
|
||||
const results = regex.exec(url);
|
||||
|
||||
if (!results) return null;
|
||||
if (!results[2]) return "";
|
||||
|
||||
return decodeURIComponent(results[2].replace(/\+/g, " "));
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two arrays of scalars
|
||||
* @param {array} arr1: array of numbers
|
||||
@@ -597,3 +578,8 @@ export function downloadAsFile(filename, content) {
|
||||
}
|
||||
|
||||
export const isEmptyString = (s) => s.length === 0;
|
||||
export const isOdd = (num) => num % 2 !== 0;
|
||||
|
||||
export function isEmptyObject(o) {
|
||||
return o == null || (typeof o === "object" && !Object.keys(o).length);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from "react";
|
||||
|
||||
import { bindActionCreators } from "redux";
|
||||
import { connect } from "react-redux";
|
||||
import { isMobileOnly } from "react-device-detect";
|
||||
import * as actions from "../actions";
|
||||
import * as selectors from "../selectors";
|
||||
|
||||
@@ -23,7 +24,6 @@ import NarrativeControls from "./controls/NarrativeControls.js";
|
||||
|
||||
import colors from "../common/global";
|
||||
import { binarySearch, insetSourceFrom } from "../common/utilities";
|
||||
import { isMobileOnly } from "react-device-detect";
|
||||
|
||||
class Dashboard extends React.Component {
|
||||
constructor(props) {
|
||||
@@ -41,12 +41,14 @@ class Dashboard extends React.Component {
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.props.actions.fetchDomain().then((domain) =>
|
||||
this.props.actions.fetchDomain().then((domain) => {
|
||||
this.props.actions.updateDomain({
|
||||
domain,
|
||||
features: this.props.features,
|
||||
})
|
||||
);
|
||||
});
|
||||
this.props.actions.rehydrateState();
|
||||
});
|
||||
|
||||
// NOTE: hack to get the timeline to always show. Not entirely sure why
|
||||
// this is necessary.
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
@@ -259,21 +261,33 @@ class Dashboard extends React.Component {
|
||||
) : null;
|
||||
|
||||
let searchParams = new URLSearchParams(window.location.href.split("?")[1]);
|
||||
return (
|
||||
<Popup
|
||||
title="Introduction to the platform"
|
||||
theme="dark"
|
||||
isOpen={
|
||||
app.flags.isIntropopup &&
|
||||
(!searchParams.has("cover") || searchParams.get("cover") !== "false")
|
||||
}
|
||||
onClose={actions.toggleIntroPopup}
|
||||
content={app.intro}
|
||||
styles={styles}
|
||||
>
|
||||
{extraContent}
|
||||
</Popup>
|
||||
);
|
||||
let rememberDismissedIntro =
|
||||
localStorage.getItem("rememberDismissedIntro") === "true";
|
||||
let forceShowIntro = searchParams.get("cover") === "true";
|
||||
if (
|
||||
(forceShowIntro || !rememberDismissedIntro) &&
|
||||
!searchParams.has("id")
|
||||
) {
|
||||
return (
|
||||
<Popup
|
||||
title="Introduction to the platform"
|
||||
theme="dark"
|
||||
isOpen={
|
||||
app.flags.isIntropopup && searchParams.get("cover") !== "false"
|
||||
}
|
||||
onClose={() => {
|
||||
actions.toggleIntroPopup();
|
||||
localStorage.setItem("rememberDismissedIntro", "true");
|
||||
}}
|
||||
content={app.intro}
|
||||
styles={styles}
|
||||
>
|
||||
{extraContent}
|
||||
</Popup>
|
||||
);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
|
||||
@@ -1,13 +1,26 @@
|
||||
import { combineReducers } from "redux";
|
||||
|
||||
import rootReducer from "./root.js";
|
||||
import domain from "./domain.js";
|
||||
import app from "./app.js";
|
||||
import ui from "./ui.js";
|
||||
import features from "./features.js";
|
||||
|
||||
export default combineReducers({
|
||||
app,
|
||||
domain,
|
||||
ui,
|
||||
features,
|
||||
});
|
||||
function decorateRootReducer(rootReducer, reducer) {
|
||||
return (state, action) =>
|
||||
reducer(
|
||||
{
|
||||
...rootReducer(state, action),
|
||||
},
|
||||
action
|
||||
);
|
||||
}
|
||||
|
||||
export default decorateRootReducer(
|
||||
rootReducer,
|
||||
combineReducers({
|
||||
app,
|
||||
domain,
|
||||
ui,
|
||||
features,
|
||||
})
|
||||
);
|
||||
|
||||
11
src/reducers/root.js
Normal file
11
src/reducers/root.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { REHYDRATE_STATE } from "../actions";
|
||||
import { applyUrlState } from "../store/plugins/urlState";
|
||||
|
||||
export default function rootReducer(state = {}, action) {
|
||||
switch (action.type) {
|
||||
case REHYDRATE_STATE:
|
||||
return applyUrlState(state);
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,9 @@ 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 getColoringSet = (state) => state.app.associations.coloringSet;
|
||||
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,7 +70,6 @@ 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;
|
||||
@@ -75,6 +77,7 @@ export const selectTimeRange = createSelector(
|
||||
[getTimeRange, getInitialTimeRange, getInitialDaysShown],
|
||||
(range, initialRange, initialDaysShown) => {
|
||||
let start, end;
|
||||
range = range.current;
|
||||
|
||||
if (Array.isArray(range) && range.length === 2) {
|
||||
[start, end] = range;
|
||||
@@ -97,17 +100,6 @@ export const selectTimeRangeLimits = createSelector(
|
||||
}
|
||||
);
|
||||
|
||||
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
|
||||
@@ -394,3 +386,45 @@ 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
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
export const selectFilterPathToIdMapping = createSelector(
|
||||
[getFilters],
|
||||
(filters) => {
|
||||
return filters.reduce((acc, curr) => {
|
||||
acc[createFilterPathString(curr)] = curr.id;
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
);
|
||||
|
||||
export const selectActiveColorSets = createSelector(
|
||||
[getColoringSet, selectFilterPathToIdMapping],
|
||||
(set, mapping) => {
|
||||
return set.map((set) => mapFiltersToIds(set, mapping).join(","));
|
||||
}
|
||||
);
|
||||
|
||||
export const selectActiveFilterIds = createSelector(
|
||||
[getActiveFilters, selectFilterPathToIdMapping],
|
||||
(filters, mapping) => {
|
||||
return mapFiltersToIds(filters, mapping);
|
||||
}
|
||||
);
|
||||
|
||||
function mapFiltersToIds(arr, filterMapping) {
|
||||
return arr.reduce((acc, path) => {
|
||||
const id = filterMapping[path];
|
||||
if (id) acc.push(id);
|
||||
return acc;
|
||||
}, []);
|
||||
}
|
||||
|
||||
@@ -2,11 +2,13 @@ import { createStore, applyMiddleware, compose } from "redux";
|
||||
import thunk from "redux-thunk";
|
||||
|
||||
import rootReducer from "../reducers";
|
||||
import { urlStateMiddleware } from "./plugins/urlState";
|
||||
|
||||
const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
|
||||
|
||||
const store = createStore(
|
||||
rootReducer,
|
||||
composeEnhancers(applyMiddleware(thunk))
|
||||
composeEnhancers(applyMiddleware(thunk), applyMiddleware(urlStateMiddleware))
|
||||
);
|
||||
|
||||
export default store;
|
||||
|
||||
20
src/store/plugins/urlState/applyUrlState.js
Normal file
20
src/store/plugins/urlState/applyUrlState.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import { isEmptyObject } from "../../../common/utilities";
|
||||
import { SCHEMA } from "./schema";
|
||||
import URLState from "./urlState";
|
||||
|
||||
export function applyUrlState(state) {
|
||||
const urlState = new URLState().deserialize();
|
||||
if (isEmptyObject(urlState)) return state;
|
||||
|
||||
const nextState = { ...state };
|
||||
|
||||
Object.values(SCHEMA).forEach((s) => {
|
||||
try {
|
||||
s.rehydrate(nextState, urlState);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
});
|
||||
|
||||
return nextState;
|
||||
}
|
||||
2
src/store/plugins/urlState/index.js
Normal file
2
src/store/plugins/urlState/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
export { applyUrlState } from "./applyUrlState";
|
||||
export { urlStateMiddleware } from "./middleware";
|
||||
27
src/store/plugins/urlState/middleware.js
Normal file
27
src/store/plugins/urlState/middleware.js
Normal file
@@ -0,0 +1,27 @@
|
||||
import { SCHEMA } from "./schema";
|
||||
import URLState from "./urlState";
|
||||
|
||||
export function urlStateMiddleware(store) {
|
||||
return (next) => (action) => {
|
||||
const result = next(action);
|
||||
|
||||
try {
|
||||
const schemas = Object.values(SCHEMA).filter(
|
||||
(s) => s.trigger === action.type
|
||||
);
|
||||
|
||||
if (schemas.length) {
|
||||
const urlState = new URLState();
|
||||
const state = store.getState();
|
||||
schemas.forEach((s) => {
|
||||
urlState.set(s.key, s.dehydrate(state));
|
||||
});
|
||||
urlState.serialize();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("error serializing url state", err);
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
}
|
||||
138
src/store/plugins/urlState/schema.js
Normal file
138
src/store/plugins/urlState/schema.js
Normal file
@@ -0,0 +1,138 @@
|
||||
import {
|
||||
TOGGLE_ASSOCIATIONS,
|
||||
UPDATE_COLORING_SET,
|
||||
UPDATE_SELECTED,
|
||||
UPDATE_TIMERANGE,
|
||||
} from "../../../actions";
|
||||
import { ASSOCIATION_MODES } from "../../../common/constants";
|
||||
import { createFilterPathString } from "../../../common/utilities";
|
||||
import {
|
||||
getSelected,
|
||||
getTimeRange,
|
||||
selectActiveColorSets,
|
||||
selectActiveFilterIds,
|
||||
} from "../../../selectors";
|
||||
|
||||
export const SCHEMA_TYPES = {
|
||||
NUMBER: "NUMBER",
|
||||
NUMBER_ARRAY: "NUMBER_ARRAY",
|
||||
STRING: "STRING",
|
||||
STRING_ARRAY: "STRING_ARRAY",
|
||||
DATE: "DATE",
|
||||
DATE_ARRAY: "DATE_ARRAY",
|
||||
};
|
||||
|
||||
export function isSchemaArray(schema) {
|
||||
return [
|
||||
SCHEMA_TYPES.DATE_ARRAY,
|
||||
SCHEMA_TYPES.NUMBER_ARRAY,
|
||||
SCHEMA_TYPES.STRING_ARRAY,
|
||||
].includes(schema.type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Schema specifies how redux state maps to the url and vice versa.
|
||||
* `trigger`: action that triggers a call to `dehydrate()`
|
||||
* `type`: type of the mapped URL property
|
||||
* `dehydrate()`: maps redux state to url state.
|
||||
* `rehydrate()`:
|
||||
* maps url state to redux state.
|
||||
* !for performance reasons, this function works with a mutable ref to `state`!
|
||||
*/
|
||||
export const SCHEMA = Object.freeze({
|
||||
id: {
|
||||
key: "id",
|
||||
trigger: UPDATE_SELECTED,
|
||||
type: SCHEMA_TYPES.STRING_ARRAY,
|
||||
dehydrate(state) {
|
||||
return getSelected(state).map(({ civId }) => civId);
|
||||
},
|
||||
// TODO: determine time range if `range` not set.
|
||||
rehydrate(nextState, { id }) {
|
||||
if (id?.length) {
|
||||
nextState.app.selected = id.reduce((acc, curr) => {
|
||||
const event = nextState.domain.events.find((e) => e.civId === curr);
|
||||
|
||||
if (event) {
|
||||
acc.push(event);
|
||||
} else {
|
||||
console.warn(
|
||||
`event ${curr} could not be rehydrated. reason: not present.`
|
||||
);
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, []);
|
||||
}
|
||||
},
|
||||
},
|
||||
range: {
|
||||
key: "range",
|
||||
trigger: UPDATE_TIMERANGE,
|
||||
type: SCHEMA_TYPES.DATE_ARRAY,
|
||||
dehydrate(state) {
|
||||
return getTimeRange(state);
|
||||
},
|
||||
rehydrate(nextState, { range }) {
|
||||
if (range?.length === 2) {
|
||||
const val = Array.from(range);
|
||||
val.sort((a, b) => new Date(a) - new Date(b));
|
||||
// HACK! diversion from upstream: we use a custom timeline state format.
|
||||
nextState.app.timeline = {
|
||||
...nextState.app.timeline,
|
||||
range: {
|
||||
...nextState.app.timeline.range,
|
||||
current: val,
|
||||
},
|
||||
};
|
||||
}
|
||||
},
|
||||
},
|
||||
filter: {
|
||||
key: "filter",
|
||||
trigger: TOGGLE_ASSOCIATIONS,
|
||||
type: SCHEMA_TYPES.STRING_ARRAY,
|
||||
dehydrate(state) {
|
||||
return selectActiveFilterIds(state);
|
||||
},
|
||||
// TODO: set parent filters if all children checked.
|
||||
rehydrate(nextState, { filter }) {
|
||||
if (filter?.length) {
|
||||
const filters = nextState.domain.associations.filter(
|
||||
(x) => x.mode === ASSOCIATION_MODES.FILTER
|
||||
);
|
||||
const filterMapping = mapFilterIdsToPaths(filters);
|
||||
nextState.app.associations.filters = filter.map(
|
||||
(id) => filterMapping[id]
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
color: {
|
||||
key: "color",
|
||||
trigger: UPDATE_COLORING_SET,
|
||||
type: SCHEMA_TYPES.STRING_ARRAY,
|
||||
dehydrate(state) {
|
||||
return selectActiveColorSets(state);
|
||||
},
|
||||
// TODO: color parent if all children checked.
|
||||
rehydrate(state, { color }) {
|
||||
if (color?.length) {
|
||||
const filters = state.domain.associations.filter(
|
||||
(x) => x.mode === ASSOCIATION_MODES.FILTER
|
||||
);
|
||||
const filterMapping = mapFilterIdsToPaths(filters);
|
||||
state.app.associations.coloringSet = color.map((set) =>
|
||||
set.split(",").map((id) => filterMapping[id])
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function mapFilterIdsToPaths(filters) {
|
||||
return filters.reduce((acc, curr) => {
|
||||
acc[curr.id] = createFilterPathString(curr);
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
108
src/store/plugins/urlState/urlState.js
Normal file
108
src/store/plugins/urlState/urlState.js
Normal file
@@ -0,0 +1,108 @@
|
||||
import dayjs from "dayjs";
|
||||
import { isSchemaArray, SCHEMA, SCHEMA_TYPES } from "./schema";
|
||||
|
||||
export class URLState {
|
||||
constructor() {
|
||||
this.url = new URL(window.location);
|
||||
this.schema = SCHEMA;
|
||||
}
|
||||
|
||||
delete(key) {
|
||||
this.url.searchParams.delete(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* `key` not declared in `schema` will be ignored.
|
||||
* `value` is encoded according to the schema.
|
||||
* if the schema declares `isArray: true`, `value` is required be an array.
|
||||
*/
|
||||
set(key, value) {
|
||||
const schema = this.schema[key];
|
||||
if (!schema) return;
|
||||
|
||||
this.delete(key);
|
||||
|
||||
// HACK! diversion from upstream: we use a custom timeline state format.
|
||||
if (schema.type === SCHEMA_TYPES.DATE_ARRAY) {
|
||||
value.current.forEach((val) => {
|
||||
const encoded = this._encode(schema, val);
|
||||
if (encoded) this.url.searchParams.append(key, encoded);
|
||||
});
|
||||
} else if (isSchemaArray(schema)) {
|
||||
value.forEach((val) => {
|
||||
const encoded = this._encode(schema, val);
|
||||
if (encoded) this.url.searchParams.append(key, encoded);
|
||||
});
|
||||
} else {
|
||||
const encoded = this._encode(schema, value);
|
||||
if (encoded) this.url.searchParams.set(key, encoded);
|
||||
}
|
||||
}
|
||||
|
||||
serialize() {
|
||||
window.history.replaceState(null, "", this.url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns URL state as object.
|
||||
* Values are decoded according to schema.
|
||||
*/
|
||||
deserialize() {
|
||||
const state = {};
|
||||
|
||||
this.url.searchParams.forEach((_, key) => {
|
||||
if (state[key] != null) return;
|
||||
|
||||
const schema = this.schema[key];
|
||||
// ignore unknown query parameters
|
||||
if (!schema) return;
|
||||
|
||||
state[key] = isSchemaArray(schema)
|
||||
? this.url.searchParams
|
||||
.getAll(key)
|
||||
.map((val) => this._decode(schema, val))
|
||||
: this._decode(schema, this.url.searchParams.get(key));
|
||||
});
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
_decode(schema, value) {
|
||||
switch (schema.type) {
|
||||
case SCHEMA_TYPES.NUMBER_ARRAY:
|
||||
case SCHEMA_TYPES.NUMBER: {
|
||||
return +value;
|
||||
}
|
||||
|
||||
case SCHEMA_TYPES.DATE:
|
||||
case SCHEMA_TYPES.DATE_ARRAY: {
|
||||
return new Date(value);
|
||||
}
|
||||
|
||||
default: {
|
||||
if (value === "null" || value === "undefined") return undefined;
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_encode(schema, value) {
|
||||
switch (schema.type) {
|
||||
case SCHEMA_TYPES.NUMBER_ARRAY:
|
||||
case SCHEMA_TYPES.NUMBER: {
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
case SCHEMA_TYPES.DATE:
|
||||
case SCHEMA_TYPES.DATE_ARRAY: {
|
||||
return dayjs(value).format("YYYY-MM-DD");
|
||||
}
|
||||
|
||||
default: {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default URLState;
|
||||
Reference in New Issue
Block a user