Skip to content

Commit

Permalink
feat(offline-mode): add downloaded data to store
Browse files Browse the repository at this point in the history
* feat(electron): add downloaded data

* chore(data): change soil moisture color map
  • Loading branch information
pwambach authored Mar 10, 2020
1 parent c7a2604 commit 2831e00
Show file tree
Hide file tree
Showing 17 changed files with 219 additions and 45 deletions.
8 changes: 8 additions & 0 deletions data/layers-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,13 @@
"month": "numeric",
"day": "numeric"
}
},
"soilmoisture.sm": {
"colorMap": "inferno",
"timeFormat": {
"year": "numeric",
"month": "long",
"day": "2-digit"
}
}
}
32 changes: 32 additions & 0 deletions src/electron/download-handler.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ const {app} = require('electron');
* Intercepts all browser downloads in the given window
*/
module.exports.addDownloadHandler = function(browserWindow) {
// update the downloaded data state once on load
browserWindow.webContents.on('did-finish-load', () => {
browserWindow.webContents.send(
'offline-update',
JSON.stringify(getDownloadedIds())
);
});

browserWindow.webContents.session.on('will-download', (_, item) => {
const downloadsPath = app.getPath('downloads');
const tmpFilePath = path.join(downloadsPath, `${Date.now()}.zip`);
Expand All @@ -31,9 +39,33 @@ module.exports.addDownloadHandler = function(browserWindow) {
console.log('Download successfully', item.savePath);
zip.unzipSync(item.savePath, downloadsPath);
fs.unlinkSync(item.savePath);
browserWindow.webContents.send(
'offline-update',
JSON.stringify(getDownloadedIds())
);
} else {
console.log(`Download failed: ${state}`, item.savePath);
}
});
});
};

/**
* Get downloaded Ids from the downloads folder content
*/
function getDownloadedIds() {
const dirContent = fs
.readdirSync(app.getPath('downloads'), {
withFileTypes: true
})
.filter(entry => entry.isDirectory())
.map(entry => entry.name);

const layers = dirContent.filter(name => !name.startsWith('story'));
const stories = dirContent.filter(name => name.startsWith('story'));

return {
layers,
stories
};
}
10 changes: 5 additions & 5 deletions src/electron/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ function createWindow() {
// save window's reference
windows.push(window);

// add download handler
const downloadsPath = path.join(app.getPath('home'), '.esa-cfs', 'offline');
app.setPath('downloads', downloadsPath);
addDownloadHandler(window);

// load the index page in the window
const indexPath = `file://${__dirname}/../../dist/index.html`;
window.loadURL(indexPath);
Expand All @@ -28,11 +33,6 @@ function createWindow() {
window.on('closed', () => {
windows = windows.filter(w => w !== window);
});

// add download handler
const downloadsPath = path.join(app.getPath('home'), '.esa-cfs', 'offline');
app.setPath('downloads', downloadsPath);
addDownloadHandler(window);
}

app.on('ready', createWindow);
Expand Down
17 changes: 17 additions & 0 deletions src/scripts/actions/set-downloaded-data.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import {DownloadedData} from '../types/downloaded-data';

export const SET_DOWNLOADED_DATA = 'SET_DOWNLOADED_DATA';

export interface SetDownloadedDataAction {
type: typeof SET_DOWNLOADED_DATA;
data: DownloadedData;
}

const setDownloadedDataAction = (
data: DownloadedData
): SetDownloadedDataAction => ({
type: SET_DOWNLOADED_DATA,
data
});

export default setDownloadedDataAction;
7 changes: 7 additions & 0 deletions src/scripts/components/app/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import {EsaLogo} from '../icons/esa-logo';
import Globes from '../globes/globes';
import TimeSlider from '../time-slider/time-slider';
import DataSetInfo from '../data-set-info/data-set-info';
// @ts-ignore
import {isElectron, connectToStore} from 'electronHelpers'; // this is an webpack alias

import translations from '../../i18n';

Expand All @@ -28,6 +30,11 @@ const store = createStore(
applyMiddleware(thunk, createLogger({collapsed: true}))
);

// connect electron messages to redux store
if (isElectron()) {
connectToStore(store.dispatch);
}

const App: FunctionComponent = () => (
<StoreProvider store={store}>
<TranslatedApp />
Expand Down
56 changes: 35 additions & 21 deletions src/scripts/components/layer-list-item/layer-list-item.tsx
Original file line number Diff line number Diff line change
@@ -1,39 +1,53 @@
import React, {FunctionComponent} from 'react';
import {FormattedMessage} from 'react-intl';

// @ts-ignore
import {isElectron, downloadUrl} from 'electronHelpers'; // this is an webpack alias
import {replaceUrlPlaceholders} from '../../libs/replace-url-placeholders';
import config from '../../config/main';

import {LayerListItem as LayerListItemType} from '../../types/layer-list';

import styles from './layer-list-item.styl';

interface Props {
layer: LayerListItemType;
isMainSelected: boolean;
isDownloaded: boolean;
onSelect: (id: string, isMain: boolean) => void;
onDownload: null | ((id: string) => void);
}

const LayerListItem: FunctionComponent<Props> = ({
layer,
isMainSelected,
onSelect,
onDownload
}) => (
<div className={styles.layerItem} onClick={() => onSelect(layer.id, true)}>
<span className={styles.layerTitle}>{layer.name}</span>
{isMainSelected && (
<button
className={styles.compare}
onClick={event => {
onSelect(layer.id, false);
event.stopPropagation();
}}>
<FormattedMessage id={'layerSelector.compare'} />
</button>
)}
{onDownload && (
<button onClick={() => onDownload(layer.id)}>Download</button>
)}
</div>
);
isDownloaded,
onSelect
}) => {
const onDownload = () =>
isElectron() &&
downloadUrl(
replaceUrlPlaceholders(config.api.layerOfflinePackage, {id: layer.id})
);

return (
<div className={styles.layerItem} onClick={() => onSelect(layer.id, true)}>
<span className={styles.layerTitle}>{layer.name}</span>
{isMainSelected && (
<button
className={styles.compare}
onClick={event => {
onSelect(layer.id, false);
event.stopPropagation();
}}>
<FormattedMessage id={'layerSelector.compare'} />
</button>
)}
{isElectron() && !isDownloaded && (
<button onClick={onDownload}>Download</button>
)}
{isElectron() && isDownloaded && <button disabled>Ready</button>}
</div>
);
};

export default LayerListItem;
8 changes: 4 additions & 4 deletions src/scripts/components/layer-list/layer-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,15 @@ import styles from './layer-list.styl';
interface Props {
selectedLayerIds: SelectedLayerIdsState;
layers: LayerListItemType[];
downloadedLayerIds: string[];
onSelect: (id: string, isMain: boolean) => void;
onDownload: null | ((id: string) => void);
}

const LayerList: FunctionComponent<Props> = ({
selectedLayerIds,
layers,
onSelect,
onDownload
downloadedLayerIds,
onSelect
}) => {
const {mainId} = selectedLayerIds;
const isMainSelected = Boolean(mainId);
Expand All @@ -32,7 +32,7 @@ const LayerList: FunctionComponent<Props> = ({
<LayerListItem
onSelect={(id, isMain) => onSelect(id, isMain)}
isMainSelected={isMainSelected}
onDownload={onDownload}
isDownloaded={downloadedLayerIds.includes(layer.id)}
layer={layer}
/>
</li>
Expand Down
15 changes: 3 additions & 12 deletions src/scripts/components/layer-selector/layer-selector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,34 +10,25 @@ import showLayerSelectorAction from '../../actions/show-layer-selector';
import LayerList from '../layer-list/layer-list';
import SelectedLayerListItem from '../selected-layer-list-item/selected-layer-list-item';
import {layersSelector} from '../../selectors/layers/list';
import {replaceUrlPlaceholders} from '../../libs/replace-url-placeholders';
import config from '../../config/main';
// @ts-ignore
import {isElectron, downloadUrl} from 'electronHelpers'; // this is an webpack alias

import styles from './layer-selector.styl';
import setSelectedLayerIdsAction from '../../actions/set-selected-layer-id';
import {selectedLayerIdsSelector} from '../../selectors/layers/selected-ids';
import {downloadedDataSelector} from '../../selectors/downloaded-data';

const LayerSelector: FunctionComponent = () => {
const dispatch = useDispatch();
const layers = useSelector(layersSelector);
const selectedLayerIds = useSelector(selectedLayerIdsSelector);
const showLayerSelector = useSelector(showLayerSelectorSelector);
const downloadedData = useSelector(downloadedDataSelector);
const selectedMainLayer = layers.find(
layer => layer.id === selectedLayerIds.mainId
);
const selectedCompareLayer = layers.find(
layer => layer.id === selectedLayerIds.compareId
);

const onDownload = isElectron()
? (layerId: string) =>
downloadUrl(
replaceUrlPlaceholders(config.api.layerOfflinePackage, {id: layerId})
)
: null;

return (
<AnimatePresence>
{showLayerSelector ? (
Expand Down Expand Up @@ -74,10 +65,10 @@ const LayerSelector: FunctionComponent = () => {
<LayerList
layers={layers}
selectedLayerIds={selectedLayerIds}
downloadedLayerIds={downloadedData.layers}
onSelect={(layerId, isMain) =>
dispatch(setSelectedLayerIdsAction(layerId, isMain))
}
onDownload={onDownload}
/>
</div>
</motion.div>
Expand Down
1 change: 1 addition & 0 deletions src/scripts/libs/electron-helpers-mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
* Webpack will only include this module when building for web
*/

// State if evironment is electron
export function isElectron() {
return false;
}
48 changes: 47 additions & 1 deletion src/scripts/libs/electron-helpers.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,61 @@
/**
* Webpack will only include this module when building for electron
*/
import path from 'path';
import {remote, ipcRenderer} from 'electron';
import {Dispatch} from 'redux';
import setDownloadedDataAction, {
SetDownloadedDataAction
} from '../actions/set-downloaded-data';

import {remote} from 'electron';
console.log('Electron Helpers loaded');

const app = remote.app;
const webContents = remote.getCurrentWebContents();
let isConnected = false;

// Returns if environment is electron
export function isElectron() {
return true;
}

// Returns if the machine is offline
export function isOffline() {
return !navigator.onLine;
}

/**
* Connects this module to the store so that incoming ipc messages can be
* dispatched
*/
export function connectToStore(dispatch: Dispatch<SetDownloadedDataAction>) {
if (isConnected) {
console.warn('Electron Helpers: Store already connected! Doing nothing...');
return;
}

isConnected = true;
ipcRenderer.on('offline-update', (event, message) => {
const data = JSON.parse(message);
dispatch(setDownloadedDataAction(data));
});
}

// Returns the url template for offline usage
export function getOfflineTilesUrl() {
return path.join(
app.getPath('downloads'),
'{id}',
'tiles',
'{timeIndex}',
'{z}',
'{x}',
'{reverseY}.png'
);
}

// Downloads the content at the given URL
// the download will be handled by the electron 'will-download' handler)
export function downloadUrl(url: string) {
webContents.downloadURL(url);
}
9 changes: 8 additions & 1 deletion src/scripts/libs/get-layer-tile-url.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import config from '../config/main';
import {replaceUrlPlaceholders} from '../libs/replace-url-placeholders';

// @ts-ignore
import {isElectron, isOffline, getOfflineTilesUrl} from 'electronHelpers'; // this is an webpack alias

import {Layer} from '../types/layer';

/**
Expand All @@ -15,8 +18,12 @@ export function getLayerTileUrl(
return null;
}

// decide between remote or local tiles
const url =
isElectron() && isOffline() ? getOfflineTilesUrl() : config.api.layerTiles;

const timeIndex = getLayerTime(time, layer.timestamps).toString();
return replaceUrlPlaceholders(config.api.layerTiles, {
return replaceUrlPlaceholders(url, {
id: layer.id,
timeIndex
});
Expand Down
25 changes: 25 additions & 0 deletions src/scripts/reducers/downloaded-data.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import {
SET_DOWNLOADED_DATA,
SetDownloadedDataAction
} from '../actions/set-downloaded-data';

import {DownloadedData} from '../types/downloaded-data';

const initialState: DownloadedData = {
layers: [],
stories: []
};

function downloadedDataReducer(
state: DownloadedData = initialState,
action: SetDownloadedDataAction
): DownloadedData {
switch (action.type) {
case SET_DOWNLOADED_DATA:
return action.data;
default:
return state;
}
}

export default downloadedDataReducer;
Loading

0 comments on commit 2831e00

Please sign in to comment.