From 559d6c8d07a4f604a8e195d46da35ae538995d0e Mon Sep 17 00:00:00 2001
From: muerwre <gotham48@gmail.com>
Date: Mon, 18 Feb 2019 18:03:03 +0700
Subject: [PATCH] lazy loading maps

---
 backend/routes/route/list.js             | 13 +++-
 backend/tools/import.js                  |  2 +-
 package-lock.json                        |  5 ++
 package.json                             |  1 +
 src/components/dialogs/MapListDialog.tsx | 25 +++++--
 src/redux/user/actions.ts                |  4 ++
 src/redux/user/constants.ts              |  4 ++
 src/redux/user/reducer.ts                | 19 +++++-
 src/redux/user/sagas.ts                  | 85 ++++++++++++++++++++----
 src/styles/dialogs.less                  | 18 +++++
 src/utils/api.js                         |  6 +-
 11 files changed, 157 insertions(+), 25 deletions(-)

diff --git a/backend/routes/route/list.js b/backend/routes/route/list.js
index 13c7fab..0a4f880 100644
--- a/backend/routes/route/list.js
+++ b/backend/routes/route/list.js
@@ -3,7 +3,7 @@ const { Route, User } = require('../../models');
 module.exports = async (req, res) => {
   const {
     query: {
-      id, token, title, distance, author, starred,
+      id, token, title, distance, author, step = 20, shift = 0,
     }
   } = req;
 
@@ -34,7 +34,7 @@ module.exports = async (req, res) => {
     },
     '_id title distance owner updated_at is_public',
     {
-      limit: 500,
+      limit: 9000,
       sort: { updated_at: -1 },
     }
   ).populate('owner', '_id');
@@ -61,6 +61,12 @@ module.exports = async (req, res) => {
     ));
   }
 
+  const limit = list.length;
+
+  if (step) {
+    list = list.slice(parseInt(shift, 10), (parseInt(shift, 10) + parseInt(step, 10)));
+  }
+
   if (list.length === 0) {
     limits = { min: 0, max: 0 };
   } else if (limits.max === 0) {
@@ -74,6 +80,9 @@ module.exports = async (req, res) => {
   res.send({
     success: true,
     list,
+    limit,
+    step: parseInt(step, 10),
+    shift: parseInt(shift, 10),
     ...limits,
   });
 };
diff --git a/backend/tools/import.js b/backend/tools/import.js
index ee19d5a..0481cc3 100755
--- a/backend/tools/import.js
+++ b/backend/tools/import.js
@@ -141,7 +141,7 @@ const run = async () => {
         title: '',
         version: 1,
         distance: calcPolyDistance(route),
-        is_public: false,
+        is_public: true,
       };
     }));
   }));
diff --git a/package-lock.json b/package-lock.json
index 0b55d6c..3baed82 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11461,6 +11461,11 @@
       "integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=",
       "dev": true
     },
+    "throttle-debounce": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-2.1.0.tgz",
+      "integrity": "sha512-AOvyNahXQuU7NN+VVvOOX+uW6FPaWdAOdRP5HfwYxAfCzXTFKRMoIMk+n+po318+ktcChx+F1Dd91G3YHeMKyg=="
+    },
     "through": {
       "version": "2.3.8",
       "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",
diff --git a/package.json b/package.json
index 7b4b15e..25f6808 100644
--- a/package.json
+++ b/package.json
@@ -100,6 +100,7 @@
     "scrypt": "^6.0.3",
     "styled-components": "^3.2.6",
     "styled-theming": "^2.2.0",
+    "throttle-debounce": "^2.1.0",
     "tt-react-custom-scrollbars": "^4.2.1-tt2",
     "typeface-pt-sans": "0.0.54",
     "typesafe-actions": "^3.0.0",
diff --git a/src/components/dialogs/MapListDialog.tsx b/src/components/dialogs/MapListDialog.tsx
index 3a6665d..c13fb9e 100644
--- a/src/components/dialogs/MapListDialog.tsx
+++ b/src/components/dialogs/MapListDialog.tsx
@@ -7,7 +7,7 @@ import {
   searchSetDistance,
   searchSetTitle,
   searchSetTab,
-  setDialogActive,
+  setDialogActive, mapsLoadMore,
 } from '$redux/user/actions';
 import { isMobile } from '$utils/window';
 import classnames from 'classnames';
@@ -21,7 +21,7 @@ import { IRootState } from '$redux/user/reducer';
 interface Props extends IRootState {
   marks: { [x: number]: string },
   routes_sorted: Array<string>,
-
+  mapsLoadMore: typeof mapsLoadMore,
   searchSetDistance: typeof searchSetDistance,
   searchSetTitle: typeof searchSetTitle,
   searchSetTab: typeof searchSetTab,
@@ -50,6 +50,19 @@ class Component extends React.Component<Props, State> {
     pushPath(`/${_id}/${this.props.editing ? 'edit' : ''}`);
   };
 
+  onScroll = (e: { target: { scrollHeight: number, scrollTop: number, clientHeight: number }}): void => {
+    const { target: { scrollHeight, scrollTop, clientHeight }} = e;
+    const delta = scrollHeight - scrollTop - clientHeight;
+
+    if (
+      delta < 300 &&
+      this.props.routes.list.length < this.props.routes.limit &&
+      !this.props.routes.loading
+    ) {
+      this.props.mapsLoadMore();
+    }
+  };
+
   render() {
     const {
       ready,
@@ -130,7 +143,10 @@ class Component extends React.Component<Props, State> {
           </div>
         </div>
 
-        <Scroll className="dialog-shader">
+        <Scroll
+          className="dialog-shader"
+          onScroll={this.onScroll}
+        >
           <div className="dialog-maplist">
             {
               list.map(route => (
@@ -148,7 +164,7 @@ class Component extends React.Component<Props, State> {
             }
           </div>
         </Scroll>
-
+        <div className={classnames('dialog-maplist-pulse', { active: loading })} />
       </div>
     );
   }
@@ -178,6 +194,7 @@ const mapDispatchToProps = dispatch => bindActionCreators({
   searchSetTitle,
   searchSetTab,
   setDialogActive,
+  mapsLoadMore,
 }, dispatch);
 
 export const MapListDialog = connect(mapStateToProps, mapDispatchToProps)(Component);
diff --git a/src/redux/user/actions.ts b/src/redux/user/actions.ts
index 73cdbe2..8a51feb 100644
--- a/src/redux/user/actions.ts
+++ b/src/redux/user/actions.ts
@@ -56,6 +56,7 @@ export const keyPressed = ({ key, target: { tagName } }) => ({ type: ACTIONS.KEY
 
 export const searchSetTitle = title => ({ type: ACTIONS.SEARCH_SET_TITLE, title });
 export const searchSetDistance = distance => ({ type: ACTIONS.SEARCH_SET_DISTANCE, distance });
+export const searchChangeDistance = distance => ({ type: ACTIONS.SEARCH_CHANGE_DISTANCE, distance });
 export const searchSetTab = tab => ({ type: ACTIONS.SEARCH_SET_TAB, tab });
 export const searchSetLoading = loading => ({ type: ACTIONS.SEARCH_SET_LOADING, loading });
 
@@ -64,3 +65,6 @@ export const searchPutRoutes = payload => ({ type: ACTIONS.SEARCH_PUT_ROUTES, ..
 export const setMarkersShown = markers_shown => ({ type: ACTIONS.SET_MARKERS_SHOWN, markers_shown });
 export const getGPXTrack = () => ({ type: ACTIONS.GET_GPX_TRACK });
 export const setIsEmpty = is_empty => ({ type: ACTIONS.SET_IS_EMPTY, is_empty });
+
+export const mapsLoadMore = () => ({ type: ACTIONS.MAPS_LOAD_MORE });
+export const mapsSetShift = (shift: number) => ({ type: ACTIONS.MAPS_SET_SHIFT, shift });
diff --git a/src/redux/user/constants.ts b/src/redux/user/constants.ts
index 9500f93..363e066 100644
--- a/src/redux/user/constants.ts
+++ b/src/redux/user/constants.ts
@@ -59,6 +59,7 @@ export const ACTIONS: IActions = {
 
   SEARCH_SET_TITLE: 'SEARCH_SET_TITLE',
   SEARCH_SET_DISTANCE: 'SEARCH_SET_DISTANCE',
+  SEARCH_CHANGE_DISTANCE: 'SEARCH_CHANGE_DISTANCE',
 
   SEARCH_SET_TAB: 'SEARCH_SET_TAB',
   SEARCH_PUT_ROUTES: 'SEARCH_PUT_ROUTES',
@@ -71,4 +72,7 @@ export const ACTIONS: IActions = {
 
   GET_GPX_TRACK: 'GET_GPX_TRACK',
   SET_IS_EMPTY: 'SET_IS_EMPTY',
+
+  MAPS_LOAD_MORE: 'MAPS_LOAD_MORE',
+  MAPS_SET_SHIFT: 'MAPS_SET_SHIFT',
 };
diff --git a/src/redux/user/reducer.ts b/src/redux/user/reducer.ts
index 0e0fa97..b7a8ab5 100644
--- a/src/redux/user/reducer.ts
+++ b/src/redux/user/reducer.ts
@@ -57,6 +57,8 @@ interface IRootReducer {
     limit: 0,
     loading: boolean,
     list: Array<IRoute>,
+    step: number,
+    shift: number,
     filter: {
       title: '',
       starred: boolean,
@@ -237,11 +239,14 @@ const searchSetTab: ActionHandler<typeof ActionCreators.searchSetTab> = (state,
   }
 });
 
-const searchPutRoutes: ActionHandler<typeof ActionCreators.searchPutRoutes> = (state, { list = [], min, max }) => ({
+const searchPutRoutes: ActionHandler<typeof ActionCreators.searchPutRoutes> = (state, { list = [], min, max, limit, step, shift }) => ({
   ...state,
   routes: {
     ...state.routes,
     list,
+    limit,
+    step,
+    shift,
     filter: {
       ...state.routes.filter,
       distance: (state.routes.filter.min === state.routes.filter.max)
@@ -270,6 +275,13 @@ const setSpeed: ActionHandler<typeof ActionCreators.setSpeed> = (state, { speed
 
 const setMarkersShown: ActionHandler<typeof ActionCreators.setMarkersShown> = (state, { markers_shown = true }) => ({ ...state, markers_shown });
 const setIsEmpty: ActionHandler<typeof ActionCreators.setIsEmpty> = (state, { is_empty = true }) => ({ ...state, is_empty });
+const mapsSetShift: ActionHandler<typeof ActionCreators.mapsSetShift> = (state, { shift = 0 }) => ({
+  ...state,
+  routes: {
+    ...state.routes,
+    shift,
+  }
+});
 
 const HANDLERS = ({
   [ACTIONS.SET_USER]: setUser,
@@ -302,6 +314,7 @@ const HANDLERS = ({
 
   [ACTIONS.SEARCH_SET_TITLE]: searchSetTitle,
   [ACTIONS.SEARCH_SET_DISTANCE]: searchSetDistance,
+  [ACTIONS.SEARCH_CHANGE_DISTANCE]: searchSetDistance,
   [ACTIONS.SEARCH_SET_TAB]: searchSetTab,
   [ACTIONS.SEARCH_PUT_ROUTES]: searchPutRoutes,
   [ACTIONS.SEARCH_SET_LOADING]: searchSetLoading,
@@ -310,6 +323,8 @@ const HANDLERS = ({
 
   [ACTIONS.SET_MARKERS_SHOWN]: setMarkersShown,
   [ACTIONS.SET_IS_EMPTY]: setIsEmpty,
+  [ACTIONS.MAPS_SET_SHIFT]: mapsSetShift,
+
 });
 
 export const INITIAL_STATE: IRootReducer = {
@@ -354,6 +369,8 @@ export const INITIAL_STATE: IRootReducer = {
     limit: 0,
     loading: false, // <-- maybe delete this
     list: [],
+    step: 20,
+    shift: 0,
     filter: {
       title: '',
       starred: false,
diff --git a/src/redux/user/sagas.ts b/src/redux/user/sagas.ts
index 512e5f8..4460342 100644
--- a/src/redux/user/sagas.ts
+++ b/src/redux/user/sagas.ts
@@ -9,15 +9,32 @@ import {
   postMap
 } from '$utils/api';
 import {
-  hideRenderer, searchPutRoutes, searchSetDistance, searchSetLoading,
-  setActiveSticker, setAddress,
-  setChanged, setDialogActive,
+  hideRenderer,
+  searchPutRoutes,
+  searchSetDistance,
+  searchSetLoading,
+  setActiveSticker,
+  setAddress,
+  setChanged,
+  setDialogActive,
   setEditing,
-  setMode, setReady, setRenderer,
+  setMode,
+  setReady,
+  setRenderer,
   setSaveError,
-  setSaveOverwrite, setSaveSuccess, setTitle,
+  setSaveOverwrite,
+  setSaveSuccess,
+  setTitle,
   searchSetTab,
-  setUser, setDialog, setPublic, setAddressOrigin, setProvider, changeProvider, openMapDialog, setSaveLoading,
+  setUser,
+  setDialog,
+  setPublic,
+  setAddressOrigin,
+  setProvider,
+  changeProvider,
+  openMapDialog,
+  setSaveLoading,
+  mapsSetShift, searchChangeDistance,
 } from '$redux/user/actions';
 import { getUrlData, parseQuery, pushLoaderState, pushNetworkInitError, pushPath, replacePath } from '$utils/history';
 import { editor } from '$modules/Editor';
@@ -456,28 +473,36 @@ function* keyPressedSaga({ key, target }: ReturnType<typeof ActionCreators.keyPr
   }
 }
 
-function* searchSetSagaWorker() {
+function* searchGetRoutes() {
   const { id, token } = yield select(getUser);
 
-  const { routes: { filter, filter: { title, distance, tab } } } = yield select(getState);
+  const { routes: { step, shift, filter: { title, distance, tab } } } = yield select(getState);
 
-  const { list, min, max } = yield call(getRouteList, {
+  return yield call(getRouteList, {
     id,
     token,
     title,
     distance,
+    step,
+    shift,
     author: tab === 'mine' ? id : '',
     starred: tab === 'starred',
   });
+}
 
-  yield put(searchPutRoutes({ list, min, max }));
+function* searchSetSagaWorker() {
+  const { routes: { filter } } = yield select(getState);
+
+  const { list, min, max, limit, shift, step } = yield call(searchGetRoutes);
+
+  yield put(searchPutRoutes({ list, min, max, limit, shift, step }));
 
   // change distange range if needed and load additional data
   if (
     (filter.min > min && filter.distance[0] <= filter.min) ||
     (filter.max < max && filter.distance[1] >= filter.max)
   ) {
-    yield put(searchSetDistance([
+    yield put(searchChangeDistance([
       (filter.min > min && filter.distance[0] <= filter.min)
         ? min
         : filter.distance[0],
@@ -492,6 +517,7 @@ function* searchSetSagaWorker() {
 
 function* searchSetSaga() {
   yield put(searchSetLoading(true));
+  yield put(mapsSetShift(0));
   yield delay(500);
   yield call(searchSetSagaWorker);
 }
@@ -514,8 +540,8 @@ function* openMapDialogSaga({ tab }: ReturnType<typeof ActionCreators.openMapDia
 }
 
 function* searchSetTabSaga() {
-  yield put(searchSetDistance([0, 10000]));
-  yield put(searchPutRoutes({ list: [], min: 0, max: 10000 }));
+  yield put(searchChangeDistance([0, 10000]));
+  yield put(searchPutRoutes({ list: [], min: 0, max: 10000, step: 20, shift: 0 }));
 
   yield call(searchSetSaga);
 }
@@ -572,6 +598,36 @@ function* getGPXTrackSaga(): SagaIterator {
   return downloadGPXTrack({ track, title });
 }
 
+function* mapsLoadMoreSaga() {
+  const { routes: { limit, list, shift, step, loading, filter } } = yield select(getState);
+
+  if (loading || list.length >= limit || limit === 0) return;
+
+  yield delay(500);
+
+  yield put(searchSetLoading(true));
+  yield put(mapsSetShift(shift + step));
+
+  const result = yield call(searchGetRoutes);
+
+  if (
+    (filter.min > result.min && filter.distance[0] <= filter.min) ||
+    (filter.max < result.max && filter.distance[1] >= filter.max)
+  ) {
+    yield put(searchChangeDistance([
+      (filter.min > result.min && filter.distance[0] <= filter.min)
+        ? result.min
+        : filter.distance[0],
+      (filter.max < result.max && filter.distance[1] >= filter.max)
+        ? result.max
+        : filter.distance[1],
+    ]));
+  }
+
+  yield put(searchPutRoutes({ ...result, list: [...list, ...result.list] }));
+  yield put(searchSetLoading(false));
+}
+
 export function* userSaga() {
   yield takeLatest(REHYDRATE, authCheckSaga);
   yield takeEvery(ACTIONS.SET_MODE, setModeSaga);
@@ -614,5 +670,6 @@ export function* userSaga() {
   yield takeLatest(ACTIONS.SEARCH_SET_TAB, searchSetTabSaga);
   yield takeLatest(ACTIONS.SET_USER, setUserSaga);
 
-  yield takeLatest(ACTIONS.GET_GPX_TRACK, getGPXTrackSaga)
+  yield takeLatest(ACTIONS.GET_GPX_TRACK, getGPXTrackSaga);
+  yield takeLatest(ACTIONS.MAPS_LOAD_MORE, mapsLoadMoreSaga);
 }
diff --git a/src/styles/dialogs.less b/src/styles/dialogs.less
index d982860..33d0f05 100644
--- a/src/styles/dialogs.less
+++ b/src/styles/dialogs.less
@@ -126,6 +126,24 @@
   100% { transform: rotate(360deg); }
 }
 
+.dialog-maplist-pulse {
+  position: absolute;
+  bottom: 0;
+  left: 0;
+  width: 100%;
+  z-index: 10;
+  background: linear-gradient(fade(@red_secondary, 0%), @red_secondary);
+  height: 48px;
+  pointer-events: none;
+  transition: opacity 250ms;
+  opacity: 0;
+
+  &.active {
+    opacity: 1;
+    animation: pulse 500ms infinite alternate;
+  }
+}
+
 .dialog-maplist-loader {
   display: flex;
   margin-bottom: 10px;
diff --git a/src/utils/api.js b/src/utils/api.js
index 58cf1bb..6237cfb 100644
--- a/src/utils/api.js
+++ b/src/utils/api.js
@@ -40,10 +40,10 @@ export const checkIframeToken = ({ viewer_id, auth_key }) => axios.get(API.IFRAM
 }).then(result => (result && result.data && result.data.success && result.data.user)).catch(() => (false));
 
 export const getRouteList = ({
-  title, distance, author, starred, id, token,
+  title, distance, author, starred, id, token, step, shift,
 }) => axios.get(API.GET_ROUTE_LIST, {
   params: {
-    title, distance, author, starred, id, token,
+    title, distance, author, starred, id, token, step, shift
   }
 }).then(result => (result && result.data && result.data.success && result.data))
-  .catch(() => ({ list: [], min: 0, max: 0 }));
+  .catch(() => ({ list: [], min: 0, max: 0, limit: 0, step: 20, shift: 20 }));