├── .gitignore
├── LICENSE.txt
├── README.md
├── logo.xcf
├── package-lock.json
├── package.json
├── public
├── favicon-16x16.png
├── favicon-32x32.png
├── favicon.ico
└── index.html
├── src
├── App.js
├── App.test.js
├── AuthorStats.js
├── PlotlyComponent.js
├── RatingStats.js
├── ReadingStats.js
├── Section.js
├── SortableTable.js
├── Spinner.css
├── Spinner.js
├── Statistics.js
├── StatsComponent.js
├── css
│ ├── App.css
│ ├── index.css
│ └── tables.css
├── export_enhanced_full.csv
├── img
│ ├── error_smiley.png
│ └── logo.png
├── index.js
├── parseExport.js
├── registerServiceWorker.js
├── shared_plots.js
└── util.js
└── yarn.lock
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/ignore-files/ for more about ignoring files.
2 |
3 | # IDE files
4 | /.idea
5 |
6 | # dependencies
7 | /node_modules
8 |
9 | # testing
10 | /coverage
11 |
12 | # production
13 | /build
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 |
--------------------------------------------------------------------------------
/LICENSE.txt:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2017 Paul Klinger
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Bookstats
2 |
3 | Shows detailed statistics about users reading habits by analyzing their exported database from [goodreads](https://goodreads.com).
4 |
5 | Additional information (e.g. re-reading dates) can be added to the export file [using an external tool](https://github.com/PaulKlinger/Enhance-GoodReads-Export) and will be used in the statistics.
6 | If the "private notes" field contains a line of the form "words: 543" (i.e. matching the regex "^words: (\d+)$") this will be used instead of the default estimate of multiplying the page count by 270.
7 |
8 | Available at https://almoturg.com/bookstats
--------------------------------------------------------------------------------
/logo.xcf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PaulKlinger/Bookstats/8c945dca784f33201fa722de24db301db96f5c1d/logo.xcf
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "bookstats",
3 | "version": "0.1.0",
4 | "private": true,
5 | "homepage": "https://almoturg.com/bookstats",
6 | "dependencies": {
7 | "moment": "^2.27.0",
8 | "papaparse": "^5.3.0",
9 | "plotly.js": "^1.55.1",
10 | "react": "^15.5.4",
11 | "react-dom": "^15.5.4"
12 | },
13 | "devDependencies": {
14 | "react-scripts": "1.0.1"
15 | },
16 | "scripts": {
17 | "start": "react-scripts start",
18 | "build": "react-scripts build",
19 | "test": "react-scripts test --env=jsdom",
20 | "eject": "react-scripts eject"
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/public/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PaulKlinger/Bookstats/8c945dca784f33201fa722de24db301db96f5c1d/public/favicon-16x16.png
--------------------------------------------------------------------------------
/public/favicon-32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PaulKlinger/Bookstats/8c945dca784f33201fa722de24db301db96f5c1d/public/favicon-32x32.png
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PaulKlinger/Bookstats/8c945dca784f33201fa722de24db301db96f5c1d/public/favicon.ico
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | this.container = node} />
22 | }
23 | }
24 |
25 | export default PlotlyComponent
--------------------------------------------------------------------------------
/src/RatingStats.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Created by Paul on 2017-05-20.
3 | */
4 | import React, {Component} from 'react';
5 |
6 | import {ScatterPlot, Histogram, TimeLinePlot, DotViolin, MeanStdDev, TimeLineSlidingWindowPlot} from './shared_plots'
7 | import SortableTable from "./SortableTable"
8 | import {cmpNumNullLast} from "./util"
9 |
10 | export default class RatingStats extends Component {
11 | render() {
12 | return (
13 |
14 |
b.user_rating === null ? 0 : b.user_rating)}
16 | xaxis_title="your rating" tickvals={[0, 1, 2, 3, 4, 5]}
17 | ticktext={["not rated", 1, 2, 3, 4, 5]}/>
18 |
21 |
24 |
30 | b.user_rating > 0)
32 | .map(b => b.user_rating - b.average_rating)}
33 | xaxis_title="Your ★ - Avg. ★" yaxis_title="# Books"/>
34 |
35 | Δ ★ is the difference between your rating and the average rating.
36 | a.title < b.title ? -1 : a.title > b.title ? 1 : 0
41 | },
42 | {
43 | column: "author", name: "Author",
44 | cmpfunction: (a, b) => a.author_sort < b.author_sort ? -1 : a.author_sort > b.author_sort ? 1 : 0
45 | },
46 | {
47 | column: "user_rating", name: "Your ★",
48 | cmpfunction: (a, b) => cmpNumNullLast(a.user_rating, b.user_rating)
49 | },
50 | {
51 | column: "avg_rating_2prec", name: "Avg. ★",
52 | cmpfunction: (a, b) => cmpNumNullLast(a.avg_rating, b.avg_rating)
53 | },
54 | {
55 | column: "rating_diff_2prec", name: "Δ ★",
56 | cmpfunction: (a, b) => cmpNumNullLast(a.rating_diff, b.rating_diff)
57 | },
58 | {
59 | column: "reading_count", name: "# read",
60 | cmpfunction: (a, b) => a.reading_count - b.reading_count
61 | }
62 | ]} defaultSort={{column: "rating_diff_2prec", mult: -1}}/>
63 |
64 |
66 | {this.props.statistics.has_genres ?
67 |
70 | : ""}
71 |
72 |
73 | );
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/src/ReadingStats.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Created by Paul on 2017-05-21.
3 | */
4 | import React, {Component} from 'react';
5 |
6 | import {Bar, TimeLineSlidingWindowPlot, DotViolin, ScatterPlot} from './shared_plots.js'
7 |
8 |
9 | export default class ReadingStats extends Component {
10 | render() {
11 | return (
12 |
13 |
15 |
22 |
25 |
28 |
31 | {this.props.statistics.has_read_dates ?
32 |
35 | : ""}
36 |
39 |
40 |
41 | );
42 | }
43 | }
--------------------------------------------------------------------------------
/src/Section.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Created by Paul on 2017-05-27.
3 | */
4 |
5 | import React, { Component } from 'react';
6 |
7 | export default class Section extends Component {
8 | constructor(props) {
9 | super(props);
10 | this.state = {visible: props.defaultVisible};
11 | this.changeVisibility = this.changeVisibility.bind(this);
12 | }
13 |
14 | changeVisibility(){
15 | this.setState({visible: ! this.state.visible});
16 | }
17 |
18 | render() {
19 | return (
20 |
21 | {this.props.title} {this.state.visible ? "▲" : "▼"}
22 |
23 |
24 | {this.props.children}
25 |
26 |
);
27 | }
28 | }
--------------------------------------------------------------------------------
/src/SortableTable.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Created by Paul on 2017-05-26.
3 | */
4 | import React, {Component} from 'react';
5 |
6 | export default class SortableTable extends Component {
7 | // data = [{*columName1*: *value1*, ...},...]
8 | // columns = [{column: *columnName*, name: *header text*, cmpfunction: *(a,b) => ...*},...]
9 | // defaultSort = {column: *columName*, mult: 1/-1}
10 | constructor(props) {
11 | super(props);
12 | this.state = {sort: props.defaultSort};
13 | this.handleClick = this.handleClick.bind(this);
14 | }
15 |
16 | handleClick(e) {
17 | this.setState({
18 | sort: {
19 | column: e.target.name,
20 | mult: e.target.name === this.state.sort.column ? -this.state.sort.mult : -1
21 | }
22 | });
23 | }
24 |
25 | render() {
26 | let self = this;
27 | let sorted_data = this.props.data.sort((a, b) =>
28 | (this.state.sort.mult * this.props.columns.filter(c => c.column === this.state.sort.column)[0]
29 | .cmpfunction(a, b)));
30 | return (
31 |
49 | );
50 | }
51 | }
--------------------------------------------------------------------------------
/src/Spinner.css:
--------------------------------------------------------------------------------
1 | .spinner {
2 | display: inline-block;
3 | margin: 0;
4 | width: 50px;
5 | height: 40px;
6 | text-align: center;
7 | font-size: 10px;
8 | }
9 |
10 | .spinner > div {
11 | background-color: #1F76B4;
12 | margin-left: 3px;
13 | height: 100%;
14 | width: 6px;
15 | display: inline-block;
16 |
17 | -webkit-animation: sk-stretchdelay 1.2s infinite ease-in-out;
18 | animation: sk-stretchdelay 1.2s infinite ease-in-out;
19 | }
20 |
21 | .spinner .rect2 {
22 | -webkit-animation-delay: -1.1s;
23 | animation-delay: -1.1s;
24 | }
25 |
26 | .spinner .rect3 {
27 | -webkit-animation-delay: -1.0s;
28 | animation-delay: -1.0s;
29 | }
30 |
31 | .spinner .rect4 {
32 | -webkit-animation-delay: -0.9s;
33 | animation-delay: -0.9s;
34 | }
35 |
36 | .spinner .rect5 {
37 | -webkit-animation-delay: -0.8s;
38 | animation-delay: -0.8s;
39 | }
40 |
41 | @-webkit-keyframes sk-stretchdelay {
42 | 0%, 40%, 100% { -webkit-transform: scaleY(0.4) }
43 | 20% { -webkit-transform: scaleY(1.0) }
44 | }
45 |
46 | @keyframes sk-stretchdelay {
47 | 0%, 40%, 100% {
48 | transform: scaleY(0.4);
49 | -webkit-transform: scaleY(0.4);
50 | } 20% {
51 | transform: scaleY(1.0);
52 | -webkit-transform: scaleY(1.0);
53 | }
54 | }
--------------------------------------------------------------------------------
/src/Spinner.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Created by Paul on 2017-05-27.
3 | */
4 |
5 | import React, {Component} from 'react';
6 | import './Spinner.css'
7 |
8 | export default class Section extends Component {
9 | render() {
10 | return (
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 | );
19 | }
20 | }
--------------------------------------------------------------------------------
/src/Statistics.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Created by Paul on 2017-05-20.
3 | */
4 |
5 | import moment from 'moment'
6 |
7 | import {isNum, mean, sum, countEach, meanStdDev, weighted_mean, sum_with_multiplicity} from './util.js'
8 |
9 |
10 | export function nday_sliding_window(data, ndays, fillval, aggregation) {
11 | // calculates mean in sliding window of width ndays
12 | // data = [{date: *moment*, val: *value*, num: *number of aggregated data points*},...]
13 | // fillval is the value to assign days with no data (if null they are ignored, 0 they are respected for mean calc)
14 | // out.y2 total number of datapoints in sliding window if num is given
15 |
16 | let out = {x: [], y: [], y2: []};
17 | if (data === undefined) {
18 | return out;
19 | }
20 | let aggregation_func;
21 | if (aggregation === "mean") {
22 | aggregation_func = weighted_mean;
23 | } else if (aggregation === "sum") {
24 | aggregation_func = sum_with_multiplicity;
25 | } else {
26 | console.log("Unknown aggregation: " + aggregation);
27 | return null
28 | }
29 |
30 | let daydata = {};
31 | data.forEach(d => {
32 | daydata[+d.date] = d
33 | });
34 |
35 | let min = moment.min(data.map(d => d.date));
36 | let max = moment.max(data.map(d => d.date));
37 |
38 | let day = min.clone();
39 | let vals = [];
40 | let nums = [];
41 |
42 | while (day.isSameOrBefore(max)) {
43 | if (daydata.hasOwnProperty(+day)) {
44 | nums.push(daydata[+day].num);
45 | vals.push(daydata[+day].val);
46 | } else {
47 | vals.push(fillval);
48 | // for fillval==0 the following is correct and for fillval==null the entries are ignored in weighted_mean
49 | nums.push(1);
50 | }
51 | if (vals.length === ndays) {
52 | out.x.push(day.clone().subtract(Math.ceil((ndays - 1) / 2), "days").format("YYYY-MM-DD"));
53 | out.y.push(aggregation_func(vals, nums));
54 | out.y2.push(sum(nums));
55 | vals.shift();
56 | nums.shift();
57 | }
58 |
59 | day.add(1, "days");
60 | }
61 | return out;
62 | }
63 |
64 | export default class Statistics {
65 | constructor(data, has_read_dates, has_genres) {
66 | this.data = data;
67 | this.data_valid_date_read = data.filter(b => b.date_read.isValid());
68 | this.data_start_end_dates = this.data_valid_date_read.filter(b => b.date_started.isValid());
69 | this.data_primary = data.filter(b => b.primary);
70 | this.has_read_dates = has_read_dates;
71 | this.has_genres = has_genres;
72 | }
73 |
74 | get total_pages() {
75 | if (this._total_pages === undefined) {
76 | this._total_pages = sum(this.data.map(b => b.num_pages));
77 | }
78 | return this._total_pages;
79 | }
80 |
81 | get total_words() {
82 | if (this._total_words === undefined) {
83 | this._total_words = sum(this.data.map(b => b.num_words));
84 | }
85 | return this._total_words;
86 | }
87 |
88 | get user_rating_vs_average_rating() {
89 | let out = {x: [], y: [], text: []};
90 | this.data_primary.forEach(book => {
91 | if (book.user_rating > 0 && book.average_rating > 0) {
92 | out.x.push(book.average_rating);
93 | out.y.push(book.user_rating);
94 | out.text.push(`${book.title} (${book.author})`);
95 | }
96 | });
97 | return out;
98 | }
99 |
100 | get user_rating_vs_num_pages() {
101 | let out = {x: [], y: [], text: []};
102 | this.data_primary.forEach(book => {
103 | if (book.user_rating > 0 && book.num_pages > 0) {
104 | out.y.push(book.user_rating);
105 | out.x.push(book.num_pages);
106 | out.text.push(`${book.title} (${book.author})`);
107 | }
108 | });
109 | return out;
110 | }
111 |
112 | get publication_year_bar() {
113 | let years = this.data_primary.map(b => b.publication_year).filter(x => x > 0);
114 | const counts = countEach(years);
115 | return {x: Object.keys(counts).sort(), y: Object.keys(counts).sort().map(k => counts[k])};
116 | }
117 |
118 | get publication_year_title() {
119 | let titles = [];
120 | const years = this.data_primary.filter(b => b.publication_year > 0).map(b => b.publication_year);
121 | const yearCounts = countEach(years);
122 | this.data_primary.filter(b => b.publication_year > 0).forEach(b => {
123 | titles.push(`${yearCounts[b.publication_year]}
${b.title} (${b.author})`);
124 | });
125 | return {x: years, text: titles};
126 | }
127 |
128 | get read_vs_pub() {
129 | const data = this.data_valid_date_read.filter(b => b.publication_year > 0);
130 | let out = {x: [], y: [], text: []};
131 | data.forEach(book => {
132 | out.x.push(book.date_read.format("YYYY-MM-DD"));
133 | out.y.push(+book.publication_year);
134 | out.text.push(book.title);
135 | })
136 | return out;
137 | }
138 |
139 | get months_books_read_bar() {
140 | const months = this.data_valid_date_read.map(b => b.date_read.clone().startOf("month").add(1, "day").toISOString());
141 | const counts = countEach(months);
142 | return {x: Object.keys(counts).sort(), y: Object.keys(counts).sort().map(k => counts[k])};
143 | }
144 |
145 | get books_by_date_read() {
146 | if (this._books_by_date_read === undefined) {
147 | let date_to_books = {};
148 | this.data_valid_date_read.forEach(b => {
149 | if (!date_to_books.hasOwnProperty(+b.date_read)) {
150 | date_to_books[+b.date_read] = {date: b.date_read, books: []};
151 | }
152 | date_to_books[+b.date_read].books.push(b);
153 | });
154 | this._books_by_date_read = date_to_books;
155 | }
156 | return this._books_by_date_read;
157 | }
158 |
159 | get user_rating_vs_date_read() {
160 | let valid_data = [];
161 | let nums_data = [];
162 | Object.keys(this.books_by_date_read).forEach(d => {
163 | let ratings = this.books_by_date_read[d].books.map(b => b.user_rating).filter(x => x > 0);
164 | if (ratings.length > 0) {
165 | valid_data.push({date: moment(+d), val: mean(ratings), num: ratings.length});
166 | nums_data.push({date: moment(+d), val: ratings.length, num: 1});
167 | }
168 | });
169 | return {data1: valid_data, data2: nums_data};
170 | }
171 |
172 | get books_pages_read() {
173 | const valid_data_pages = [];
174 | const valid_data_books = [];
175 | const dots_x = [];
176 | const dots_y = [];
177 | const dots_text = [];
178 |
179 | const pages_per_day_from_start_end = {};
180 | this.data_start_end_dates.forEach(b => {
181 | const day = b.date_started.clone();
182 | const reading_days = b.date_read.diff(b.date_started, "days") + 1;
183 | while (day.isSameOrBefore(b.date_read)) {
184 | if (!pages_per_day_from_start_end.hasOwnProperty(+day)) {
185 | pages_per_day_from_start_end[+day] = 0;
186 | }
187 | if (!this.books_by_date_read.hasOwnProperty(+day)) {
188 | this.books_by_date_read[+day] = {books: [], date: day};
189 | }
190 | pages_per_day_from_start_end[+day] += b.num_pages / reading_days;
191 | day.add(1, "days");
192 | }
193 | });
194 |
195 | Object.keys(this.books_by_date_read).forEach(d => {
196 | valid_data_books.push({date: moment(+d), val: this.books_by_date_read[d].books.length * 7, num: 1});
197 | valid_data_pages.push({
198 | date: moment(+d),
199 | val: sum(this.books_by_date_read[d].books.filter(b => !b.date_started.isValid()).map(b => b.num_pages))
200 | + (pages_per_day_from_start_end.hasOwnProperty(d) ? pages_per_day_from_start_end[d] : 0),
201 | num: 1
202 | });
203 | this.books_by_date_read[d].books.forEach((b, i) => {
204 | dots_x.push(moment(+d).format("YYYY-MM-DD"));
205 | dots_y.push(i);
206 | dots_text.push(`${b.title} (${b.author})`);
207 | })
208 | });
209 |
210 |
211 | return {
212 | data1: valid_data_pages,
213 | data2: valid_data_books,
214 | dots_data: {
215 | x: dots_x,
216 | y: dots_y,
217 | text: dots_text
218 | }
219 | }
220 | }
221 |
222 | get weekday_finish() {
223 | let counts = countEach(
224 | this.data_valid_date_read.filter(b => !b.book_moved).map(b => b.date_read.day()));
225 | return {
226 | x: ["Mo", "Tu", "Wed", "Th", "Fr", "Sa", "Su"],
227 | y: [counts[0], counts[1], counts[2], counts[3], counts[4], counts[5], counts[6]]
228 | };
229 | }
230 |
231 | get books_by_genre() {
232 | if (this._books_by_genre === undefined) {
233 | let genre_to_books = {};
234 | this.data_primary.filter(b => b.genres !== undefined).forEach(b => {
235 | b.genres.forEach(g => {
236 | if (g.num > 10) {
237 | if (!genre_to_books.hasOwnProperty(g.genre_string)) {
238 | genre_to_books[g.genre_string] = [];
239 | }
240 | genre_to_books[g.genre_string].push(b);
241 | }
242 | });
243 | });
244 | this._books_by_genre = Object.keys(genre_to_books).map(k => ({genre: k, books: genre_to_books[k]}));
245 | this._books_by_genre.sort((a, b) => a.books.length - b.books.length);
246 | }
247 | return this._books_by_genre;
248 | }
249 |
250 | get genre_books() {
251 | return {
252 | x: this.books_by_genre.map(g => g.genre),
253 | y: this.books_by_genre.map(g => g.books.length)
254 | };
255 | }
256 |
257 | get genre_ratings() {
258 | const xs = [];
259 | const ys = [];
260 | const errors = [];
261 | this.books_by_genre.slice(-20).forEach(g => {
262 | const mean_std_dev = meanStdDev(g.books.filter(b => b.user_rating > 0).map(b => b.user_rating));
263 | xs.push(mean_std_dev.mean);
264 | errors.push(mean_std_dev.stddev);
265 | ys.push(g.genre);
266 | });
267 | return {xs: xs, ys: ys, errors: errors};
268 | }
269 |
270 | get author_stats() {
271 | if (!(this._author_stats === undefined)) {
272 | return this._author_stats
273 | }
274 | let author_books = {};
275 | this.data_primary.forEach(b => {
276 | if (!author_books.hasOwnProperty(b.author)) {
277 | author_books[b.author] = [];
278 | }
279 | author_books[b.author].push(b);
280 | });
281 |
282 | let author_stats = {};
283 | Object.keys(author_books).forEach(a => {
284 | author_stats[a] = {
285 | avg_user_rating: mean(author_books[a].map(b => b.user_rating).filter(r => r > 0)),
286 | avg_user_rating_2prec: null,
287 | num_books: author_books[a].length,
288 | avg_rating_diff: mean(author_books[a].filter(b => b.user_rating > 0).map(b => b.user_rating - b.average_rating)),
289 | avg_rating_diff_2prec: null,
290 | author_sort: author_books[a][0].author_sort,
291 | total_pages: sum(author_books[a].map(b => b.num_pages)),
292 | };
293 | if (isNum(author_stats[a].avg_user_rating)) {
294 | author_stats[a].avg_user_rating_2prec = author_stats[a].avg_user_rating.toPrecision(2);
295 | author_stats[a].avg_rating_diff_2prec = author_stats[a].avg_rating_diff.toPrecision(2);
296 | }
297 | });
298 |
299 | this._author_stats = author_stats;
300 | return author_stats;
301 | }
302 |
303 | get author_stats_list() {
304 | return Object.keys(this.author_stats).map(a => ({
305 | author: a,
306 | author_sort: this.author_stats[a].author_sort,
307 | num_books: this.author_stats[a].num_books,
308 | total_pages: this.author_stats[a].total_pages,
309 | avg_user_rating_2prec: this.author_stats[a].avg_user_rating_2prec,
310 | avg_user_rating: this.author_stats[a].avg_user_rating,
311 | avg_rating_diff: this.author_stats[a].avg_rating_diff,
312 | avg_rating_diff_2prec: this.author_stats[a].avg_rating_diff_2prec
313 | }));
314 | }
315 |
316 | get author_num_books_vs_avg_user_rating() {
317 | let out = {x: [], y: [], text: []};
318 | Object.keys(this.author_stats).forEach(a => {
319 | if (isNum(this.author_stats[a].avg_user_rating)) {
320 | out.x.push(this.author_stats[a].num_books);
321 | out.y.push(this.author_stats[a].avg_user_rating);
322 | out.text.push(a);
323 | }
324 | });
325 | return out;
326 | }
327 |
328 | get book_list() {
329 | return this.data_primary.map(b => ({
330 | title: b.title, author: b.author, author_sort: b.author_sort,
331 | user_rating: b.user_rating, avg_rating: b.average_rating,
332 | avg_rating_2prec: b.average_rating === null ? null : b.average_rating.toPrecision(2),
333 | rating_diff: b.user_rating === null ? null : b.user_rating - b.average_rating,
334 | rating_diff_2prec: b.user_rating === null ? null : (b.user_rating - b.average_rating).toPrecision(2),
335 | reading_count: b.reading_count
336 | }))
337 | }
338 |
339 | get pages_and_title() {
340 | const books_with_pages = this.data_primary.filter(b => b.num_pages > 0);
341 | return {
342 | x: books_with_pages.map(b => b.num_pages),
343 | text: books_with_pages.map(b => `${b.title} (${b.author})`)
344 | };
345 | }
346 |
347 | get avgrating_and_title() {
348 | const books_with_avg_rating = this.data_primary.filter(b => b.average_rating > 0);
349 | return {
350 | x: books_with_avg_rating.map(b => b.average_rating),
351 | text: books_with_avg_rating.map(b => `${b.title} (${b.author})`)
352 | }
353 | }
354 | }
--------------------------------------------------------------------------------
/src/StatsComponent.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Created by Paul on 2017-05-27.
3 | */
4 |
5 | import React, {Component} from 'react';
6 |
7 | import RatingStats from './RatingStats'
8 | import ReadingStats from './ReadingStats'
9 | import AuthorStats from './AuthorStats'
10 |
11 | import Section from "./Section"
12 |
13 |
14 | export default class StatsComponent extends Component {
15 | render() {
16 | if (this.props.statistics === null || this.props.statistics.data.length === 0) {
17 | return null
18 | }
19 | return (
20 |
31 | );
32 | }
33 | }
--------------------------------------------------------------------------------
/src/css/App.css:
--------------------------------------------------------------------------------
1 | .App {
2 | text-align: center;
3 | font-family: 'Roboto', sans-serif;
4 | min-height: 100%;
5 | position: relative;
6 | }
7 |
8 | #footer {
9 | position: absolute;
10 | bottom: 0;
11 | padding: 5px;
12 | border-top-right-radius: 10px;
13 | border: 5px solid rgba(220, 220, 220, 0.53);
14 | background-color: rgba(220, 220, 220, 0.53);
15 | font-size: 10pt;
16 | }
17 |
18 | #non-footer {
19 | padding-bottom: 3em;
20 | }
21 |
22 | html {height: 100%}
23 |
24 | body {
25 | background-color: rgb(255, 243, 215);
26 | height: 100%;
27 | }
28 |
29 | #root {
30 | height: 100%;
31 | }
32 |
33 | .clearfloat {
34 | clear: both;
35 | }
36 |
37 | #logo {
38 | float: left;
39 | height: 80px;
40 | }
41 |
42 | #title {
43 | padding-left: 1em;
44 | float: left;
45 | font-family: 'Patua One', cursive;
46 | color: #1F76B4;
47 | }
48 |
49 | #title h1 {
50 | font-size: 30pt;
51 | font-weight: normal;
52 | }
53 |
54 | .App-header {
55 | margin-top: 2em;
56 | display: inline-block;
57 | }
58 |
59 | .InputCSV {
60 | float: left;
61 | border-radius: 10px;
62 | border: 10px solid #ffffff;
63 | background-color: #fff;
64 | margin: 5px;
65 | max-width: 600px;
66 | }
67 |
68 | .options {
69 | margin-top: 1em;
70 | }
71 |
72 | #top-bar {
73 | border-radius: 10px;
74 | border: 10px solid #ffffff;
75 | background-color: #fff;
76 | margin: 5px;
77 | display: inline-block;
78 | text-align: left;
79 | font-size: 12pt;
80 | }
81 | .float {
82 | float: left;
83 | }
84 |
85 | #overview {margin-top: 15px;}
86 |
87 | #number_stats li {
88 | text-align: left;
89 | }
90 |
91 | #number_stats ul {
92 | margin: 0;
93 | }
94 |
95 | #instructions {
96 | margin-left: 1em;
97 | float: left;
98 | max-width: 400px;
99 | }
100 |
101 | #instructions p {
102 | margin-bottom: 0;
103 | margin-top: 0.5em;
104 | }
105 |
106 | .plot {
107 | float: left;
108 | width: 590px;
109 | height: 500px;
110 | border-radius: 10px;
111 | border: 10px solid #ffffff;
112 | background-color: #fff;
113 | margin: 5px;
114 | }
115 |
116 | .plot_list {
117 | overflow-y: auto;
118 | }
119 |
120 | .plot_half {
121 | height: 235px !important;
122 | }
123 |
124 | .section-title {
125 | color: #1F76B4;
126 | font-family: "Patua One", cursive;
127 | font-size: 15pt;
128 | padding-top: 15px;
129 | padding-bottom: 20px;
130 | }
131 |
132 | .section {
133 | background-color: rgb(247, 246, 255);
134 | border: 2px dashed #1F76B4;
135 | border-radius: 20px;
136 | margin: 5px;
137 | }
138 |
139 | #file_select_and_processing {
140 | display: flex;
141 | align-items: center;
142 | }
143 |
144 | #error {
145 | display: flex;
146 | align-items: center;
147 | margin-left: 1em;
148 | }
149 |
150 | .error_smiley {
151 | width: 1.5em;
152 | height: 1.5em;
153 | padding-right: 0.5em;
154 | }
155 |
156 | a, .link_button {
157 | cursor: pointer;
158 | color: #1488C6;
159 | }
160 |
161 | #demo_link {
162 | margin-top: 0.6em;
163 | font-size: 12pt;
164 | }
--------------------------------------------------------------------------------
/src/css/index.css:
--------------------------------------------------------------------------------
1 | body {
2 | margin: 0;
3 | padding: 0;
4 | font-family: sans-serif;
5 | }
6 |
--------------------------------------------------------------------------------
/src/css/tables.css:
--------------------------------------------------------------------------------
1 | table {
2 | border-collapse: collapse;
3 | }
4 |
5 | thead {
6 | display: table-header-group
7 | }
8 |
9 | th {
10 | padding-bottom: 1em;
11 | padding-left: 0.5em;
12 | white-space: pre-wrap;
13 | color: #1F76B4;
14 | }
15 |
16 | td {
17 | text-align: left;
18 | margin: 0;
19 | max-width: 200px;
20 | }
21 |
22 | tr.SortedTable_row_odd {
23 | background-color: gainsboro;
24 | }
--------------------------------------------------------------------------------
/src/img/error_smiley.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PaulKlinger/Bookstats/8c945dca784f33201fa722de24db301db96f5c1d/src/img/error_smiley.png
--------------------------------------------------------------------------------
/src/img/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PaulKlinger/Bookstats/8c945dca784f33201fa722de24db301db96f5c1d/src/img/logo.png
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import App from './App';
4 | import registerServiceWorker from './registerServiceWorker';
5 | import './css/index.css';
6 |
7 | ReactDOM.render(
, document.getElementById('root'));
8 | registerServiceWorker();
9 |
--------------------------------------------------------------------------------
/src/parseExport.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Created by Paul on 2017-05-24.
3 | */
4 | import Papa from 'papaparse'
5 | import moment from 'moment'
6 |
7 | import Statistics from './Statistics'
8 |
9 | class Book {
10 | constructor(primary, title, author, isbn, user_rating, average_rating, num_pages, date_started, date_read, author_sort,
11 | publication_year, genres, num_words, reading_count) {
12 | this.primary = primary;
13 | this.title = title;
14 | this.author = author;
15 | this.author_sort = author_sort;
16 | this.isbn = isbn;
17 | this.user_rating = user_rating;
18 | this.average_rating = average_rating;
19 | this.num_pages = num_pages;
20 | this.date_started = date_started;
21 | this.date_read = date_read;
22 | this.publication_year = publication_year;
23 | this.genres = genres;
24 | this.num_words = num_words;
25 | if (this.num_words === null && this.num_pages !== null) {
26 | this.num_words = this.num_pages * 270;
27 | }
28 | this.reading_count = reading_count // number of times this book has been read
29 |
30 | this.book_moved = false; // Book date_read has been artificially moved (e.g. to spread Jan 1 books over year)
31 | }
32 | }
33 |
34 | function distribute_year(data) {
35 | let year_start_books = {};
36 | data.forEach(b => {
37 | if (b.date_read.month() === 0 && b.date_read.date() === 1) {
38 | if (!year_start_books.hasOwnProperty(b.date_read)) {
39 | year_start_books[b.date_read] = [];
40 | }
41 | year_start_books[b.date_read].push(b);
42 | }
43 | });
44 | for (let year in year_start_books) {
45 | if (year_start_books.hasOwnProperty(year) && year_start_books[year].length > 10) {
46 | const n_books = year_start_books[year].length;
47 | const delta = 365 / n_books;
48 | for (let i=0; i < n_books; i++) {
49 | year_start_books[year][i].date_read.add(Math.floor(delta * i), "days");
50 | year_start_books[year][i].book_moved = true;
51 | }
52 | }
53 | }
54 | }
55 |
56 |
57 | function parseGenres(genres_string) {
58 | const genres = [];
59 | genres_string.split(";").forEach(s => {
60 | const subgenres_num = s.split("|");
61 | genres.push({subgenres: subgenres_num[0].split(","),
62 | num: subgenres_num[1],
63 | genre_string: subgenres_num[0].replace(",", ">")});
64 | });
65 | return genres;
66 | }
67 |
68 |
69 | function parseReadDates(read_dates_string) {
70 | if (read_dates_string === "") {
71 | return []
72 | }
73 | const read_dates = [];
74 | read_dates_string.split(";").forEach(rd => {
75 | const start_end = rd.split(",");
76 | read_dates.push({start: moment.utc(start_end[0], "YYYY-MM-DD"), end: moment.utc(start_end[1], "YYYY-MM-DD")})
77 | });
78 | return read_dates;
79 | }
80 |
81 |
82 | function get_num_words(private_notes) {
83 | let num_words = null;
84 | const m = private_notes.match(/^words: (\d+)$/);
85 | if (m !== null) {
86 | num_words = +m[1];
87 | }
88 | return num_words;
89 | }
90 |
91 |
92 | export default function parseExport(file, options) {
93 | return new Promise((resolve, reject) => {
94 | Papa.parse(file,
95 | {
96 | download: true,
97 | complete: (results) => {
98 | const column_names = results.data.shift();
99 | // Just check some columns to see if this is a goodreads csv (not exhaustive)
100 | if (!(column_names.indexOf("Title") > -1
101 | && column_names.indexOf("Author") > -1
102 | && column_names.indexOf("Exclusive Shelf") > -1
103 | && column_names.indexOf("Date Read") > -1)){
104 | reject("Error: Not a Goodreads export file.");
105 | }
106 | const read_dates_index = column_names.indexOf("read_dates");
107 | const genres_index = column_names.indexOf("genres");
108 | let data = [];
109 | results.data.forEach(columns => {
110 | if (columns[column_names.indexOf("Exclusive Shelf")] === "read") {
111 | const genres = genres_index === -1 ? undefined : parseGenres(columns[genres_index]);
112 | const read_dates = read_dates_index === -1 ? []: parseReadDates(columns[read_dates_index]);
113 | if (read_dates.length === 0) {
114 | if (columns[column_names.indexOf("Date Read")] !== ""){
115 | read_dates.push({
116 | end: moment.utc(columns[column_names.indexOf("Date Read")], ["YYYY/MM/DD", "DD/MM/YYYY", "MM/DD/YYYY"]),
117 | start: moment.invalid()})
118 | } else { // This book was read but we don't know when
119 | read_dates.push({
120 | end: moment.invalid(),
121 | start: moment.invalid()
122 | })
123 | }
124 | }
125 | read_dates.forEach((rd, i) =>{
126 | data.push(
127 | new Book(
128 | i === 0, // primary (1 book object per reading, only one primary one)
129 | columns[column_names.indexOf("Title")], // title
130 | columns[column_names.indexOf("Author")], // author
131 | columns[column_names.indexOf("ISBN13")], // isbn
132 | columns[column_names.indexOf("My Rating")] === "0"
133 | ? null : parseFloat(columns[column_names.indexOf("My Rating")]), // user_rating
134 | columns[column_names.indexOf("Average Rating")] === "0"
135 | ? null : parseFloat(columns[column_names.indexOf("Average Rating")]), // average_rating
136 | columns[column_names.indexOf("Number of Pages")] === ""
137 | ? null : parseFloat(columns[column_names.indexOf("Number of Pages")]), // num_pages
138 | rd.start, // date_started
139 | rd.end, // date_read
140 | columns[column_names.indexOf("Author l-f")], // author_sort
141 | columns[column_names.indexOf("Original Publication Year")], // publication_year
142 | genres, // genres
143 | get_num_words(columns[column_names.indexOf("Private Notes")]), //num_words
144 | read_dates.length // reading_count
145 | ))
146 | });
147 | }
148 | });
149 | if (options.distribute_year) {
150 | distribute_year(data);
151 | }
152 | if (data.length > 0) {
153 | resolve(new Statistics(data, read_dates_index > -1, genres_index > -1));
154 | } else {
155 | reject("Error: No books found.");
156 | }
157 | }
158 | });
159 |
160 | });
161 | }
--------------------------------------------------------------------------------
/src/registerServiceWorker.js:
--------------------------------------------------------------------------------
1 | // In production, we register a service worker to serve assets from local cache.
2 |
3 | // This lets the app load faster on subsequent visits in production, and gives
4 | // it offline capabilities. However, it also means that developers (and users)
5 | // will only see deployed updates on the "N+1" visit to a page, since previously
6 | // cached resources are updated in the background.
7 |
8 | // To learn more about the benefits of this model, read https://goo.gl/KwvDNy.
9 | // This link also includes instructions on opting out of this behavior.
10 |
11 | export default function register() {
12 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
13 | window.addEventListener('load', () => {
14 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
15 | navigator.serviceWorker
16 | .register(swUrl)
17 | .then(registration => {
18 | registration.onupdatefound = () => {
19 | const installingWorker = registration.installing;
20 | installingWorker.onstatechange = () => {
21 | if (installingWorker.state === 'installed') {
22 | if (navigator.serviceWorker.controller) {
23 | // At this point, the old content will have been purged and
24 | // the fresh content will have been added to the cache.
25 | // It's the perfect time to display a "New content is
26 | // available; please refresh." message in your web app.
27 | console.log('New content is available; please refresh.');
28 | } else {
29 | // At this point, everything has been precached.
30 | // It's the perfect time to display a
31 | // "Content is cached for offline use." message.
32 | console.log('Content is cached for offline use.');
33 | }
34 | }
35 | };
36 | };
37 | })
38 | .catch(error => {
39 | console.error('Error during service worker registration:', error);
40 | });
41 | });
42 | }
43 | }
44 |
45 | export function unregister() {
46 | if ('serviceWorker' in navigator) {
47 | navigator.serviceWorker.ready.then(registration => {
48 | registration.unregister();
49 | });
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/src/shared_plots.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Created by Paul on 2017-05-21.
3 | */
4 |
5 | import React, {Component} from 'react';
6 | import PlotlyComponent from './PlotlyComponent';
7 | import Plotly from 'plotly.js/dist/plotly-cartesian';
8 | import {nday_sliding_window} from "./Statistics.js";
9 |
10 | let defaultMargins = {l: 50, r: 50, t: 80, b: 50};
11 |
12 | export class ScatterPlot extends Component {
13 | render() {
14 | let raw_data = this.props.data;
15 | let data = [
16 | {
17 | type: 'scattergl',
18 | mode: 'markers',
19 | x: raw_data.x,
20 | y: raw_data.y,
21 | text: raw_data.text,
22 | marker: {
23 | symbol: 'cross',
24 | color: 'rgb(16, 32, 77)'
25 | }
26 | }
27 | ];
28 | let layout = {
29 | margin: defaultMargins,
30 | title: this.props.title,
31 | autosize: true,
32 | hovermode: 'closest',
33 | xaxis: {
34 | title: this.props.xaxis_title
35 | },
36 | yaxis: {
37 | title: this.props.yaxis_title
38 | }
39 | };
40 | let config = {
41 | showLink: false,
42 | };
43 | return (
44 |
47 | );
48 | }
49 | }
50 |
51 | export class Histogram extends Component {
52 | render() {
53 | let raw_data = this.props.data;
54 | let data = [
55 | {
56 | type: 'histogram',
57 | x: raw_data
58 | }
59 | ];
60 | let layout = {
61 | margin: defaultMargins,
62 | title: this.props.title,
63 | autosize: true,
64 | xaxis: {
65 | title: this.props.xaxis_title,
66 | tickvals: this.props.tickvals,
67 | ticktext: this.props.ticktext
68 | },
69 | yaxis: {title: "# of books"}
70 | };
71 | let config = {
72 | showLink: false,
73 | };
74 | return (
75 |
78 | );
79 | }
80 | }
81 |
82 | export class Bar extends Component {
83 | render() {
84 | let data = [
85 | {
86 | type: 'bar',
87 | x: this.props.data.x,
88 | y: this.props.data.y
89 | }
90 | ];
91 | const margins = Object.assign({}, defaultMargins);
92 | if (this.props.margin_bottom !== undefined) {
93 | margins.b = this.props.margin_bottom;
94 | }
95 | const layout = {
96 | margin: margins,
97 | title: this.props.title,
98 | autosize: true,
99 | xaxis: {
100 | title: this.props.xaxis_title,
101 | hoverformat: this.props.xaxis_hoverformat,
102 | tickangle: this.props.tickangle
103 | },
104 | yaxis: {title: this.props.yaxis_title}
105 | };
106 | let config = {
107 | showLink: false,
108 | };
109 | return (
110 |
113 | );
114 | }
115 | }
116 |
117 | export class TimeLinePlot extends Component {
118 | render() {
119 | let raw_data_1 = this.props.data.data1;
120 | let raw_data_2 = this.props.data.data2;
121 | let data = [
122 | {
123 | type: 'scattergl',
124 | mode: 'line',
125 | name: this.props.line_1_legend,
126 | x: raw_data_1.x,
127 | y: raw_data_1.y,
128 | yaxis: 'y1'
129 | }
130 | ];
131 | let layout = {
132 | margin: defaultMargins,
133 | title: this.props.title,
134 | autosize: true,
135 | hovermode: 'closest',
136 | yaxis: {
137 | title: this.props.yaxis_title
138 | }
139 | };
140 | if (!(raw_data_2 === undefined)) {
141 | const y2_type = this.props.y2_type === undefined ? "bar" : this.props.y2_type;
142 | data.push({
143 | type: 'scattergl',
144 | mode: y2_type,
145 | x: raw_data_2.x,
146 | y: raw_data_2.y,
147 | yaxis: 'y2',
148 | name: this.props.line_2_legend
149 | });
150 | layout.yaxis2 = {
151 | side: 'right',
152 | title: this.props.yaxis2_title,
153 | overlaying: 'y' //???
154 | };
155 | layout.showlegend = true;
156 | layout.legend = {x: 0, y: 1};
157 | }
158 |
159 | if (this.props.data.dots_data !== undefined) {
160 | data.push({
161 | type: "scattergl",
162 | mode: "markers",
163 | x: this.props.data.dots_data.x,
164 | y: this.props.data.dots_data.y,
165 | text: this.props.data.dots_data.text,
166 | showlegend: false,
167 | hoverinfo: "x+text"
168 | })
169 | }
170 |
171 | let config = {
172 | showLink: false,
173 | };
174 | return (
175 |
178 | );
179 | }
180 | }
181 |
182 | export class TimeLineSlidingWindowPlot extends Component {
183 | constructor(props) {
184 | super(props);
185 | this.state = {
186 | ndays: props.default_ndays,
187 | };
188 | this.change_sliding_window = this.change_sliding_window.bind(this);
189 | this.handle_key = this.handle_key.bind(this);
190 | }
191 |
192 | handle_key(event) {
193 | if (event.key === 'Enter') {
194 | event.target.blur();
195 | }
196 | }
197 |
198 | change_sliding_window(event) {
199 | this.setState({ndays: parseInt(event.target.value, 10)});
200 | }
201 |
202 | render() {
203 | let raw_data_1 = nday_sliding_window(this.props.data.data1, this.state.ndays,
204 | this.props.fillval_1, this.props.aggregation_1);
205 | let raw_data_2 = nday_sliding_window(this.props.data.data2, this.state.ndays,
206 | this.props.fillval_2, this.props.aggregation_2);
207 |
208 | let data = [
209 | {
210 | type: 'scattergl',
211 | mode: 'line',
212 | name: this.props.line_1_legend,
213 | x: raw_data_1.x,
214 | y: raw_data_1.y,
215 | yaxis: 'y1'
216 | }
217 | ];
218 | let layout = {
219 | margin: {
220 | l: 50,
221 | r: 50,
222 | b: 20,
223 | t: 40,
224 | pad: 4
225 | },
226 | title: null,
227 | autosize: true,
228 | hovermode: 'closest',
229 | yaxis: {
230 | title: this.props.yaxis_title
231 | }
232 | };
233 | if (!(raw_data_2 === undefined)) {
234 | const y2_type = this.props.y2_type === undefined ? "bar" : this.props.y2_type;
235 | data.push({
236 | type: 'scattergl',
237 | mode: y2_type,
238 | x: raw_data_2.x,
239 | y: raw_data_2.y,
240 | yaxis: 'y2',
241 | name: this.props.line_2_legend
242 | });
243 | layout.yaxis2 = {
244 | side: 'right',
245 | title: this.props.yaxis2_title,
246 | overlaying: 'y' //???
247 | };
248 | layout.showlegend = true;
249 | layout.legend = {x: 0, y: 1};
250 | }
251 |
252 | if (this.props.data.dots_data !== undefined) {
253 | data.push({
254 | type: "scattergl",
255 | mode: "markers",
256 | x: this.props.data.dots_data.x,
257 | y: this.props.data.dots_data.y,
258 | text: this.props.data.dots_data.text,
259 | showlegend: false,
260 | hoverinfo: "x+text"
261 | })
262 | }
263 |
264 | let config = {
265 | showLink: false,
266 | };
267 | return (
268 |
276 | );
277 | }
278 | }
279 |
280 | function calcViolinYs(xs, offset, slotsize, oneside) {
281 | const sortedxs = xs.slice().sort();
282 | const minx = sortedxs[0];
283 |
284 | if (slotsize === undefined) {
285 | // Somewhat arbitrary algorithm to calculate "bin" size
286 | // split 10th to 90th percentile region into bins
287 | // such that for a flat distribution each bin contains 15 points
288 | // this seems to give a nice picture for ~240 and ~1270 points
289 | // TODO: maybe adjust based on plot height?
290 |
291 | const interval_of_interest = (sortedxs[Math.floor(sortedxs.length * 0.90)] - sortedxs[Math.floor(sortedxs.length * 0.10)]);
292 | slotsize = interval_of_interest / (0.8 * sortedxs.length / 15);
293 | }
294 | const occupied = {};
295 |
296 | const ys = [];
297 |
298 | xs.forEach(x => {
299 | let y = oneside ? 1 : 0;
300 | const slot = Math.floor((x - minx) / slotsize);
301 | while (true) {
302 | if (!occupied.hasOwnProperty([slot, y])) {
303 | break;
304 | }
305 | y += 1;
306 | if (!oneside) {
307 | if (!occupied.hasOwnProperty([slot, -y])) {
308 | y = -y;
309 | break;
310 | }
311 | }
312 | }
313 | occupied[[slot, y]] = true;
314 | ys.push(y + offset);
315 | });
316 | return ys;
317 | }
318 |
319 | export class DotViolin extends Component {
320 | render() {
321 | let ys = calcViolinYs(this.props.data.x, 0, this.props.slotsize, this.props.oneside);
322 | let xs = this.props.data.x;
323 |
324 | let data = [
325 | {
326 | type: 'scattergl',
327 | mode: 'markers',
328 | x: xs,
329 | y: ys,
330 | text: this.props.data.text,
331 | hoverinfo: "x+text",
332 | marker: {
333 | symbol: 'circle-dot',
334 | color: 'rgb(16, 32, 77)'
335 | }
336 | }
337 | ];
338 | let layout = {
339 | margin: {t: this.props.title === undefined ? 0 : 50, l: 50, r: 50, b: 40},
340 | title: this.props.title,
341 | height: (this.props.size === "full") ? 500 : 235,
342 | width: 550,
343 | hovermode: 'closest',
344 | xaxis: {
345 | title: this.props.xaxis_title,
346 | zeroline: false
347 | },
348 | yaxis: {
349 | dtick: 5,
350 | visible: !!this.props.oneside,
351 | }
352 | };
353 | let config = {
354 | showLink: false,
355 | };
356 | return (
357 |
360 | );
361 | }
362 | }
363 |
364 | export class MeanStdDev extends Component {
365 | render() {
366 | const margins = Object.assign({}, defaultMargins);
367 | margins.l = 150;
368 | let data = [
369 | {
370 | type: 'scattergl',
371 | mode: 'markers',
372 | x: this.props.data.xs,
373 | y: this.props.data.ys,
374 | error_x: {
375 | visible: true,
376 | type: "array",
377 | array: this.props.data.errors
378 | },
379 | marker: {
380 | symbol: 'circle-dot',
381 | color: 'rgb(16, 32, 77)'
382 | }
383 | }
384 | ];
385 | let layout = {
386 | margin: margins,
387 | title: this.props.title,
388 | autosize: true,
389 | hovermode: 'closest',
390 | xaxis: {
391 | title: this.props.xaxis_title,
392 | tickvals: this.props.tickvals,
393 | },
394 | yaxis: {
395 | title: this.props.yaxis_title
396 | }
397 | };
398 | let config = {
399 | showLink: false,
400 | };
401 | return (
402 |
405 | );
406 | }
407 | }
408 |
409 | export class BoxPlots extends Component {
410 | render() {
411 | let data = [];
412 | this.props.data.forEach(d => {
413 | data.push({
414 | x: d.xs,
415 | name: d.name,
416 | type: "box",
417 | boxmean: "sd"
418 | });
419 | });
420 | let layout = {
421 | margin: defaultMargins,
422 | title: this.props.title,
423 | height: (this.props.size === "full") ? 500 : 250,
424 | width: 550,
425 | hovermode: 'closest',
426 | xaxis: {
427 | title: this.props.xaxis_title,
428 | zeroline: false
429 | },
430 | yaxis: {
431 | visible: false
432 | }
433 | };
434 | let config = {
435 | showLink: false,
436 | };
437 | return (
438 |
441 | );
442 | }
443 | }
--------------------------------------------------------------------------------
/src/util.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Created by Paul on 2017-05-26.
3 | */
4 |
5 | export function isNum(x) {
6 | if (x === undefined || x === null) {
7 | return false;
8 | }
9 | return !!x.toFixed;
10 | }
11 |
12 | export function sum(a) {
13 | let total = 0;
14 | let contains_number = false;
15 | a.forEach(x => {
16 | if (isNum(x)) {
17 | contains_number = true;
18 | total += x;
19 | }
20 | }
21 | );
22 | return contains_number ? total : null;
23 | }
24 |
25 | export function sum_with_multiplicity(values, mults) {
26 | if (values.length !== mults.length) {
27 | console.log("Different number of values & multiplicities!");
28 | return null;
29 | }
30 | let total = 0;
31 | let contains_number = false;
32 | values.forEach((x, mi) => {
33 | if (isNum(x) && isNum(mults[mi])) {
34 | contains_number = true;
35 | total += mults[mi] * x;
36 | }
37 | }
38 | );
39 | return contains_number ? total : null;
40 | }
41 |
42 | export function mean(a) {
43 | let total = 0;
44 | let num = 0;
45 | a.forEach(x => {
46 | if (isNum(x)) {
47 | num++;
48 | total += x;
49 | }
50 | }
51 | );
52 | return num > 0 ? total / num : null;
53 | }
54 |
55 | export function weighted_mean(values, weights) {
56 | if (values.length !== weights.length) {
57 | console.log("Different number of values & weights in weighted mean!");
58 | return null;
59 | }
60 | let total = 0;
61 | let total_num = 0;
62 | values.forEach((v, ni) => {
63 | if (isNum(v) && isNum(weights[ni])) {
64 | total += v * weights[ni];
65 | total_num += weights[ni];
66 | }
67 | });
68 | return total_num > 0 ? total / total_num : null;
69 | }
70 |
71 | export function meanStdDev(a) {
72 | const nums = a.filter(x => isNum(x));
73 | const mean_value = mean(nums);
74 | let sumDeltaSq = 0;
75 | nums.forEach(x => {
76 | sumDeltaSq += (x - mean_value) * (x - mean_value);
77 | });
78 | return {
79 | mean: mean_value,
80 | stddev: Math.sqrt(sumDeltaSq / (nums.length - 1))}
81 | }
82 |
83 | export function countNum(a) {
84 | return a.filter(x => isNum(x)).length;
85 | }
86 |
87 | export function countEach(a) {
88 | let counts = {};
89 | a.forEach(x => {
90 | if (!counts.hasOwnProperty(x)) {
91 | counts[x] = 0
92 | }
93 | counts[x]++;
94 | });
95 | return counts;
96 | }
97 |
98 | export function cmpNumNullLast(a, b) {
99 | if (a === null) {
100 | return -1
101 | }
102 | if (b === null) {
103 | return 1
104 | }
105 | return a - b
106 | }
--------------------------------------------------------------------------------