add client render code for historical view

This commit is contained in:
HF 2020-01-10 01:50:27 +01:00
parent 1e1d511d21
commit 572408ba6a
10 changed files with 320 additions and 19 deletions

View File

@ -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).

View File

@ -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());

View File

@ -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<Action>;
export type Dispatch = (action: Action | ThunkAction | PromiseAction | Array<Action>) => any;

View File

@ -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]);

View File

@ -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));
},
};
}

View File

@ -34,6 +34,8 @@ export type CanvasState = {
requested: Set<string>,
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,
};
}

View File

@ -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();
}

View File

@ -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':

View File

@ -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

View File

@ -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);
}
}