Skip to content

Commit

Permalink
implement Countdown component (#17)
Browse files Browse the repository at this point in the history
* implement Countdown component

* fix typos

* implement arc animation

* fix merge issue

* fix merge issue

* refactor Countdown component
  • Loading branch information
Andrei Volchenko authored and kirilknysh committed Sep 24, 2017
1 parent 41648ba commit cec1205
Show file tree
Hide file tree
Showing 9 changed files with 291 additions and 1 deletion.
3 changes: 2 additions & 1 deletion .babelrc
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"plugins": [
["transform-react-jsx", { "pragma": "h" }],
"styled-jsx/babel"
"styled-jsx/babel",
"transform-class-properties"
]
}
1 change: 1 addition & 0 deletions .storybook/stories.js
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
require('../src/components/Header/Header.story');
require('../src/components/Countdown/Countdown.story.js');
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"@storybook/react": "^3.2.8",
"babel-core": "^6.26.0",
"babel-loader": "^7.1.2",
"babel-plugin-transform-class-properties": "^6.24.1",
"babel-plugin-transform-react-jsx": "^6.24.1",
"common-tags": "^1.4.0",
"inquirer": "^3.3.0",
Expand Down
41 changes: 41 additions & 0 deletions src/components/Countdown/Animation.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
class Animation {
constructor({ from, to, duration }) {
if (Animation._instance) {
Animation._instance.stopAnimation();
}

Animation._instance = this;

this.from = from;
this.to = to;
this.duration = duration;
this.valueDiff = to - from;
}

animate(animationFrameCallback) {
this.animationStart = Date.now();
this.animationFrameCallback = animationFrameCallback;
this._nextAnimationFrame();
}

stopAnimation() {
cancelAnimationFrame(this.rafId);
}

_nextAnimationFrame = () => {
let progress = (Date.now() - this.animationStart) / this.duration;

if (progress > 1) {
progress = 1;
}

const v = this.from + (this.valueDiff * progress);
this.animationFrameCallback(v);

if (progress < 1) {
this.rafId = requestAnimationFrame(this._nextAnimationFrame);
}
}
}

export { Animation };
31 changes: 31 additions & 0 deletions src/components/Countdown/Arc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { h, Component } from 'preact';

class Arc extends Component {
update() {
this.center = this.props.size / 2;
this.strokeSize = this.props.size * this.props.strokeSize;
this.radius = (this.props.size - this.strokeSize) / 2;

this.props.ctx.lineWidth = this.strokeSize;
this.props.ctx.strokeStyle = this.props.color;
this.props.ctx.lineCap = 'round';
}

renderToCanvas() {
const { ctx } = this.props;

ctx.beginPath();

ctx.arc(
this.center,
this.center,
this.radius, -Math.PI / 2,
Math.PI * 2 * this.props.progress - Math.PI / 2,
);
ctx.stroke();

ctx.closePath();
}
}

export { Arc };
77 changes: 77 additions & 0 deletions src/components/Countdown/Canvas.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { h, Component, cloneElement } from 'preact';

class Canvas extends Component {
constructor(props) {
super(props);

this.canvas = document.createElement('canvas');
this.ctx = this.canvas.getContext('2d');
this.children = new Set();

// retina magic start
// see https://www.html5rocks.com/en/tutorials/canvas/hidpi/

const backingStoreRatio = this.ctx.webkitBackingStorePixelRatio || 1;
const devicePixelRatio = window.devicePixelRatio || 1;

this.ratio = devicePixelRatio / backingStoreRatio;
this.canvas.style.transformOrigin = '0 0';
this.canvas.style.transform = `scale(${1 / this.ratio}, ${1 / this.ratio})`;
// retina magic end
}

render() {
const children = this.props.children.map(child => {
return cloneElement(child, {
ctx: this.ctx,
ref: c => {
if (child.attributes.ref) {
child.attributes.ref(c);
}

if (c) {
this.children.add(c)
} else {
this.children.delete(c);
}
},
size: this.props.size * this.ratio,
});
});

return (
<div
ref={node => this.container = node}
style={{
width: `${this.props.size}px`,
height: `${this.props.size}px`,
}}
>
{ children }
</div>
);
}

componentDidMount() {
this.container.appendChild(this.canvas);
this._render();
}

componentDidUpdate() {
this._render();
}

_render() {
this.size = this.props.size * this.ratio;

this.canvas.width = this.size;
this.canvas.height = this.size;

this.children.forEach(child => {
child.update();
child.renderToCanvas();
});
}
}

export { Canvas };
66 changes: 66 additions & 0 deletions src/components/Countdown/Countdown.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { h, Component } from 'preact';

import { Canvas } from './Canvas';
import { TimeRemainingText } from './TimeRemainingText';
import { Arc } from './Arc';
import { Animation } from './Animation';

const TIMER_TICK_INTERVAL = 1000;

class Countdown extends Component {
render() {
return (
<Canvas ref={canvas => this.canvas = canvas} size={this.props.size}>
<Arc
color={this.props.arcColor}
strokeSize={this.props.strokeSize}
progress={1}
/>
<Arc
ref={arc => this.progressArc = arc}
color={this.props.remainingTimeArcColor}
strokeSize={this.props.strokeSize}
progress={this.props.timeRemaining / this.props.timeAmount}
/>
<TimeRemainingText
timeRemaining={this.props.timeRemaining}
size={this.props.size}
textFillColor={this.props.textFillColor}
/>
</Canvas>
);
}

componentDidUpdate(prevProps) {
if (this.props.timeRemaining === prevProps.timeRemaining) {
return;
}

if (this.props.timeRemaining === this.props.timeAmount) {
return;
}

const timeRemainingDiff = (prevProps.timeRemaining - this.props.timeRemaining) * 1000;
let duration;

if (timeRemainingDiff > 0 && timeRemainingDiff < TIMER_TICK_INTERVAL) {
duration = timeRemainingDiff
} else {
duration = TIMER_TICK_INTERVAL;
}

const { timeAmount } = this.props;

const from = prevProps.timeRemaining / timeAmount;
const to = this.props.timeRemaining / timeAmount;

const animation = new Animation({ from, to, duration });

animation.animate(v => {
this.progressArc.props.progress = v;
this.canvas._render();
});
}
}

export { Countdown };
40 changes: 40 additions & 0 deletions src/components/Countdown/Countdown.story.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { h } from 'preact';
import { storiesOf } from '@storybook/react';
import { number, color } from '@storybook/addon-knobs';

import { Countdown } from './Countdown';

storiesOf('Countdown', module)
.add('round start', () => (
<Countdown
size={number('size', 300)}
strokeSize={number('strokeSize', 0.03)}
textFillColor={color('textFillColor', '#f8d940')}
arcColor={color('arcColor', '#75a096')}
remainingTimeArcColor={color('remainingTimeArcColor', '#f8d940')}
timeAmount={number('timeAmount', 120)}
timeRemaining={number('timeRemaining', 120)}
/>
))
.add('some time elapsed', () => (
<Countdown
size={number('size', 300)}
strokeSize={number('strokeSize', 0.03)}
textFillColor={color('textFillColor', '#f8d940')}
arcColor={color('arcColor', '#75a096')}
remainingTimeArcColor={color('remainingTimeArcColor', '#f8d940')}
timeAmount={number('timeAmount', 120)}
timeRemaining={number('timeRemaining', 98)}
/>
))
.add('puzzle solved', () => (
<Countdown
size={number('size', 300)}
strokeSize={number('strokeSize', 0.03)}
textFillColor={color('textFillColor', '#95c547')}
arcColor={color('arcColor', '#75a096')}
remainingTimeArcColor={color('remainingTimeArcColor', '#95c547')}
timeAmount={number('timeAmount', 120)}
timeRemaining={number('timeRemaining', 98)}
/>
));;
32 changes: 32 additions & 0 deletions src/components/Countdown/TimeRemainingText.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { h, Component } from 'preact';

const COMPONENT_SIZE_TO_FONT_SIZE_RATIO = 4;

class TimeRemainingText extends Component {
update() {
this.fontSize = this.props.size / COMPONENT_SIZE_TO_FONT_SIZE_RATIO;

this.props.ctx.font = `${this.fontSize}px sans-serif`;
this.props.ctx.fillStyle = this.props.textFillColor;
this.props.ctx.textBaseline = 'middle'

this.text = this._formatText(this.props.timeRemaining);
const { width } = this.props.ctx.measureText(this.text);

this.textX = (this.props.size - width) / 2;
this.textY = this.props.size / 2;
}

renderToCanvas() {
this.props.ctx.fillText(this.text, this.textX, this.textY);
}

_formatText(timeRemaining) {
const minutes = Math.floor(timeRemaining / 60);
const seconds = timeRemaining - (minutes * 60);

return `${minutes}:${seconds < 10 ? '0' : ''}${seconds}`;
}
}

export { TimeRemainingText };

0 comments on commit cec1205

Please sign in to comment.