},
{
@@ -134,6 +137,7 @@ WeaponProfile.propTypes = {
id: PropTypes.number.isRequired,
profile: PropTypes.shape({
uuid: PropTypes.string.isRequired,
+ name: PropTypes.string,
modifiers: PropTypes.array,
active: PropTypes.bool,
}).isRequired,
diff --git a/client/src/hooks/index.jsx b/client/src/hooks/index.jsx
index 6b1c6842..f7bb9096 100644
--- a/client/src/hooks/index.jsx
+++ b/client/src/hooks/index.jsx
@@ -1,2 +1,3 @@
export { default as useLongPress } from './useLongPress';
export { default as usePrevious } from './usePrevious';
+export { default as useRefCallback } from './useRefCallback';
diff --git a/client/src/hooks/useRefCallback.jsx b/client/src/hooks/useRefCallback.jsx
new file mode 100644
index 00000000..b82a3fc3
--- /dev/null
+++ b/client/src/hooks/useRefCallback.jsx
@@ -0,0 +1,15 @@
+import { useEffect, useState } from 'react';
+
+const useRefCallback = (callback) => {
+ const [node, setRef] = useState(null);
+
+ useEffect(() => {
+ if (node) {
+ callback(node);
+ }
+ }, [callback, node]);
+
+ return [setRef];
+};
+
+export default useRefCallback;
diff --git a/client/src/pdf/GraphWrapper.jsx b/client/src/pdf/GraphWrapper.jsx
new file mode 100644
index 00000000..b4efcc61
--- /dev/null
+++ b/client/src/pdf/GraphWrapper.jsx
@@ -0,0 +1,32 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { makeStyles } from '@material-ui/core/styles';
+import clsx from 'clsx';
+
+const useStyles = makeStyles((theme) => ({
+ container: {
+ width: '1000px',
+ height: '100%',
+ background: theme.palette.background.paper,
+ },
+ graph: {
+ height: '400px',
+ },
+}));
+
+const GraphWrapper = ({ children }) => {
+ const classes = useStyles();
+ return (
+
+ );
+};
+
+GraphWrapper.propTypes = {
+ children: PropTypes.node.isRequired,
+};
+
+export default GraphWrapper;
diff --git a/client/src/pdf/PdfGenerator.jsx b/client/src/pdf/PdfGenerator.jsx
new file mode 100644
index 00000000..030fb986
--- /dev/null
+++ b/client/src/pdf/PdfGenerator.jsx
@@ -0,0 +1,117 @@
+import React, {
+ useMemo, useLayoutEffect, useCallback, useState,
+} from 'react';
+import PropTypes from 'prop-types';
+import { makeStyles, useTheme, ThemeProvider } from '@material-ui/core/styles';
+import { useMediaQuery } from '@material-ui/core';
+import { BarGraph, LineGraph, RadarGraph } from 'components/Graphs';
+import { useHistory } from 'react-router-dom';
+import { useRefCallback } from 'hooks';
+import { lightTheme } from 'themes';
+import generate from './generator';
+import PdfLoader from './PdfLoader';
+import GraphWrapper from './GraphWrapper';
+
+
+const useStyles = makeStyles(() => ({
+ hidden: {
+ width: '100%',
+ position: 'absolute',
+ left: -2000,
+ },
+ graphGroup: {
+ display: 'flex',
+ height: '100%',
+ width: '100%',
+ },
+ line: {
+ flex: 2,
+ },
+ radar: {
+ flex: 1,
+ },
+ iframe: {
+ position: 'absolute',
+ right: 0,
+ top: 0,
+ bottom: 0,
+ height: '100%',
+ width: '100%',
+ },
+}));
+
+const PdfGenerator = ({ units, results, modifiers }) => {
+ const classes = useStyles();
+ const theme = useTheme();
+ const history = useHistory();
+ const [doc, setDoc] = useState(null);
+ const [loading, setLoading] = useState(true);
+
+ const mobile = useMediaQuery(theme.breakpoints.down('sm'));
+ const unitNames = useMemo(() => units.map(({ name }) => name), [units]);
+
+ const generatePdf = useCallback(() => (
+ generate('pdf-copy', units, results, modifiers, unitNames)
+ ), [modifiers, results, unitNames, units]);
+
+ const refCallback = useCallback(() => {
+ setTimeout(() => {
+ setLoading(false);
+ }, 1000); // Wait for recharts to finish drawing
+ }, []);
+ const [ref] = useRefCallback(refCallback);
+
+ useLayoutEffect(() => {
+ if (!loading) {
+ generatePdf().then((result) => {
+ setDoc(result);
+ if (mobile) {
+ result.save('aos-statshammer.pdf');
+ history.goBack();
+ }
+ });
+ }
+ }, [generatePdf, history, loading, mobile]);
+
+ if (doc) {
+ // eslint-disable-next-line jsx-a11y/iframe-has-title
+ return
;
+ }
+
+ return (
+
+ );
+};
+
+PdfGenerator.propTypes = {
+ units: PropTypes.arrayOf(PropTypes.object).isRequired,
+ results: PropTypes.arrayOf(PropTypes.object).isRequired,
+ modifiers: PropTypes.arrayOf(PropTypes.object).isRequired,
+};
+
+export default PdfGenerator;
diff --git a/client/src/pdf/PdfLoader.jsx b/client/src/pdf/PdfLoader.jsx
new file mode 100644
index 00000000..e4363401
--- /dev/null
+++ b/client/src/pdf/PdfLoader.jsx
@@ -0,0 +1,38 @@
+import React from 'react';
+import { makeStyles } from '@material-ui/core/styles';
+import {
+ CircularProgress, Typography, Paper,
+} from '@material-ui/core';
+
+const useStyles = makeStyles((theme) => ({
+ loader: {
+ width: '100%',
+ height: '100vh',
+ display: 'flex',
+ textAlign: 'center',
+ },
+ loaderInner: {
+ margin: 'auto',
+ minWidth: theme.spacing(40),
+ padding: theme.spacing(5, 4),
+ },
+ loaderIcon: {
+ marginBottom: theme.spacing(3),
+ },
+}));
+
+
+const PdfLoader = () => {
+ const classes = useStyles();
+
+ return (
+
+ );
+};
+
+export default PdfLoader;
diff --git a/client/src/pdf/generator/cursor.js b/client/src/pdf/generator/cursor.js
new file mode 100644
index 00000000..f5a72346
--- /dev/null
+++ b/client/src/pdf/generator/cursor.js
@@ -0,0 +1,25 @@
+/* eslint-disable no-underscore-dangle */
+class Cursor {
+ constructor(margin = 0) {
+ this._pos = margin;
+ this._margin = margin;
+ }
+
+ get pos() {
+ return this._pos;
+ }
+
+ set pos(newPos) {
+ this._pos = newPos;
+ }
+
+ incr(amt) {
+ this._pos += amt;
+ }
+
+ reset() {
+ this._pos = this._margin;
+ }
+}
+
+export default Cursor;
diff --git a/client/src/pdf/generator/generate.js b/client/src/pdf/generator/generate.js
new file mode 100644
index 00000000..c724b76d
--- /dev/null
+++ b/client/src/pdf/generator/generate.js
@@ -0,0 +1,189 @@
+/* eslint-disable no-underscore-dangle */
+import jsPDF from 'jspdf';
+import html2canvas from 'html2canvas';
+import 'jspdf-autotable';
+import { getModifierById } from 'utils/modifierHelpers';
+import { getFormattedDescription } from 'components/ModifierItem/ModifierDescription';
+import Cursor from './cursor';
+
+const margin = 20;
+const cursor = new Cursor(margin);
+const headerColor = [51, 171, 159];
+
+const getModifierItems = (modifiers) => {
+ const modifierItems = modifiers.map(({ id, options }) => {
+ const definition = getModifierById(id);
+ if (definition) {
+ const content = (
+ `${definition.name}:\n\t${getFormattedDescription(definition, options, false)}`
+ );
+ return [{ content, colSpan: 6 }];
+ }
+ return null;
+ }).filter((item) => item);
+ if (modifierItems) {
+ return [
+ [{
+ content: 'Modifiers',
+ colSpan: 6,
+ styles: { halign: 'center', fontStyle: 'bold', fillColor: [240, 240, 240] },
+ }],
+ ...modifierItems,
+ ];
+ }
+ return [];
+};
+
+const generateUnits = (doc, units) => {
+ units.forEach(({ name, weapon_profiles }) => {
+ weapon_profiles.forEach((profile, index) => {
+ const profileName = profile.name || 'Weapon Profile';
+ const {
+ num_models, attacks, to_hit, to_wound, rend, damage,
+ } = profile;
+ let body = [[num_models, attacks, `${to_hit}+`, `${to_wound}+`, rend, damage]];
+
+ if (profile.modifiers && profile.modifiers.length) {
+ const modifierItems = getModifierItems(profile.modifiers);
+ body = [...body, ...modifierItems];
+ }
+
+ let head = [
+ [{ content: profileName, colSpan: 6, styles: { halign: 'center' } }],
+ ['# Models', 'Attacks', 'To Hit', 'To Wound', 'Rend', 'Damage'],
+ ];
+ if (index === 0) {
+ const headStyle = {
+ halign: 'center',
+ fontStyle: 'bold',
+ fillColor: null,
+ textColor: 20,
+ fontSize: 12,
+ };
+ head = [[{ content: name, colSpan: 6, styles: headStyle }], ...head];
+ }
+
+ doc.autoTable({
+ startY: cursor.pos,
+ head,
+ body,
+ headStyles: { fillColor: headerColor },
+ columnStyles: {
+ 0: { cellWidth: 85 },
+ 1: { cellWidth: 85 },
+ 2: { cellWidth: 85 },
+ 3: { cellWidth: 85 },
+ 4: { cellWidth: 85 },
+ 5: { cellWidth: 85 },
+ },
+ pageBreak: 'avoid',
+ theme: 'grid',
+ });
+ cursor.pos = doc.previousAutoTable.finalY;
+ cursor.incr(doc.internal.getLineHeight() - 5);
+ });
+ cursor.incr(25);
+ });
+};
+
+const transposeData = (unitNames, results) => unitNames.reduce((acc, name) => {
+ const item = { name };
+ results.forEach(({ save, ...results }) => {
+ item[save] = results[name];
+ });
+ acc.push(item);
+ return acc;
+}, []);
+
+const generateStatsTable = (doc, results, unitNames) => {
+ const data = transposeData(unitNames, results);
+ const transformRow = ({ name, ...results }) => [
+ name,
+ ...Object.keys(results).map((k) => (
+ results[k]
+ )),
+ ];
+
+ doc.autoTable({
+ startY: cursor.pos,
+ head: [
+ [{ content: 'Average Damage', colSpan: 7, styles: { halign: 'center' } }],
+ ['Unit Name', ...results.map(({ save }) => (save !== 'None' ? `${save}+` : '-'))],
+ ],
+ body: data.map((row) => (
+ transformRow(row)
+ )),
+ headStyles: { fillColor: headerColor },
+ columnStyles: {
+ 1: { cellWidth: 40 },
+ 2: { cellWidth: 40 },
+ 3: { cellWidth: 40 },
+ 4: { cellWidth: 40 },
+ 5: { cellWidth: 40 },
+ 6: { cellWidth: 40 },
+ },
+ pageBreak: 'avoid',
+ theme: 'grid',
+ });
+ cursor.pos = doc.previousAutoTable.finalY;
+ cursor.incr(20);
+};
+
+const generate = (graphClassName, units, results, modifiers, unitNames) => {
+ window.html2canvas = html2canvas;
+ // eslint-disable-next-line new-cap
+ const doc = new jsPDF('p', 'pt', 'a4');
+ doc.setProperties({
+ title: 'AoS Statshammer Report',
+ });
+ cursor.reset();
+ cursor.incr(20);
+ doc.setFontSize(18);
+ doc.text('AoS Statshammer Report', doc.internal.pageSize.getWidth() / 2, cursor.pos, { align: 'center' });
+ cursor.incr(20);
+ doc.line(margin, cursor.pos, doc.internal.pageSize.getWidth() - (margin * 2), cursor.pos);
+ cursor.incr(doc.internal.getLineHeight());
+ // cursor.incr(5);
+ // doc.setFontSize(14);
+ // doc.text('Units', doc.internal.pageSize.getWidth() / 2, cursor.pos, { align: 'center' });
+ // cursor.incr(10);
+ doc.setFontSize(12);
+ generateUnits(doc, units);
+ doc.setFontSize(9);
+ doc.setFontType('italic');
+ doc.text(
+ 'Turn to next page for results',
+ doc.internal.pageSize.getWidth() - (margin * 2),
+ cursor.pos,
+ { align: 'right' },
+ );
+ doc.setFontType('normal');
+ doc.setFontSize(12);
+
+ doc.setFontSize(14);
+ doc.addPage();
+ cursor.reset();
+ cursor.incr(20);
+ doc.text('Results', doc.internal.pageSize.getWidth() / 2, cursor.pos, { align: 'center' });
+ doc.setFontSize(12);
+ cursor.incr(10);
+
+ generateStatsTable(doc, results, unitNames);
+
+ const elements = document.getElementsByClassName(graphClassName);
+ const promises = [];
+ Array.from(elements).forEach((element) => {
+ promises.push(doc.addHTML(element).then((canvas) => {
+ const pageWidth = doc.internal.pageSize.getWidth();
+ const imgData = canvas.toDataURL('image/jpeg', 1.0);
+ const imgProps = doc.getImageProperties(imgData);
+ const imgWidth = pageWidth - (margin * 2);
+ const imgHeight = (imgProps.height * imgWidth) / imgProps.width;
+ doc.addImage(imgData, 'JPEG', margin, cursor.pos, imgWidth, imgHeight);
+ cursor.incr(imgHeight + 20);
+ }));
+ });
+ return Promise.all(promises).then(() => doc);
+};
+
+export default generate;
diff --git a/client/src/pdf/generator/index.js b/client/src/pdf/generator/index.js
new file mode 100644
index 00000000..63a3fec5
--- /dev/null
+++ b/client/src/pdf/generator/index.js
@@ -0,0 +1 @@
+export { default } from './generate';
diff --git a/client/src/pdf/index.jsx b/client/src/pdf/index.jsx
new file mode 100644
index 00000000..54a13401
--- /dev/null
+++ b/client/src/pdf/index.jsx
@@ -0,0 +1 @@
+export { default } from './PdfGenerator';
diff --git a/client/src/utils/scrollIntoView.js b/client/src/utils/scrollIntoView.js
new file mode 100644
index 00000000..0bd1471e
--- /dev/null
+++ b/client/src/utils/scrollIntoView.js
@@ -0,0 +1,11 @@
+window.autoScrollEnabled = false;
+
+export const setAutoScrollEnabled = (enabled) => {
+ window.autoScrollEnabled = enabled;
+};
+
+export const scrollToRef = (ref, force = false) => {
+ if ((force || window.autoScrollEnabled) && ref && ref.current) {
+ ref.current.scrollIntoView({ behavior: 'smooth' });
+ }
+};
diff --git a/package.json b/package.json
index 4abc54df..2674e755 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "statshammer-express",
- "version": "0.4.0",
+ "version": "0.5.0",
"engines": {
"node": "12.13.1",
"yarn": "1.19.2"