Add typings for Box and BoxesField classes, split BSoD view into controller and pure view, add storybook, fix support link styles

This commit is contained in:
ErickSkrauch 2020-01-15 23:39:53 +03:00
parent 32ebba63a1
commit 228bc048af
No known key found for this signature in database
GPG Key ID: 669339FCBB30EE0E
11 changed files with 271 additions and 204 deletions

View File

@ -0,0 +1,6 @@
import React from 'react';
import { storiesOf } from '@storybook/react';
import BSoD from './BSoD';
storiesOf('UI', module).add('BSoD', () => <BSoD />);

View File

@ -1,72 +1,56 @@
import React from 'react';
import React, { ComponentType } from 'react';
import { FormattedMessage as Message } from 'react-intl';
import logger from 'app/services/logger';
import appInfo from 'app/components/auth/appInfo/AppInfo.intl.json';
import styles from './styles.scss';
import BoxesField from './BoxesField';
import styles from './styles.scss';
import messages from './BSoD.intl.json';
interface State {
lastEventId?: string | void;
}
// TODO: probably it is better to render this view from the App view
// to remove dependencies from store and IntlProvider
class BSoD extends React.Component<{}, State> {
state: State = {};
componentDidMount() {
// poll for event id
const timer = setInterval(() => {
if (!logger.getLastEventId()) {
return;
}
clearInterval(timer);
this.setState({
lastEventId: logger.getLastEventId(),
});
}, 500);
}
render() {
const { lastEventId } = this.state;
let emailUrl = 'mailto:support@ely.by';
if (lastEventId) {
emailUrl += `?subject=Bug report for #${lastEventId}`;
}
return (
<div className={styles.body}>
<canvas
className={styles.canvas}
ref={(el: HTMLCanvasElement | null) => el && new BoxesField(el)}
/>
<div className={styles.wrapper}>
<div className={styles.title}>
<Message {...appInfo.appName} />
</div>
<div className={styles.lineWithMargin}>
<Message {...messages.criticalErrorHappened} />
</div>
<div className={styles.line}>
<Message {...messages.reloadPageOrContactUs} />
</div>
<a href={emailUrl} className={styles.support}>
support@ely.by
</a>
<div className={styles.easterEgg}>
<Message {...messages.alsoYouCanInteractWithBackground} />
</div>
</div>
</div>
);
}
interface Props {
lastEventId?: string;
}
// TODO: probably it's better to render this view from the App view
// to remove dependencies from the store and IntlProvider
const BSoD: ComponentType<Props> = ({ lastEventId }) => {
let emailUrl = 'mailto:support@ely.by';
if (lastEventId) {
emailUrl += `?subject=Bug report for #${lastEventId}`;
}
return (
<div className={styles.body}>
<canvas
className={styles.canvas}
ref={(el: HTMLCanvasElement | null) => el && new BoxesField(el)}
/>
<div className={styles.wrapper}>
<div className={styles.title}>
<Message {...appInfo.appName} />
</div>
<div className={styles.lineWithMargin}>
<Message {...messages.criticalErrorHappened} />
</div>
<div className={styles.line}>
<Message {...messages.reloadPageOrContactUs} />
</div>
<a href={emailUrl} className={styles.support}>
support@ely.by
</a>
<div className={styles.easterEgg}>
<Message {...messages.alsoYouCanInteractWithBackground} />
</div>
</div>
</div>
);
};
export default BSoD;

View File

@ -0,0 +1,29 @@
import React, { ComponentType, useEffect, useState } from 'react';
import logger from 'app/services/logger/logger';
import BSoD from './BSoD';
const BSoDContainer: ComponentType = () => {
const [lastEventId, setLastEventId] = useState<string>();
useEffect(() => {
const timer = setInterval(() => {
// eslint-disable-next-line no-shadow
const lastEventId = logger.getLastEventId();
if (!lastEventId) {
return;
}
clearInterval(timer);
setLastEventId(lastEventId);
}, 500);
// Don't care about interval cleanup because there is no way from
// BSoD state and page can be only reloaded
}, []);
return <BSoD lastEventId={lastEventId} />;
};
export default BSoDContainer;

View File

@ -1,100 +0,0 @@
export default class Box {
constructor({ size, startX, startY, startRotate, color, shadowColor }) {
this.color = color;
this.shadowColor = shadowColor;
this.halfSize = 0;
this.setSize(size);
this.x = startX;
this.y = startY;
this.angle = startRotate;
this.shadowLength = 2000; // TODO: should be calculated
}
get size() {
return this._initialSize;
}
get dots() {
const full = (Math.PI * 2) / 4;
const p1 = {
x: this.x + this.halfSize * Math.sin(this.angle),
y: this.y + this.halfSize * Math.cos(this.angle),
};
const p2 = {
x: this.x + this.halfSize * Math.sin(this.angle + full),
y: this.y + this.halfSize * Math.cos(this.angle + full),
};
const p3 = {
x: this.x + this.halfSize * Math.sin(this.angle + full * 2),
y: this.y + this.halfSize * Math.cos(this.angle + full * 2),
};
const p4 = {
x: this.x + this.halfSize * Math.sin(this.angle + full * 3),
y: this.y + this.halfSize * Math.cos(this.angle + full * 3),
};
return { p1, p2, p3, p4 };
}
rotate() {
const speed = (60 - this.halfSize) / 20;
this.angle += speed * 0.002;
this.x += speed;
this.y += speed;
}
draw(ctx) {
const { dots } = this;
ctx.beginPath();
ctx.moveTo(dots.p1.x, dots.p1.y);
ctx.lineTo(dots.p2.x, dots.p2.y);
ctx.lineTo(dots.p3.x, dots.p3.y);
ctx.lineTo(dots.p4.x, dots.p4.y);
ctx.fillStyle = this.color;
ctx.fill();
}
drawShadow(ctx, light) {
const { dots } = this;
const angles = [];
const points = [];
for (const i in dots) {
if (!dots.hasOwnProperty(i)) {
continue;
}
const dot = dots[i];
const angle = Math.atan2(light.y - dot.y, light.x - dot.x);
const endX = dot.x + this.shadowLength * Math.sin(-angle - Math.PI / 2);
const endY = dot.y + this.shadowLength * Math.cos(-angle - Math.PI / 2);
angles.push(angle);
points.push({
endX,
endY,
startX: dot.x,
startY: dot.y,
});
}
for (let i = points.length - 1; i >= 0; i--) {
const n = i === 3 ? 0 : i + 1;
ctx.beginPath();
ctx.moveTo(points[i].startX, points[i].startY);
ctx.lineTo(points[n].startX, points[n].startY);
ctx.lineTo(points[n].endX, points[n].endY);
ctx.lineTo(points[i].endX, points[i].endY);
ctx.fillStyle = this.shadowColor;
ctx.fill();
}
}
setSize(size) {
this._initialSize = size;
this.halfSize = Math.floor(size / 2);
}
}

View File

@ -0,0 +1,119 @@
import Point from './Point';
const shadowLength = 2000; // TODO: should be calculated
export default class Box {
public position: Point;
private angle: number;
public color: string;
private readonly shadowColor: string;
private _size: number;
private _halfSize: number;
constructor(
size: number,
position: Point,
startRotate: number,
color: string,
shadowColor: string,
) {
this.size = size;
this.position = position;
this.color = color;
this.angle = startRotate;
this.shadowColor = shadowColor;
}
public get size(): number {
return this._size;
}
public set size(size: number) {
this._size = size;
this._halfSize = Math.floor(size / 2);
}
public get halfSize(): number {
return this._halfSize;
}
get points(): { p1: Point; p2: Point; p3: Point; p4: Point } {
const full = (Math.PI * 2) / 4;
const p1: Point = {
x: this.position.x + this._halfSize * Math.sin(this.angle),
y: this.position.y + this._halfSize * Math.cos(this.angle),
};
const p2: Point = {
x: this.position.x + this._halfSize * Math.sin(this.angle + full),
y: this.position.y + this._halfSize * Math.cos(this.angle + full),
};
const p3: Point = {
x: this.position.x + this._halfSize * Math.sin(this.angle + full * 2),
y: this.position.y + this._halfSize * Math.cos(this.angle + full * 2),
};
const p4: Point = {
x: this.position.x + this._halfSize * Math.sin(this.angle + full * 3),
y: this.position.y + this._halfSize * Math.cos(this.angle + full * 3),
};
return { p1, p2, p3, p4 };
}
rotate(): void {
const speed = (60 - this._halfSize) / 20;
this.angle += speed * 0.002;
this.position.x += speed;
this.position.y += speed;
}
draw(ctx: CanvasRenderingContext2D): void {
const { points } = this;
ctx.beginPath();
ctx.moveTo(points.p1.x, points.p1.y);
ctx.lineTo(points.p2.x, points.p2.y);
ctx.lineTo(points.p3.x, points.p3.y);
ctx.lineTo(points.p4.x, points.p4.y);
ctx.fillStyle = this.color;
ctx.fill();
}
drawShadow(ctx: CanvasRenderingContext2D, light: Point): void {
const boxPoints = this.points;
const points: Array<{
startX: number;
startY: number;
endX: number;
endY: number;
}> = [];
// eslint-disable-next-line guard-for-in
for (const i in boxPoints) {
const point = boxPoints[i];
const angle = Math.atan2(light.y - point.y, light.x - point.x);
const endX = point.x + shadowLength * Math.sin(-angle - Math.PI / 2);
const endY = point.y + shadowLength * Math.cos(-angle - Math.PI / 2);
points.push({
startX: point.x,
startY: point.y,
endX,
endY,
});
}
for (let i = points.length - 1; i >= 0; i--) {
const n = i === 3 ? 0 : i + 1;
ctx.beginPath();
ctx.moveTo(points[i].startX, points[i].startY);
ctx.lineTo(points[n].startX, points[n].startY);
ctx.lineTo(points[n].endX, points[n].endY);
ctx.lineTo(points[i].endX, points[i].endY);
ctx.fillStyle = this.shadowColor;
ctx.fill();
}
}
}

View File

@ -1,16 +1,34 @@
import Point from './Point';
import Box from './Box';
interface Params {
countBoxes: number;
boxMinSize: number;
boxMaxSize: number;
backgroundColor: string;
lightColor: string;
shadowColor: string;
boxColors: ReadonlyArray<string>;
}
/**
* Основано на http://codepen.io/mladen___/pen/gbvqBo
* Based on http://codepen.io/mladen___/pen/gbvqBo
*/
export default class BoxesField {
private readonly elem: HTMLCanvasElement;
private readonly ctx: CanvasRenderingContext2D;
private readonly params: Params;
private light: Point;
private boxes: Array<Box>;
/**
* @param {HTMLCanvasElement} elem - canvas DOM node
* @param {object} params
*/
constructor(
elem,
params = {
elem: HTMLCanvasElement,
params: Params = {
countBoxes: 14,
boxMinSize: 20,
boxMaxSize: 75,
@ -31,7 +49,7 @@ export default class BoxesField {
const ctx = elem.getContext('2d');
if (!ctx) {
throw new Error('Can not get canvas 2d context');
throw new Error('Cannot get canvas 2d context');
}
this.ctx = ctx;
@ -46,35 +64,34 @@ export default class BoxesField {
this.drawLoop();
this.bindWindowListeners();
/**
* @type {Box[]}
*/
this.boxes = [];
while (this.boxes.length < this.params.countBoxes) {
this.boxes.push(
new Box({
size: Math.floor(
new Box(
Math.floor(
Math.random() * (this.params.boxMaxSize - this.params.boxMinSize) +
this.params.boxMinSize,
),
startX: Math.floor(Math.random() * elem.width + 1),
startY: Math.floor(Math.random() * elem.height + 1),
startRotate: Math.random() * Math.PI,
color: this.getRandomColor(),
shadowColor: this.params.shadowColor,
}),
{
x: Math.floor(Math.random() * elem.width + 1),
y: Math.floor(Math.random() * elem.height + 1),
},
Math.random() * Math.PI,
this.getRandomColor(),
this.params.shadowColor,
),
);
}
}
resize() {
resize(): void {
const { width, height } = this.elem.getBoundingClientRect();
this.elem.width = width;
this.elem.height = height;
}
drawLight(light) {
drawLight(light: Point): void {
const greaterSize =
window.screen.width > window.screen.height
? window.screen.width
@ -98,7 +115,7 @@ export default class BoxesField {
this.ctx.fill();
}
drawLoop() {
drawLoop(): void {
this.ctx.clearRect(0, 0, this.elem.width, this.elem.height);
this.drawLight(this.light);
@ -120,14 +137,20 @@ export default class BoxesField {
const box = this.boxes[i];
box.draw(this.ctx);
// Если квадратик вылетел за пределы экрана
if (box.y - box.halfSize > this.elem.height) {
box.y -= this.elem.height + 100;
this.updateBox(box);
// When box leaves window boundaries
let shouldUpdateBox = false;
if (box.position.y - box.halfSize > this.elem.height) {
box.position.y -= this.elem.height + 100;
shouldUpdateBox = true;
}
if (box.x - box.halfSize > this.elem.width) {
box.x -= this.elem.width + 100;
if (box.position.x - box.halfSize > this.elem.width) {
box.position.x -= this.elem.width + 100;
shouldUpdateBox = true;
}
if (shouldUpdateBox) {
this.updateBox(box);
}
}
@ -143,14 +166,11 @@ export default class BoxesField {
});
}
/**
* @param {Box} box
*/
updateBox(box) {
updateBox(box: Box): void {
box.color = this.getRandomColor();
}
getRandomColor() {
getRandomColor(): string {
return this.params.boxColors[
Math.floor(Math.random() * this.params.boxColors.length)
];

View File

@ -0,0 +1,4 @@
export default interface Point {
x: number;
y: number;
}

View File

@ -1,10 +0,0 @@
export const BSOD = 'BSOD';
/**
* @returns {object}
*/
export function bsod() {
return {
type: BSOD,
};
}

View File

@ -0,0 +1,9 @@
import { Action } from 'redux';
export const BSOD = 'BSOD';
export function bsod(): Action {
return {
type: BSOD,
};
}

View File

@ -5,7 +5,7 @@ import { Store } from 'app/reducers';
import { History } from 'history';
import { bsod } from './actions';
import BSoD from './BSoD';
import BSoDContainer from './BSoDContainer';
let injectedStore: Store;
let injectedHistory: History<any>;
@ -20,7 +20,7 @@ export default function dispatchBsod(
ReactDOM.render(
<ContextProvider store={store} history={history}>
<BSoD />
<BSoDContainer />
</ContextProvider>,
document.getElementById('app'),
);

View File

@ -1,7 +1,6 @@
@import '~app/components/ui/colors.scss';
$font-family-monospaced: 'Consolas', 'Menlo', 'DejaVu Sans Mono', 'Roboto Mono',
monospace;
$font-family-monospaced: 'Consolas', 'Menlo', 'DejaVu Sans Mono', 'Roboto Mono', monospace;
.body {
height: 100%;
@ -46,9 +45,16 @@ $font-family-monospaced: 'Consolas', 'Menlo', 'DejaVu Sans Mono', 'Roboto Mono',
.support {
font-size: 18px;
line-height: 18px;
color: #fff;
margin: 3px 0 44px;
display: block;
margin: 5px 0 44px;
display: inline-block;
border-bottom-color: #39777f;
&:hover {
color: #fff;
border-bottom-color: #eee;
}
}
.easterEgg {