├── src
├── data
│ └── users.json
├── mock
│ └── users.json
├── react-app-env.d.ts
├── interfaces
│ └── ITwitterUser.ts
├── index.tsx
├── setupTests.ts
├── index.css
├── App.css
├── App.tsx
└── components
│ └── wheel
│ ├── Wheel.tsx
│ └── styles.css
├── .gitignore
├── tsconfig.json
├── public
├── index.html
└── confetti.js
├── tsconfig.node.json
├── node
├── generate.ts
└── fetchParticipants.ts
├── README.md
└── package.json
/src/data/users.json:
--------------------------------------------------------------------------------
1 | []
2 |
--------------------------------------------------------------------------------
/src/mock/users.json:
--------------------------------------------------------------------------------
1 | []
2 |
--------------------------------------------------------------------------------
/src/react-app-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/src/interfaces/ITwitterUser.ts:
--------------------------------------------------------------------------------
1 | export default interface ITwitterUser {
2 | username: string;
3 | handle: string;
4 | profileImage: string;
5 | }
6 |
--------------------------------------------------------------------------------
/src/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import './index.css';
4 | import App from './App';
5 |
6 | ReactDOM.render(
7 |
8 |
9 | ,
10 | document.getElementById('root')
11 | );
12 |
--------------------------------------------------------------------------------
/src/setupTests.ts:
--------------------------------------------------------------------------------
1 | // jest-dom adds custom jest matchers for asserting on DOM nodes.
2 | // allows you to do things like:
3 | // expect(element).toHaveTextContent(/react/i)
4 | // learn more: https://github.com/testing-library/jest-dom
5 | import '@testing-library/jest-dom/extend-expect';
6 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | /build
13 | /node_dist
14 |
15 | # misc
16 | .DS_Store
17 | .env.local
18 | .env.development.local
19 | .env.test.local
20 | .env.production.local
21 |
22 | npm-debug.log*
23 | yarn-debug.log*
24 | yarn-error.log*
25 |
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | body {
2 | margin: 0;
3 | width: 100%;
4 | height: 100%;
5 | position: absolute;
6 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu',
7 | 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
8 | -webkit-font-smoothing: antialiased;
9 | -moz-osx-font-smoothing: grayscale;
10 | overflow: hidden;
11 | }
12 |
13 | code {
14 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
15 | }
16 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "esModuleInterop": true,
8 | "allowSyntheticDefaultImports": true,
9 | "strict": true,
10 | "forceConsistentCasingInFileNames": true,
11 | "module": "esnext",
12 | "moduleResolution": "node",
13 | "resolveJsonModule": true,
14 | "isolatedModules": true,
15 | "noEmit": true,
16 | "jsx": "react"
17 | },
18 | "include": ["src"]
19 | }
20 |
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | Twitter Winner Picker
10 |
11 |
12 |
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "outDir": "node_dist",
6 | "allowJs": true,
7 | "skipLibCheck": true,
8 | "esModuleInterop": true,
9 | "allowSyntheticDefaultImports": true,
10 | "strict": true,
11 | "resolveJsonModule": true,
12 | "forceConsistentCasingInFileNames": true,
13 | "module": "commonjs",
14 | "moduleResolution": "node",
15 | "declaration": true
16 | },
17 | "include": ["node"],
18 | "exclude": ["node_modules", "node_dist"]
19 | }
20 |
--------------------------------------------------------------------------------
/node/generate.ts:
--------------------------------------------------------------------------------
1 | import fs from 'fs';
2 | import { resolve } from 'path';
3 |
4 | /**
5 | * Set the amount of mock-users to create
6 | */
7 | const USERS_AMOUNT = 500;
8 |
9 | const profileImages = [
10 | 'https://randomuser.me/api/portraits/med/men/18.jpg',
11 | 'https://randomuser.me/api/portraits/med/men/7.jpg',
12 | 'https://randomuser.me/api/portraits/med/women/48.jpg',
13 | 'https://randomuser.me/api/portraits/med/men/60.jpg',
14 | 'https://randomuser.me/api/portraits/med/men/98.jpg',
15 | 'https://randomuser.me/api/portraits/med/women/46.jpg',
16 | 'https://randomuser.me/api/portraits/med/men/71.jpg',
17 | 'https://randomuser.me/api/portraits/med/men/43.jpg',
18 | 'https://randomuser.me/api/portraits/med/women/43.jpg',
19 | 'https://randomuser.me/api/portraits/med/women/20.jpg',
20 | ];
21 |
22 | const users = [];
23 |
24 | for (let i = 0; i < USERS_AMOUNT; i++) {
25 | const user = {
26 | username: `User Userson-${i}`,
27 | handle: `@user_name_${i}`,
28 | profileImage: profileImages[Math.floor(Math.random() * profileImages.length)],
29 | };
30 |
31 | users.push(user);
32 | }
33 |
34 | fs.writeFileSync(resolve(__dirname, '../src/mock/users.json'), JSON.stringify(users, null, 2));
35 |
--------------------------------------------------------------------------------
/src/App.css:
--------------------------------------------------------------------------------
1 | .App {
2 | width: 100%;
3 | height: 100%;
4 | position: absolute;
5 | text-align: center;
6 | display: flex;
7 | justify-content: center;
8 | align-items: center;
9 | transition: color 1s;
10 | }
11 |
12 | .winner-container {
13 | width: 100px;
14 | height: 100px;
15 | border-radius: 50%;
16 | position: absolute;
17 | top: 50%;
18 | left: 50%;
19 | z-index: 25;
20 | margin-top: -50px;
21 | margin-left: 290px;
22 | transition: 2.5s;
23 | background-size: cover;
24 | opacity: 0;
25 | }
26 |
27 | .show {
28 | width: 400px;
29 | height: 400px;
30 | margin-top: -200px;
31 | margin-left: -200px;
32 | opacity: 1;
33 | }
34 |
35 | .text-container {
36 | width: 400px;
37 | height: 100px;
38 | position: absolute;
39 | top: 70%;
40 | left: 50%;
41 | margin-left: -200px;
42 | transition: 2.5s;
43 | display: flex;
44 | flex-direction: column;
45 | justify-content: space-evenly;
46 | }
47 |
48 | .text {
49 | font-family: 'Lato', 'Quicksand', sans-serif;
50 | color: #2e2044;
51 | font-size: 38px;
52 | }
53 |
54 | .subtext {
55 | font-family: 'Lato', 'Quicksand', sans-serif;
56 | color: #2e2044;
57 | font-size: 32px;
58 | }
59 |
60 | canvas#canvas {
61 | display: block;
62 | background: #f4f5f7;
63 | transition: 2s;
64 | }
65 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ## Twitter (Random) Winner Picker
2 |
3 | 
4 |
5 | This app was created as part of my [10K giveaway on Twitter](https://twitter.com/SimonHoiberg/status/1292831801648525314).
6 |
7 | The rules for the competition is:
8 |
9 | - Like
10 | - Retweet
11 | - Follow
12 |
13 | Feel free to use it in a similar competition.
14 |
15 | ### Generate mock data
16 |
17 | ```console
18 | npm run generate
19 | ```
20 |
21 | Generate some mock-data for the wheel.
22 | The mock-data will be found in `src/mock/users.json`.
23 | Go to `node/generate.ts` to configure the generation.
24 |
25 | ### Fetch participants
26 |
27 | ```console
28 | npm run fetch
29 | ```
30 |
31 | Fetch the participants from Twitter.
32 | This command will go through each of all your followers, and check if they both liked and retweeted.
33 |
34 | > :warning: This can potentially take long time, depending on how many followers you have.
35 | > For me, it took almost 3 days to go through them all.
36 |
37 | The data will be found in `src/data/users.json`.
38 | Go to `node/fetchParticipants.ts` to configure the twitter client.
39 |
40 | ### Start the wheel
41 |
42 | In the `App.tsx` file, you can choose to import either the `mock/users.json` or `data/users.json`.
43 | Start the wheel application with
44 |
45 | ```console
46 | npm start
47 | ```
48 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "twitter-winner-picker",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "@testing-library/jest-dom": "^4.2.4",
7 | "@testing-library/react": "^9.5.0",
8 | "@testing-library/user-event": "^7.2.1",
9 | "@types/jest": "^24.9.1",
10 | "@types/node": "^12.12.54",
11 | "@types/randomcolor": "^0.5.5",
12 | "@types/react": "^16.9.46",
13 | "@types/react-dom": "^16.9.8",
14 | "randomcolor": "^0.6.2",
15 | "react": "^16.13.1",
16 | "react-dom": "^16.13.1",
17 | "react-scripts": "3.4.1",
18 | "twitter-api-client": "0.1.9",
19 | "typescript": "^3.7.5"
20 | },
21 | "scripts": {
22 | "start": "react-scripts start",
23 | "build": "react-scripts build",
24 | "build:node": "tsc -p tsconfig.node.json",
25 | "test": "react-scripts test",
26 | "eject": "react-scripts eject",
27 | "generate": "npm run build:node && node ./node_dist/generate.js",
28 | "fetch": "npm run build:node && node ./node_dist/fetchParticipants.js"
29 | },
30 | "eslintConfig": {
31 | "extends": "react-app"
32 | },
33 | "browserslist": {
34 | "production": [
35 | ">0.2%",
36 | "not dead",
37 | "not op_mini all"
38 | ],
39 | "development": [
40 | "last 1 chrome version",
41 | "last 1 firefox version",
42 | "last 1 safari version"
43 | ]
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/src/App.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from 'react';
2 | import ITwitterUser from './interfaces/ITwitterUser';
3 | import Wheel from './components/wheel/Wheel';
4 | import users from './mock/users.json';
5 | import './App.css';
6 |
7 | function App() {
8 | const [winner, setWinner] = useState();
9 | const [showWinner, setShowWinner] = useState(false);
10 |
11 | useEffect(() => {
12 | if (!winner) {
13 | return;
14 | }
15 |
16 | setTimeout(() => {
17 | setShowWinner(true);
18 | }, 20000);
19 | }, [winner]);
20 |
21 | const handleSelectUser = (index: number) => {
22 | const winnerFromIndex = users[index];
23 | setWinner(winnerFromIndex);
24 | };
25 |
26 | const winnerStyle = winner ? { backgroundImage: `url(${winner.profileImage})` } : {};
27 |
28 | return (
29 |
30 |
31 |
32 |
33 |
{winner?.username}
34 |
{winner?.handle}
35 |
36 |
37 |
38 | );
39 | }
40 |
41 | export default App;
42 |
--------------------------------------------------------------------------------
/src/components/wheel/Wheel.tsx:
--------------------------------------------------------------------------------
1 | import React, { FC, useState, CSSProperties, useEffect } from 'react';
2 | import randomColor from 'randomcolor';
3 | import ITwitterUser from '../../interfaces/ITwitterUser';
4 | import './styles.css';
5 |
6 | interface IProps {
7 | twitterUsers: ITwitterUser[];
8 | onSelectUser: (index: number) => void;
9 | hide?: boolean;
10 | }
11 |
12 | let colors: string[];
13 |
14 | const Wheel: FC = (props) => {
15 | const [spinning, setSpinning] = useState(false);
16 | const [selectedUser, setSelectedUser] = useState();
17 | const [visibleWheel, setVisibleWheel] = useState(0);
18 |
19 | useEffect(() => {
20 | if (selectedUser) {
21 | const i = setInterval(() => {
22 | const newVisibleWheel = Math.floor(Math.random() * chunks.length);
23 | setVisibleWheel(newVisibleWheel);
24 | }, 1500);
25 |
26 | setTimeout(() => {
27 | clearInterval(i);
28 | const lastChunk = Math.floor(selectedUser / 10);
29 | setVisibleWheel(lastChunk);
30 | }, 18000);
31 | }
32 | }, [selectedUser]);
33 |
34 | const chunkArray = (arr: ITwitterUser[], n: number): ITwitterUser[][] =>
35 | arr.length ? [arr.slice(0, n), ...chunkArray(arr.slice(n), n)] : [];
36 |
37 | const chunks = chunkArray(props.twitterUsers, 10);
38 |
39 | if (!colors) {
40 | colors = randomColor({ luminosity: 'dark', count: chunks.length });
41 | }
42 |
43 | const selectUser = () => {
44 | if (selectedUser) {
45 | return;
46 | }
47 |
48 | const newSelectedUser = Math.floor(Math.random() * props.twitterUsers.length);
49 | props.onSelectUser(newSelectedUser);
50 | setSelectedUser(newSelectedUser);
51 | setSpinning(true);
52 | };
53 |
54 | const wheelVars = {
55 | '--nb-item': 10,
56 | '--selected-item': selectedUser,
57 | } as CSSProperties;
58 |
59 | const renderWheels = () => {
60 | return chunks.map((chunk, i) => (
61 |
69 |
73 | {chunk.map((item, index) => (
74 |
75 |
76 |
77 |
78 | {item.username}
79 |
80 |
@{item.handle}
81 |
82 |
86 |
87 |
88 | ))}
89 |
94 |
95 |
96 | ));
97 | };
98 |
99 | return <>{renderWheels()}>;
100 | };
101 |
102 | export default Wheel;
103 |
--------------------------------------------------------------------------------
/src/components/wheel/styles.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --wheel-font: 'Lato', 'Quicksand', sans-serif;
3 | --wheel-size: 800px;
4 | --wheel-slice-spacing: 50px;
5 | --wheel-border-size: 5px;
6 | --wheel-color: #3a3a3a;
7 | --neutral-color: white;
8 | --PI: 3.14159265358979;
9 | --nb-item: 0;
10 | --item-nb: 0;
11 | --selected-item: 0;
12 | --nb-turn: 2;
13 | --spinning-duration: 20s;
14 | --reset-duration: 0.25s;
15 | }
16 |
17 | .wheel-container {
18 | display: block;
19 | position: absolute;
20 | box-sizing: content-box;
21 | width: calc(var(--wheel-size) + 2 * var(--wheel-border-size));
22 | height: calc(var(--wheel-size) + 2 * var(--wheel-border-size));
23 | padding: 3px;
24 | margin: auto;
25 | border-radius: 50%;
26 | user-select: none;
27 | transition: opacity 0.3s;
28 | }
29 |
30 | .wheel-container::before,
31 | .wheel-container::after {
32 | content: '';
33 | display: block;
34 | position: absolute;
35 | height: 0;
36 | width: 0;
37 | top: 50%;
38 | transform: translateY(-50%);
39 | z-index: 2;
40 | border: solid transparent 20px;
41 | border-left-width: 0;
42 | }
43 |
44 | .wheel-container::before {
45 | right: 0px;
46 | border-right-color: inherit;
47 | }
48 |
49 | .wheel-container::after {
50 | right: -5px;
51 | border-right-color: var(--neutral-color);
52 | }
53 |
54 | .wheel {
55 | display: block;
56 | position: relative;
57 | box-sizing: content-box;
58 | margin: auto;
59 | width: var(--wheel-size);
60 | height: var(--wheel-size);
61 | overflow: hidden;
62 | border-radius: 50%;
63 | background-color: var(--wheel-color);
64 | transition: transform var(--reset-duration);
65 | transform: rotate(0deg);
66 | }
67 |
68 | .wheel.spinning {
69 | transition: transform var(--spinning-duration);
70 | transform: rotate(
71 | calc(var(--nb-turn) * 360deg + (-360deg * var(--selected-item) / var(--nb-item, 1)))
72 | );
73 | }
74 |
75 | .wheel-middle {
76 | width: 100px;
77 | height: 100px;
78 | border-radius: 50%;
79 | position: absolute;
80 | left: 50%;
81 | top: 50%;
82 | z-index: 10;
83 | border: 10px solid var(--wheel-color);
84 | cursor: pointer;
85 | margin-left: -60px;
86 | margin-top: -60px;
87 | background-color: white;
88 | }
89 |
90 | .wheel::after {
91 | display: block;
92 | position: absolute;
93 | content: '';
94 | background-color: var(--neutral-color);
95 | width: 25px;
96 | height: 25px;
97 | z-index: 2;
98 | top: 50%;
99 | left: 50%;
100 | transform: translate(-50%, -50%);
101 | border-radius: 50%;
102 | }
103 |
104 | .wheel-item {
105 | display: block;
106 | position: absolute;
107 | box-sizing: border-box;
108 | top: 50%;
109 | left: 50%;
110 | width: 50%;
111 | transform-origin: center left;
112 | transform: translateY(-50%) rotate(calc(var(--item-nb) * (360deg / var(--nb-item, 1))));
113 | color: var(--neutral-color);
114 | text-align: right;
115 | padding: 0 25px 0 50px;
116 | font-family: var(--wheel-font);
117 | }
118 |
119 | .wheel-item:before {
120 | content: ' ';
121 | display: block;
122 | position: absolute;
123 | box-sizing: border-box;
124 | z-index: -1;
125 | width: 0;
126 | height: 0;
127 | top: 50%;
128 | left: 50%;
129 | transform: translate(-50%, -50%);
130 | padding-left: 0px;
131 | opacity: 0.25;
132 |
133 | --slice-max-width: calc(var(--PI) * var(--wheel-size) + var(--wheel-size) / 2);
134 | --slice-width: calc((var(--slice-max-width) / var(--nb-item)) - var(--wheel-slice-spacing));
135 | border: solid transparent calc(var(--slice-width) / 2);
136 | border-left: solid transparent 0;
137 |
138 | border-right: solid var(--neutral-color) calc(var(--wheel-size) / 2);
139 | }
140 |
141 | .wheel-item-container {
142 | width: 70%;
143 | margin-left: 30%;
144 | display: flex;
145 | align-items: center;
146 | }
147 |
148 | .wheel-item-image {
149 | width: 30%;
150 | padding-bottom: 30%;
151 | border-radius: 50%;
152 | background-size: cover;
153 | background-position: center;
154 | }
155 |
156 | .wheel-item-text {
157 | width: 60%;
158 | margin-right: 25px;
159 | }
160 |
161 | .wheel-item-text div {
162 | width: 100%;
163 | }
164 |
--------------------------------------------------------------------------------
/node/fetchParticipants.ts:
--------------------------------------------------------------------------------
1 | import TwitterClient from 'twitter-api-client';
2 | import fs from 'fs';
3 | import { resolve } from 'path';
4 | import { promisify } from 'util';
5 |
6 | const TWEET_ID = '';
7 | const API_KEY = '';
8 | const API_SECRET = '';
9 | const ACCESS_TOKEN = '';
10 | const ACCESS_TOKEN_SECRET = '';
11 |
12 | const twitterClient = new TwitterClient({
13 | apiKey: API_KEY,
14 | apiSecret: API_SECRET,
15 | accessToken: ACCESS_TOKEN,
16 | accessTokenSecret: ACCESS_TOKEN_SECRET,
17 | });
18 |
19 | const writeFile = promisify(fs.writeFile);
20 |
21 | async function init() {
22 | await writeFile(resolve(__dirname, '../src/data/users.json'), '[]');
23 | const followers = await getFollowers();
24 |
25 | // We loop through all the users.
26 | // To avoid hitting the rate limit, we need to go through each one-by-one.
27 | for (const follower of followers) {
28 | console.log('Evaluation:', follower);
29 |
30 | const evaluate = async () => {
31 | const liked = await didLike(follower);
32 | const retweeted = await didRetweet(follower);
33 |
34 | if (liked && retweeted) {
35 | const [user] = await twitterClient.accountsAndUsers.usersLookup({ user_id: follower });
36 |
37 | const smallUser = {
38 | username: user.name,
39 | handle: user.screen_name,
40 | profileImage: user.profile_image_url.replace('normal', '400x400'),
41 | };
42 |
43 | const list = require('../src/data/users.json');
44 | list.push(smallUser);
45 |
46 | await writeFile(
47 | resolve(__dirname, '../src/data/users.json'),
48 | JSON.stringify(list, null, 2),
49 | );
50 | }
51 |
52 | console.log('Evaluation finished. Qualified: ', liked && retweeted);
53 | };
54 |
55 | try {
56 | await evaluate();
57 | } catch (error) {
58 | // If we hit a rate limit, we pause for 15 minutes and continue
59 | if (error.statusCode === 429) {
60 | console.log('Rate limit exceeded. Pausing for 15 minutes.');
61 | await new Promise((r) => setTimeout(r, 5 * 60 * 1000));
62 | console.log('10 minutes to go...');
63 | await new Promise((r) => setTimeout(r, 5 * 60 * 1000));
64 | console.log('5 minutes to go...');
65 | await new Promise((r) => setTimeout(r, 5.5 * 60 * 1000));
66 | console.log('Continuing...');
67 |
68 | try {
69 | await evaluate();
70 | } catch (error) {
71 | console.log('Evalution failed. Continuing...');
72 | }
73 | } else {
74 | console.log('Evalution failed. Continuing...');
75 | }
76 | }
77 |
78 | console.log('----------');
79 |
80 | await new Promise((r) => setTimeout(r, 3000));
81 | }
82 | }
83 |
84 | /**
85 | * Get all followers
86 | * @param cursor
87 | */
88 | async function getFollowers(cursor?: string) {
89 | const params = cursor ? { cursor } : {};
90 |
91 | const followerRequest = await twitterClient.accountsAndUsers.followersIds({
92 | stringify_ids: true,
93 | ...params,
94 | });
95 |
96 | if (!followerRequest) {
97 | return [];
98 | }
99 |
100 | let followers = (followerRequest.ids as any) as string[];
101 |
102 | if (followerRequest.next_cursor_str !== '0') {
103 | const nextFollowers = await getFollowers(followerRequest.next_cursor_str);
104 |
105 | if (nextFollowers) {
106 | followers = [...followers, ...nextFollowers];
107 | }
108 | }
109 |
110 | return followers;
111 | }
112 |
113 | /**
114 | * Returns true if the user liked the tweet
115 | * @param userID
116 | */
117 | async function didLike(userID: string) {
118 | const likes = await twitterClient.tweets.favoritesList({
119 | user_id: userID,
120 | max_id: TWEET_ID,
121 | count: 1,
122 | });
123 |
124 | const [like] = likes;
125 |
126 | return like?.id_str === TWEET_ID;
127 | }
128 |
129 | /**
130 | * Return true if the user retweetet the tweet
131 | * @param userID
132 | */
133 | async function didRetweet(userID: string) {
134 | const retweets = await twitterClient.tweets.statusesUserTimeline({
135 | user_id: userID,
136 | count: 200,
137 | include_rts: true,
138 | exclude_replies: true,
139 | });
140 |
141 | const isQuoted = retweets.some((rt) => rt.quoted_status?.id_str === TWEET_ID);
142 | const isRetweeted = retweets.some((rt) => rt.retweeted_status?.id_str === TWEET_ID);
143 |
144 | return isQuoted || isRetweeted;
145 | }
146 |
147 | init();
148 |
--------------------------------------------------------------------------------
/public/confetti.js:
--------------------------------------------------------------------------------
1 | (function () {
2 | 'use strict';
3 | window.addEventListener('load', function () {
4 | var canvas = document.getElementById('canvas');
5 |
6 | if (!canvas || !canvas.getContext) {
7 | return false;
8 | }
9 |
10 | /********************
11 | Random Number
12 | ********************/
13 |
14 | function rand(min, max) {
15 | return Math.floor(Math.random() * (max - min + 1) + min);
16 | }
17 |
18 | /********************
19 | Var
20 | ********************/
21 |
22 | var ctx = canvas.getContext('2d');
23 | var X = (canvas.width = window.innerWidth);
24 | var Y = (canvas.height = window.innerHeight);
25 | var mouseX = X / 2;
26 | var mouseY = Y / 2;
27 | var shapes = [];
28 | var shapeNum = 1000;
29 | var xNum = 2;
30 | var yNum = 5;
31 |
32 | if (X < 768) {
33 | shapeNum = 500;
34 | xNum = 1;
35 | yNum = 4;
36 | }
37 |
38 | /********************
39 | Animation
40 | ********************/
41 |
42 | window.requestAnimationFrame =
43 | window.requestAnimationFrame ||
44 | window.mozRequestAnimationFrame ||
45 | window.webkitRequestAnimationFrame ||
46 | window.msRequestAnimationFrame ||
47 | function (cb) {
48 | setTimeout(cb, 17);
49 | };
50 |
51 | /********************
52 | Shape
53 | ********************/
54 |
55 | function Shape(ctx, x, y) {
56 | this.ctx = ctx;
57 | this.init(x, y);
58 | }
59 |
60 | Shape.prototype.init = function (x, y) {
61 | this.x = x;
62 | this.y = y;
63 | this.r = 1;
64 | this.c = rand(0, 360);
65 | this.a = rand(0, 360);
66 | this.rad = (this.a * Math.PI) / 180;
67 | this.inA = Math.random();
68 | this.inR = Math.random() * Math.random() * Math.random();
69 | this.v = {
70 | x: Math.sin(this.rad) * xNum,
71 | y: Math.cos(this.rad) * yNum,
72 | };
73 | this.ga = Math.random();
74 | };
75 |
76 | Shape.prototype.draw = function () {
77 | var ctx = this.ctx;
78 | ctx.save();
79 | ctx.fillStyle = 'hsl(' + this.c + ', ' + '80%, 80%)';
80 | ctx.globalCompositeOperation = 'xor';
81 | ctx.globalAlpha = this.ga;
82 | ctx.translate(this.x + this.r / 2, this.y + this.r / 2);
83 | ctx.rotate(this.rad * 1.5);
84 | ctx.scale(Math.sin(this.rad), Math.cos(this.rad));
85 | ctx.translate(-this.x - this.r / 2, -this.y - this.r / 2);
86 | ctx.beginPath();
87 | ctx.rect(this.x, this.y, this.r, this.r);
88 | ctx.fill();
89 | ctx.restore();
90 | };
91 |
92 | Shape.prototype.resize = function () {
93 | this.x = X / 2;
94 | this.y = Y / 2;
95 | };
96 |
97 | Shape.prototype.updatePosition = function () {
98 | this.v.y += 0.01;
99 | this.x += this.v.x;
100 | this.y += this.v.y;
101 | };
102 |
103 | Shape.prototype.wrapPosition = function () {
104 | if (this.y - this.r > Y) {
105 | this.init(X / 2, Y / 2);
106 | }
107 | };
108 |
109 | Shape.prototype.updateParams = function (i) {
110 | if (i % 2 === 0) {
111 | this.a += this.inA;
112 | } else {
113 | this.a -= this.inA;
114 | }
115 | this.rad = (this.a * Math.PI) / 180;
116 | this.r += this.inR;
117 | };
118 |
119 | Shape.prototype.render = function (i) {
120 | this.updateParams(i);
121 | this.updatePosition();
122 | this.wrapPosition();
123 | this.draw();
124 | };
125 |
126 | for (var i = 0; i < 1; i++) {
127 | var s = new Shape(ctx, X / 2, Y / 2);
128 | shapes.push(s);
129 | }
130 |
131 | var clearId = setInterval(function () {
132 | var s = new Shape(ctx, X / 2, Y / 2);
133 | shapes.push(s);
134 | if (shapes.length > 1000) {
135 | clearInterval(clearId);
136 | }
137 | }, 60);
138 |
139 | /********************
140 | Render
141 | ********************/
142 |
143 | function render() {
144 | ctx.clearRect(0, 0, X, Y);
145 | for (var i = 0; i < shapes.length; i++) {
146 | shapes[i].render(i);
147 | }
148 | requestAnimationFrame(render);
149 | }
150 |
151 | render();
152 |
153 | /********************
154 | Event
155 | ********************/
156 |
157 | function onResize() {
158 | X = canvas.width = window.innerWidth;
159 | Y = canvas.height = window.innerHeight;
160 | if (X < 768) {
161 | shapeNum = 500;
162 | xNum = 1;
163 | yNum = 4;
164 | } else {
165 | shapeNum = 1000;
166 | xNum = 2;
167 | yNum = 5;
168 | }
169 | for (var i = 0; i < shapes.length; i++) {
170 | shapes[i].resize();
171 | }
172 | }
173 |
174 | window.addEventListener('resize', function () {
175 | onResize();
176 | });
177 |
178 | canvas.addEventListener(
179 | 'wheel',
180 | function (e) {
181 | for (var i = 0; i < shapes.length; i++) {}
182 | },
183 | false,
184 | );
185 | });
186 | // Author
187 | console.log(
188 | 'File Name / confetti.js\nCreated Date / July 08, 2020\nAuthor / Toshiya Marukubo\nTwitter / https://twitter.com/toshiyamarukubo',
189 | );
190 | })();
--------------------------------------------------------------------------------