forked from ppfun/pixelplanet
add client render code for historical view
This commit is contained in:
parent
1e1d511d21
commit
572408ba6a
10
README.md
10
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).
|
||||
|
|
|
@ -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());
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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]);
|
||||
|
||||
|
|
|
@ -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));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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':
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
Loading…
Reference in New Issue
Block a user