From 572408ba6aa7709a0a6d8613cf056cd152caff34 Mon Sep 17 00:00:00 2001 From: HF Date: Fri, 10 Jan 2020 01:50:27 +0100 Subject: [PATCH] add client render code for historical view --- README.md | 10 +- src/actions/index.js | 39 ++++++ src/actions/types.js | 1 + src/client.js | 7 +- src/components/HistorySelect.jsx | 12 +- src/reducers/canvas.js | 27 +++- src/routes/api/history.js | 5 +- src/store/rendererHook.js | 10 +- src/ui/ChunkRGB.js | 2 + src/ui/Renderer.js | 226 ++++++++++++++++++++++++++++++- 10 files changed, 320 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 2b19240d..6d95152b 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,8 @@ Configuration takes place in the environment variables that are defined in ecosy | SESSION_SECRET | random sting for expression sessions | "ayylmao" | | LOG_MYSQL | if sql queries should get logged | 0 | | USE_XREALIP | see cloudflare section | 1 | +| BACKUP_URL | url of backup server (see Backup) | "http://localhost" | +| BACKUP_DIR | mounted directory of backup server | "/mnt/backup/" | Notes: @@ -186,7 +188,7 @@ You can use `npm run babel-node ./your/script.js` to execute a script with local `npm run upgrade` can be use for interactively upgrading npm packages. -## Backups +## Backups and Historical View PixelPlanet includes a backup script that creates full canvas backups daily in the form of PNG tile files and incremential backups all 15min (or whatever you define) that saves PNG tiles with just the differences since the last full daily backup. @@ -203,3 +205,9 @@ Interval is the time in minutes between incremential backups. If interval is und If command is defined, it will be executed after every backup (just one command, with no arguments, like "dosomething.sh"), this is useful for synchronisation with a storage server i.e.. Alternatively you can run it with pm2, just like pixelplanet. An example ecosystem-backup.example.yml file will be located in the build directory. + +### Historical view + +Pixelplanet is able to let the user browse through the past with those backups. For this you need to define `BACKUP_URL` and `BACKUP_DIR` in your ecosystem.yml for pixelplanet. +`BACKUP_URL` is the URL where the backup folder is available. It's best to let another server serve those files or at least use nginx. +`BACKUP_DIR` is the full path of the local directory where the backup is located (whats set as `BACKUP_DIRECTORY` in the command of the backup.js). diff --git a/src/actions/index.js b/src/actions/index.js index 061b052f..d053f11b 100644 --- a/src/actions/index.js +++ b/src/actions/index.js @@ -429,6 +429,37 @@ export function fetchTile(canvasId, center: Cell): PromiseAction { }; } +export function fetchHistoricalChunk( + canvasId: number, + center: Cell, + historicalDate: string, + historicalTime: string, +): PromiseAction { + const [cx, cy] = center; + + return async (dispatch) => { + let url = `${window.backupurl}/${historicalDate}/`; + let zkey; + if (historicalTime) { + // incremential tiles + zkey = `${historicalDate}${historicalTime}`; + url += `${canvasId}/${historicalTime}/${cx}/${cy}.png`; + } else { + // full tiles + zkey = historicalDate; + url += `${canvasId}/tiles/${cx}/${cy}.png`; + } + const keyValues = [zkey, cx, cy]; + dispatch(requestBigChunk(keyValues)); + try { + const img = await loadImage(url); + dispatch(receiveImageTile(keyValues, img)); + } catch (error) { + dispatch(receiveBigChunkFailure(keyValues, error)); + } + }; +} + export function fetchChunk(canvasId, center: Cell): PromiseAction { const [, cx, cy] = center; @@ -670,6 +701,14 @@ export function onViewFinishChange(): Action { }; } +export function selectHistoricalTime(date: string, time: string) { + return { + type: 'SET_HISTORICAL_TIME', + date, + time, + }; +} + export function urlChange(): PromiseAction { return (dispatch) => { dispatch(reloadUrl()); diff --git a/src/actions/types.js b/src/actions/types.js index bb9c8b79..42341a1c 100644 --- a/src/actions/types.js +++ b/src/actions/types.js @@ -74,6 +74,7 @@ export type Action = | { type: 'SHOW_MODAL', modalType: string, modalProps: obj } | { type: 'HIDE_MODAL' } | { type: 'RELOAD_URL' } + | { type: 'SET_HISTORICAL_TIME', date: string, time: string } | { type: 'ON_VIEW_FINISH_CHANGE' }; export type PromiseAction = Promise; export type Dispatch = (action: Action | ThunkAction | PromiseAction | Array) => any; diff --git a/src/client.js b/src/client.js index 9cd4d9c0..1c0e6d8a 100644 --- a/src/client.js +++ b/src/client.js @@ -105,7 +105,12 @@ function initViewport() { const { autoZoomIn } = state.gui; const { placeAllowed } = state.user; - const { scale } = state.canvas; + const { + scale, + isHistoricalView, + } = state.canvas; + if (isHistoricalView) return; + const { x, y } = center; const cell = screenToWorld(state, viewport, [x, y]); diff --git a/src/components/HistorySelect.jsx b/src/components/HistorySelect.jsx index 52531700..0f6b7341 100644 --- a/src/components/HistorySelect.jsx +++ b/src/components/HistorySelect.jsx @@ -6,8 +6,10 @@ import React from 'react'; import { connect } from 'react-redux'; import type { State } from '../reducers'; +import { selectHistoricalTime } from '../actions'; function dateToString(date) { + // YYYY-MM-DD const timeString = date.substr(0, 4) + date.substr(5, 2) + date.substr(8, 2); return timeString; } @@ -60,9 +62,13 @@ class HistorySelect extends React.Component { }); const { canvasId, + setTime, } = this.props; - const date = dateToString(evt.target.value) + const date = dateToString(evt.target.value); const times = await getTimes(date, canvasId); + if (times.length > 0) { + setTime(date, times[0]); + } this.setState({ submitting: false, selectedDate: date, @@ -111,9 +117,7 @@ function mapDispatchToProps(dispatch) { return { setTime(date: string, time: string) { const timeString = time.substr(0, 2) + time.substr(-2, 2); - const dateString = dateToString(date); - console.log(`${timeString} - ${dateString}`); - // dispatch(selectHistoricalTime(dateString, timeString)); + dispatch(selectHistoricalTime(date, timeString)); }, }; } diff --git a/src/reducers/canvas.js b/src/reducers/canvas.js index 995c360f..32323bc1 100644 --- a/src/reducers/canvas.js +++ b/src/reducers/canvas.js @@ -34,6 +34,8 @@ export type CanvasState = { requested: Set, fetchs: number, isHistoricalView: boolean, + historicalDate: string, + historicalTime: string, // object with all canvas informations from all canvases like colors and size canvases: Object, }; @@ -108,6 +110,8 @@ const initialState: CanvasState = { requested: new Set(), fetchs: 0, isHistoricalView: false, + historicalDate: null, + historicalTime: null, }; @@ -180,11 +184,27 @@ export default function gui( }; } - case 'TOGGLE_HISTORICAL_VIEW': { + case 'SET_HISTORICAL_TIME': { + const { + date, + time, + } = action; return { ...state, - scale: 1.0, - viewscale: 1.0, + historicalDate: date, + historicalTime: time, + }; + } + + case 'TOGGLE_HISTORICAL_VIEW': { + const { + scale, + viewscale, + } = state; + return { + ...state, + scale: (scale < 1.0) ? 1.0 : scale, + viewscale: (viewscale < 1.0) ? 1.0 : viewscale, isHistoricalView: !state.isHistoricalView, }; } @@ -280,7 +300,6 @@ export default function gui( return { ...state, - chunks, fetchs: fetchs + 1, }; } diff --git a/src/routes/api/history.js b/src/routes/api/history.js index 2e5ae8c2..110a7014 100644 --- a/src/routes/api/history.js +++ b/src/routes/api/history.js @@ -20,13 +20,12 @@ async function history(req: Request, res: Response) { res.status(404).end(); } - const dirs = fs.readdirSync(path) - const filteredDir = dirs.filter(item => item !== 'tiles') + const dirs = fs.readdirSync(path); + const filteredDir = dirs.filter((item) => item !== 'tiles'); res.set({ 'Cache-Control': `public, max-age=${60 * 60}`, // seconds }); res.json(filteredDir); - } catch { res.status(404).end(); } diff --git a/src/store/rendererHook.js b/src/store/rendererHook.js index 18740706..c99f2bfd 100644 --- a/src/store/rendererHook.js +++ b/src/store/rendererHook.js @@ -7,12 +7,19 @@ import renderer from '../ui/Renderer'; export default (store) => (next) => (action) => { + const { type } = action; + + if (type == 'SET_HISTORICAL_TIME') { + const state = store.getState(); + renderer.updateOldHistoricalTime(state.canvas.historicalTime); + } + // executed after reducers const ret = next(action); const state = store.getState(); - switch (action.type) { + switch (type) { case 'RELOAD_URL': case 'SELECT_CANVAS': case 'RECEIVE_ME': { @@ -20,6 +27,7 @@ export default (store) => (next) => (action) => { break; } + case 'SET_HISTORICAL_TIME': case 'REQUEST_BIG_CHUNK': case 'RECEIVE_BIG_CHUNK': case 'RECEIVE_BIG_CHUNK_FAILURE': diff --git a/src/ui/ChunkRGB.js b/src/ui/ChunkRGB.js index d0556353..8d3c137e 100644 --- a/src/ui/ChunkRGB.js +++ b/src/ui/ChunkRGB.js @@ -28,6 +28,7 @@ class ChunkRGB { this.cell = cell; this.key = ChunkRGB.getKey(...cell); this.ready = false; + this.isEmpty = false; this.timestamp = Date.now(); } @@ -90,6 +91,7 @@ class ChunkRGB { empty() { this.ready = true; + this.isEmpty = true; const { image, palette } = this; const ctx = image.getContext('2d'); // eslint-disable-next-line prefer-destructuring diff --git a/src/ui/Renderer.js b/src/ui/Renderer.js index cf7d6678..01f097cf 100644 --- a/src/ui/Renderer.js +++ b/src/ui/Renderer.js @@ -11,7 +11,11 @@ import { getTileOfPixel, getPixelFromChunkOffset, } from '../core/utils'; -import { fetchChunk, fetchTile } from '../actions'; +import { + fetchChunk, + fetchTile, + fetchHistoricalChunk, +} from '../actions'; import { renderGrid, @@ -46,6 +50,8 @@ class Renderer { forceNextSubrender: boolean; canvas: HTMLCanvasElement; lastFetch: number; + //-- + oldHistoricalTime: string; constructor() { this.centerChunk = [null, null]; @@ -56,6 +62,7 @@ class Renderer { this.forceNextRender = true; this.forceNextSubrender = true; this.lastFetch = 0; + this.oldHistoricalTime = null; //-- this.canvas = document.createElement('canvas'); this.canvas.width = CANVAS_WIDTH; @@ -91,10 +98,13 @@ class Renderer { view, canvasSize, } = state.canvas; - this.tiledZoom = canvasMaxTiledZoom + Math.log2(this.tiledScale) / 2; this.updateScale(viewscale, canvasMaxTiledZoom, view, canvasSize); } + updateOldHistoricalTime(historicalTime: string) { + this.oldHistoricalTime = historicalTime; + } + updateScale( viewscale: number, canvasMaxTiledZoom: number, @@ -229,7 +239,6 @@ class Renderer { const CHUNK_RENDER_RADIUS_Y = Math.ceil(height / TILE_SIZE / 2 / relScale); // If scale is so large that neighbouring chunks wouldn't fit in canvas, // do scale = 1 and scale in render() - // TODO this is not working if (scale > SCALE_THREASHOLD) relScale = 1.0; // scale context.save(); @@ -298,13 +307,22 @@ class Renderer { } + render() { + const state: State = this.store.getState(); + return (state.canvas.isHistoricalView) + ? this.renderHistorical(state) + : this.renderMain(state); + } + + // keep in mind that everything we got here gets executed 60 times per second // avoiding unneccessary stuff is important - render() { + renderMain( + state: State, + ) { const { viewport, } = this; - const state: State = this.store.getState(); const { showGrid, showPixelNotify, @@ -387,6 +405,204 @@ class Renderer { if (hover && doRenderPlaceholder) renderPlaceholder(state, viewport, viewscale); if (hover && doRenderPotatoPlaceholder) renderPotatoPlaceholder(state, viewport, viewscale); } + + + renderHistoricalChunks( + state: State, + ) { + const context = this.canvas.getContext('2d'); + if (!context) return; + + const { + centerChunk: chunkPosition, + viewport, + oldHistoricalTime, + } = this; + const { + viewscale, + canvasId, + canvasSize, + chunks, + historicalDate, + historicalTime, + } = state.canvas; + + + // clear rect is just needed for Google Chrome, else it would flash regularly + context.clearRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT); + // Disable smoothing + // making it dependent on the scale is needed for Google Chrome, else scale <1 would look shit + if (viewscale >= 1) { + context.msImageSmoothingEnabled = false; + context.webkitImageSmoothingEnabled = false; + context.imageSmoothingEnabled = false; + } else { + context.msImageSmoothingEnabled = true; + context.webkitImageSmoothingEnabled = true; + context.imageSmoothingEnabled = true; + } + + const scale = (viewscale > SCALE_THREASHOLD) ? 1.0 : viewscale; + // define how many chunks we will render + // don't render chunks outside of viewport + const { width, height } = viewport; + const CHUNK_RENDER_RADIUS_X = Math.ceil(width / TILE_SIZE / 2 / scale); + const CHUNK_RENDER_RADIUS_Y = Math.ceil(height / TILE_SIZE / 2 / scale); + + context.save(); + context.fillStyle = '#C4C4C4'; + // clear canvas and do nothing if no time selected + if (!historicalDate || !historicalTime) { + context.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT); + context.restore(); + return; + } + // scale + context.scale(scale, scale); + // decide if we will fetch missing chunks + // and update the timestamps of accessed chunks + const curTime = Date.now(); + let fetch = false; + if (curTime > this.lastFetch + 150) { + this.lastFetch = curTime; + fetch = true; + } + + const xOffset = CANVAS_WIDTH / 2 / scale - TILE_SIZE / 2; + const yOffset = CANVAS_HEIGHT / 2 / scale - TILE_SIZE / 2; + + const [xc, yc] = chunkPosition; // center chunk + // CLEAN margin + // draw chunks. If not existing, just clear. + let chunk: ChunkRGB; + let key: string; + for (let dx = -CHUNK_RENDER_RADIUS_X; dx <= CHUNK_RENDER_RADIUS_X; dx += 1) { + for (let dy = -CHUNK_RENDER_RADIUS_Y; dy <= CHUNK_RENDER_RADIUS_Y; dy += 1) { + const cx = xc + dx; + const cy = yc + dy; + const x = xOffset + dx * TILE_SIZE; + const y = yOffset + dy * TILE_SIZE; + + const chunkMaxXY = canvasSize / TILE_SIZE; + if (cx < 0 || cx >= chunkMaxXY || cy < 0 || cy >= chunkMaxXY) { + // if out of bounds + context.fillRect(x, y, TILE_SIZE, TILE_SIZE); + } else { + // full chunks + key = ChunkRGB.getKey(historicalDate, cx, cy); + chunk = chunks.get(key); + if (chunk) { + // render new chunk + if (chunk.ready) { + context.drawImage(chunk.image, x, y); + if (fetch) chunk.timestamp = curTime; + } else if (loadingTiles.hasTiles) { + context.drawImage(loadingTiles.getTile(canvasId), x, y); + } else { + context.fillRect(x, y, TILE_SIZE, TILE_SIZE); + } + } else { + // we don't have that chunk + if (fetch) { + this.store.dispatch(fetchHistoricalChunk(canvasId, [cx, cy], historicalDate, null)); + } + if (loadingTiles.hasTiles) { + context.drawImage(loadingTiles.getTile(canvasId), x, y); + } else { + context.fillRect(x, y, TILE_SIZE, TILE_SIZE); + } + } + // incremential chunks + key = ChunkRGB.getKey(`${historicalDate}${historicalTime}`, cx, cy); + chunk = chunks.get(key); + if (chunk) { + // render new chunk + if (!chunk.ready && oldHistoricalTime) { + // redraw previous incremential chunk if new one is not there yet + key = ChunkRGB.getKey(`${historicalDate}${oldHistoricalTime}`, cx, cy); + chunk = chunks.get(key); + } + if (chunk && chunk.ready && !chunk.isEmpty) { + context.drawImage(chunk.image, x, y); + if (fetch) chunk.timestamp = curTime; + } + } else { + if (fetch) { + // we don't have that chunk + this.store.dispatch(fetchHistoricalChunk(canvasId, [cx, cy], historicalDate, historicalTime)); + } + if (oldHistoricalTime) { + key = ChunkRGB.getKey(`${historicalDate}${oldHistoricalTime}`, cx, cy); + chunk = chunks.get(key); + if (chunk && chunk.ready && !chunk.isEmpty) { + context.drawImage(chunk.image, x, y); + } + } + } + } + } + } + context.restore(); + } + + + // keep in mind that everything we got here gets executed 60 times per second + // avoiding unneccessary stuff is important + renderHistorical( + state: State, + ) { + const { + viewport, + } = this; + const { + showGrid, + isLightGrid, + } = state.gui; + const { + view, + viewscale, + canvasSize, + } = state.canvas; + + const [x, y] = view; + const [cx, cy] = this.centerChunk; + + if (!this.forceNextRender && !this.forceNextSubrender) { + return; + } + + if (this.forceNextRender) { + this.renderHistoricalChunks(state); + } + this.forceNextRender = false; + this.forceNextSubrender = false; + + const { width, height } = viewport; + const viewportCtx = viewport.getContext('2d'); + if (!viewportCtx) return; + + // canvas optimization: https://www.html5rocks.com/en/tutorials/canvas/performance/ + viewportCtx.msImageSmoothingEnabled = false; + viewportCtx.webkitImageSmoothingEnabled = false; + viewportCtx.imageSmoothingEnabled = false; + // If scale is so large that neighbouring chunks wouldn't fit in offscreen canvas, + // do scale = 1 in renderChunks and scale in render() + const canvasCenter = canvasSize / 2; + if (viewscale > SCALE_THREASHOLD) { + viewportCtx.save(); + viewportCtx.scale(viewscale, viewscale); + viewportCtx.drawImage(this.canvas, + width / 2 / viewscale - CANVAS_WIDTH / 2 + ((cx + 0.5) * TILE_SIZE - canvasCenter - x), + height / 2 / viewscale - CANVAS_HEIGHT / 2 + ((cy + 0.5) * TILE_SIZE - canvasCenter - y)); + viewportCtx.restore(); + } else { + viewportCtx.drawImage(this.canvas, + Math.floor(width / 2 - CANVAS_WIDTH / 2 + ((cx + 0.5) * TILE_SIZE - canvasCenter - x) * viewscale), + Math.floor(height / 2 - CANVAS_HEIGHT / 2 + ((cy + 0.5) * TILE_SIZE - canvasCenter - y) * viewscale)); + } + + if (showGrid && viewscale >= 8) renderGrid(state, viewport, viewscale, isLightGrid); + } }