Initial version

This commit is contained in:
Przemek Wiech 2019-01-26 22:01:07 +01:00
parent 12d9246add
commit b5775ef687
14 changed files with 906 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
build
node_modules
package-lock.json

6
.prettierrc.json Normal file
View File

@ -0,0 +1,6 @@
{
"singleQuote": true,
"bracketSpacing": false,
"arrowParens": "always",
"trailingComma": "all"
}

39
package.json Normal file
View File

@ -0,0 +1,39 @@
{
"name": "topola-viewer",
"version": "1.0.0",
"main": "src/index.tsx",
"dependencies": {
"axios": "^0.18.0",
"d3": "^5.7.0",
"history": "^4.7.2",
"md5": "^2.2.1",
"query-string": "^6.2.0",
"react": "latest",
"react-dom": "latest",
"react-router-dom": "^4.3.1",
"semantic-ui-css": "^2.4.1",
"semantic-ui-react": "^0.84.0",
"topola": "^2.0.2"
},
"devDependencies": {
"@types/d3": "^5.5.0",
"@types/history": "^4.7.2",
"@types/md5": "^2.1.33",
"@types/query-string": "^6.2.0",
"@types/react": "*",
"@types/react-dom": "*",
"@types/react-router-dom": "^4.3.1",
"@types/jest": "*",
"@types/node": "*",
"prettier": "^1.15.3",
"react-scripts-ts": "latest",
"tslint-config-prettier": "^1.17.0",
"typescript": "latest"
},
"scripts": {
"start": "react-scripts-ts start",
"build": "react-scripts-ts build",
"test": "react-scripts-ts test --env=jsdom",
"prettier": "prettier --write src/**/*.{ts,tsx}"
}
}

16
public/index.html Normal file
View File

@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="minimum-scale=1, initial-scale=1, width=device-width, shrink-to-fit=no">
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500" />
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons" />
<title>Topola Genealogy Viewer</title>
</head>
<body>
<noscript>
You need to enable JavaScript to run this app.
</noscript>
<div id="root"></div>
</body>
</html>

129
src/chart.tsx Normal file
View File

@ -0,0 +1,129 @@
import * as d3 from 'd3';
import * as React from 'react';
import {
JsonGedcomData,
ChartHandle,
IndiInfo,
createChart,
DetailedRenderer,
HourglassChart,
} from 'topola';
/** Called when the view is dragged with the mouse. */
function zoomed() {
const svg = d3.select('#chart');
const parent = (svg.node() as HTMLElement).parentElement!;
parent.scrollLeft = -d3.event.transform.x;
parent.scrollTop = -d3.event.transform.y;
}
/** Called when the scrollbars are used. */
function scrolled() {
const svg = d3.select('#chart');
const parent = (svg.node() as HTMLElement).parentElement!;
const x = parent.scrollLeft + parent.clientWidth / 2;
const y = parent.scrollTop + parent.clientHeight / 2;
d3.select(parent).call(d3.zoom().translateTo, x, y);
}
export interface ChartProps {
data: JsonGedcomData;
selection: IndiInfo;
onSelection: (indiInfo: IndiInfo) => void;
}
/** Component showing the genealogy chart and handling transition animations. */
export class Chart extends React.PureComponent<ChartProps, {}> {
private chart?: ChartHandle;
/**
* Renders the chart or performs a transition animation to a new state.
* If indiInfo is not given, it means that it is the initial render and no
* animation is performed.
*/
private renderChart(args: {initialRender: boolean} = {initialRender: false}) {
if (args.initialRender) {
(d3.select('#chart').node() as HTMLElement).innerHTML = '';
this.chart = createChart({
json: this.props.data,
chartType: HourglassChart,
renderer: DetailedRenderer,
svgSelector: '#chart',
indiCallback: (info) => this.props.onSelection(info),
animate: true,
updateSvgSize: false,
});
}
const chartInfo = this.chart!.render({
startIndi: this.props.selection.id,
baseGeneration: this.props.selection.generation,
});
const svg = d3.select('#chart');
const parent = (svg.node() as HTMLElement).parentElement!;
d3.select(parent)
.on('scroll', scrolled)
.call(
d3
.zoom()
.scaleExtent([1, 1])
.translateExtent([[0, 0], chartInfo.size])
.on('zoom', zoomed),
);
const scrollTopTween = (scrollTop: number) => {
return () => {
const i = d3.interpolateNumber(parent.scrollTop, scrollTop);
return (t: number) => {
parent.scrollTop = i(t);
};
};
};
const scrollLeftTween = (scrollLeft: number) => {
return () => {
const i = d3.interpolateNumber(parent.scrollLeft, scrollLeft);
return (t: number) => {
parent.scrollLeft = i(t);
};
};
};
const dx = parent.clientWidth / 2 - chartInfo.origin[0];
const dy = parent.clientHeight / 2 - chartInfo.origin[1];
const offsetX = d3.max([0, (parent.clientWidth - chartInfo.size[0]) / 2]);
const offsetY = d3.max([0, (parent.clientHeight - chartInfo.size[1]) / 2]);
const svgTransition = svg
.transition()
.delay(200)
.duration(500);
const transition = args.initialRender ? svg : svgTransition;
transition
.attr('transform', `translate(${offsetX}, ${offsetY})`)
.attr('width', chartInfo.size[0])
.attr('height', chartInfo.size[1]);
if (args.initialRender) {
parent.scrollLeft = -dx;
parent.scrollTop = -dy;
} else {
svgTransition
.tween('scrollLeft', scrollLeftTween(-dx))
.tween('scrollTop', scrollTopTween(-dy));
}
}
componentDidMount() {
this.renderChart({initialRender: true});
}
componentDidUpdate(prevProps: ChartProps) {
this.renderChart({initialRender: this.props.data !== prevProps.data});
}
render() {
return (
<div id="svgContainer">
<svg id="chart" />
</div>
);
}
}

233
src/chart_view.tsx Normal file
View File

@ -0,0 +1,233 @@
import * as md5 from 'md5';
import * as queryString from 'query-string';
import * as React from 'react';
import axios from 'axios';
import {Chart} from './chart';
import {convertGedcom} from './gedcom_util';
import {IndiInfo, JsonGedcomData} from 'topola';
import {Loader, Message} from 'semantic-ui-react';
import {RouteComponentProps} from 'react-router-dom';
/** Shows an error message. */
export function ErrorMessage(props: {message: string}) {
return (
<Message negative className="error">
<Message.Header>Failed to load file</Message.Header>
<p>{props.message}</p>
</Message>
);
}
/**
* Returns a valid IndiInfo object, either with the given indi and generation
* or with an individual taken from the data and generation 0.
*/
function getSelection(
data: JsonGedcomData,
indi?: string,
generation?: number,
): IndiInfo {
return {
id: indi || data.indis[0].id,
generation: generation || 0,
};
}
interface State {
/** Loaded data. */
data?: JsonGedcomData;
/** Selected individual. */
selection?: IndiInfo;
/** Hash of the GEDCOM contents. */
hash?: string;
/** Error to display. */
error?: string;
/** True if currently loading. */
loading: boolean;
/** URL of the data that is loaded or is being loaded. */
loadedUrl?: string;
}
/** The main area of the application dedicated for rendering the family chart. */
export class ChartView extends React.Component<RouteComponentProps, State> {
state: State = {loading: false};
/**
* Called when the user clicks an individual box in the chart.
* Updates the browser URL.
*/
onSelection = (selection: IndiInfo) => {
const location = this.props.location;
const search = queryString.parse(location.search);
search.indi = selection.id;
search.gen = String(selection.generation);
location.search = queryString.stringify(search);
this.props.history.push(location);
};
/** Loads a GEDCOM file from the given URL. */
loadFromUrl(
url: string,
options: {
handleCors?: boolean;
indi?: string;
generation?: number;
} = {},
) {
const cachedData = sessionStorage.getItem(url);
if (cachedData) {
const data = JSON.parse(cachedData);
this.setState(
Object.assign({}, this.state, {
data,
selection: getSelection(data, options.indi, options.generation),
loadedUrl: url,
loading: false,
error: undefined,
hash: undefined,
}),
);
return;
}
this.setState(
Object.assign({}, this.state, {
loading: true,
loadedUrl: url,
data: undefined,
error: undefined,
}),
);
const urlToFetch = options.handleCors
? 'https://cors-anywhere.herokuapp.com/' + url
: url;
axios
.get(urlToFetch)
.then((response) =>
this.setGedcom({
gedcom: response.data,
url,
indi: options.indi,
generation: options.generation,
}),
)
.catch((e: Error) =>
this.setState(
Object.assign({}, this.state, {error: e.message, loading: false}),
),
);
}
/**
* Converts GEDCOM contents and sets the data in the state.
* In case of an error reading the file, sets an error.
*/
setGedcom(input: {
gedcom: string;
url?: string;
indi?: string;
generation?: number;
}) {
try {
const data = convertGedcom(input.gedcom);
const hash = md5(input.gedcom);
const serializedData = JSON.stringify(data);
sessionStorage.setItem(input.url || hash, serializedData);
this.setState(
Object.assign({}, this.state, {
data,
selection: getSelection(data, input.indi, input.generation),
hash,
loading: false,
loadedUrl: input.url,
}),
);
} catch (e) {
this.setState(Object.assign({}, this.state, {error: e.message}));
}
}
componentDidMount() {
this.componentDidUpdate();
}
componentDidUpdate() {
const gedcom = this.props.location.state && this.props.location.state.data;
const search = queryString.parse(this.props.location.search);
const getParam = (name: string) => {
const value = search[name];
return typeof value === 'string' ? value : undefined;
};
const url = getParam('url');
const indi = getParam('indi');
const parsedGen = Number(getParam('gen'));
const generation = !isNaN(parsedGen) ? parsedGen : undefined;
const hash = getParam('file');
if (hash && hash !== this.state.hash) {
// New "load from file" data.
if (gedcom) {
this.setGedcom({gedcom, indi, generation});
} else {
// Data is not present. Try loading from cache.
const cachedData = sessionStorage.getItem(hash);
if (cachedData) {
const data = JSON.parse(cachedData);
this.setState(
Object.assign({}, this.state, {
data,
hash,
selection: getSelection(data, indi, generation),
error: undefined,
loading: false,
loadedUrl: undefined,
}),
);
} else {
// No data available. Redirect to main page.
this.props.history.replace({pathname: '/'});
}
}
} else if (!this.state.loading && url && this.state.loadedUrl !== url) {
// New URL to load data from.
this.loadFromUrl(url, {
indi,
generation,
handleCors: url.startsWith('http'),
});
} else if (!url && !gedcom && hash !== this.state.hash) {
this.props.history.replace({pathname: '/'});
} else if (this.state.data && this.state.selection) {
// Update selection if it has changed in the URL.
const selection = getSelection(this.state.data, indi, generation);
if (
this.state.selection.id !== selection.id ||
this.state.selection.generation !== selection.generation
) {
this.setState(
Object.assign({}, this.state, {
selection,
}),
);
}
}
}
render() {
if (this.state.data && this.state.selection) {
return (
<Chart
data={this.state.data}
onSelection={this.onSelection}
selection={this.state.selection}
/>
);
}
if (this.state.error) {
return <ErrorMessage message={this.state.error!} />;
}
return <Loader active size="large" />;
}
}

109
src/gedcom_util.ts Normal file
View File

@ -0,0 +1,109 @@
import {gedcomToJson, JsonFam, JsonGedcomData, JsonIndi} from 'topola';
function strcmp(a: string, b: string) {
if (a < b) {
return -1;
}
if (a > b) {
return 1;
}
return 0;
}
/** Birth date comparator for individuals. */
function birthDatesComparator(gedcom: JsonGedcomData) {
const idToIndiMap = new Map<string, JsonIndi>();
gedcom.indis.forEach((indi) => {
idToIndiMap[indi.id] = indi;
});
return (indiId1: string, indiId2: string) => {
const idComparison = strcmp(indiId1, indiId2);
const indi1: JsonIndi = idToIndiMap[indiId1];
const indi2: JsonIndi = idToIndiMap[indiId2];
const birth1 = indi1 && indi1.birth;
const birth2 = indi2 && indi2.birth;
const date1 =
birth1 && (birth1.date || (birth1.dateRange && birth1.dateRange.from));
const date2 =
birth2 && (birth2.date || (birth2.dateRange && birth2.dateRange.from));
if (!date1 || !date1.year || !date2 || !date2.year) {
return idComparison;
}
if (date1.year !== date2.year) {
return date1.year - date2.year;
}
if (!date1.month || !date2.month) {
return idComparison;
}
if (date1.month !== date2.month) {
return date1.month - date2.month;
}
if (date1.day && date2.day && date1.day !== date2.day) {
return date1.month - date2.month;
}
return idComparison;
};
}
/**
* Sorts children by birth date in the given family.
* Does not modify the input objects.
*/
function sortFamilyChildren(fam: JsonFam, gedcom: JsonGedcomData): JsonFam {
if (!fam.children) {
return fam;
}
const newChildren = fam.children.sort(birthDatesComparator(gedcom));
return Object.assign({}, fam, {children: newChildren});
}
/**
* Sorts children by birth date.
* Does not modify the input object.
*/
function sortChildren(gedcom: JsonGedcomData): JsonGedcomData {
const newFams = gedcom.fams.map((fam) => sortFamilyChildren(fam, gedcom));
return Object.assign({}, gedcom, {fams: newFams});
}
/**
* Removes images that are not HTTP links.
* Does not modify the input object.
*/
function filterImage(indi: JsonIndi): JsonIndi {
if (!indi.imageUrl || indi.imageUrl.startsWith('http')) {
return indi;
}
const newIndi = Object.assign({}, indi);
delete newIndi.imageUrl;
return newIndi;
}
/**
* Removes images that are not HTTP links.
* Does not modify the input object.
*/
function filterImages(gedcom: JsonGedcomData): JsonGedcomData {
const newIndis = gedcom.indis.map(filterImage);
return Object.assign({}, gedcom, {indis: newIndis});
}
/**
* Converts GEDCOM file into JSON data performing additional transformations:
* - sort children by birth date
* - remove images that are not HTTP links.
*/
export function convertGedcom(gedcom: string): JsonGedcomData {
const json = gedcomToJson(gedcom);
if (
!json ||
!json.indis ||
!json.indis.length ||
!json.fams ||
!json.fams.length
) {
throw new Error('Failed to read GEDCOM file');
}
return filterImages(sortChildren(json));
}

35
src/index.css Normal file
View File

@ -0,0 +1,35 @@
body, html, #root {
height: 100%;
}
.node {
cursor: pointer;
}
.root {
height: 100%;
display: flex;
flex-direction: column;
}
#svgContainer {
flex: 1 1 auto;
overflow: scroll;
}
.hidden {
display: none;
}
p.comment {
margin-top: 16px;
}
div.ui.negative.message {
margin: auto;
}
div.ui.card.intro {
width: 600px;
margin: auto;
}

21
src/index.tsx Normal file
View File

@ -0,0 +1,21 @@
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import {ChartView} from './chart_view';
import {HashRouter as Router, Route, Switch} from 'react-router-dom';
import {Intro} from './intro';
import {TopBar} from './top_bar';
import 'semantic-ui-css/semantic.min.css';
import './index.css';
ReactDOM.render(
<Router>
<div className="root">
<Route path="/" component={TopBar} />
<Switch>
<Route exact path="/" component={Intro} />
<Route exact path="/view" component={ChartView} />
</Switch>
</div>
</Router>,
document.querySelector('#root'),
);

79
src/intro.tsx Normal file
View File

@ -0,0 +1,79 @@
import * as queryString from 'query-string';
import * as React from 'react';
import {Card} from 'semantic-ui-react';
import {Link} from 'react-router-dom';
/** Link that loads a GEDCOM file from URL. */
function GedcomLink(props: {url: string; text: string}) {
return (
<Link
to={{pathname: '/view', search: queryString.stringify({url: props.url})}}
>
{props.text}
</Link>
);
}
/** The intro page. */
export function Intro() {
return (
<Card className="intro">
<Card.Content header="Topola Genealogy Viewer" />
<Card.Content>
<p>
Topola Genealogy is a genealogy tree viewer that lets you browse the
structure of the family.
</p>
<p>
Use the LOAD FROM URL or LOAD FROM FILE buttons above to load a GEDCOM
file. You can export a GEDCOM file from most of the existing genealogy
programs and web sites.
</p>
<p>Here are some examples from the web that you can view:</p>
<ul>
<li>
<GedcomLink
url="http://genpol.com/module-Downloads-prep_hand_out-lid-32.html"
text="Karol Wojtyła"
/>{' '}
(from{' '}
<a href="http://genpol.com/module-Downloads-display-lid-32.html">
GENPOL
</a>
)
</li>
<li>
<GedcomLink
url="https://webtreeprint.com/tp_downloader.php?path=famous_gedcoms/shakespeare.ged"
text="Shakespeare"
/>{' '}
(from{' '}
<a href="https://webtreeprint.com/tp_famous_gedcoms.php">
webtreeprint.com
</a>
)
</li>
<li>
<GedcomLink
url="http://genealogyoflife.com/tng/gedcom/HarryPotter.ged"
text="Harry Potter"
/>{' '}
(from{' '}
<a href="http://famousfamilytrees.blogspot.com/">
Famous Family Trees
</a>
)
</li>
</ul>
<p>
<b>Privacy</b>: When using the "load from file" option, this site does
not send your data anywhere and files loaded from disk do not leave
your computer. When using "load from URL", data is passed through the{' '}
<a href="https://cors-anywhere.herokuapp.com/">cors-anywhere</a>{' '}
service to deal with an issue with cross-site file loading in the
browser.
</p>
</Card.Content>
</Card>
);
}

145
src/top_bar.tsx Normal file
View File

@ -0,0 +1,145 @@
import * as md5 from 'md5';
import * as queryString from 'query-string';
import * as React from 'react';
import {Link} from 'react-router-dom';
import {RouteComponentProps} from 'react-router-dom';
import {
Header,
Button,
Icon,
Menu,
Modal,
Input,
Form,
} from 'semantic-ui-react';
/** Menus and dialogs state. */
interface State {
loadUrlDialogOpen: boolean;
url?: string;
}
export class TopBar extends React.Component<RouteComponentProps, State> {
state: State = {loadUrlDialogOpen: false};
inputRef?: Input;
/** Handles the "Upload file" button. */
handleUpload(event: React.SyntheticEvent<HTMLInputElement>) {
const files = (event.target as HTMLInputElement).files;
if (!files || !files.length) {
return;
}
const reader = new FileReader();
reader.onload = (evt: ProgressEvent) => {
const data = (evt.target as FileReader).result;
const hash = md5(data as string);
this.props.history.push({
pathname: '/view',
search: queryString.stringify({file: hash}),
state: {data},
});
};
reader.readAsText(files[0]);
}
/** Opens the "Load from URL" dialog. */
handleLoadFromUrl() {
this.setState(
Object.assign({}, this.state, {loadUrlDialogOpen: true}),
() => this.inputRef!.focus(),
);
}
/** Cancels the "Load from URL" dialog. */
handleClose() {
this.setState(Object.assign({}, this.state, {loadUrlDialogOpen: false}));
}
/** Upload button clicked in the "Load from URL" dialog. */
handleLoad() {
this.setState(
Object.assign({}, this.state, {
loadUrlDialogOpen: false,
}),
);
if (this.state.url) {
this.props.history.push({
pathname: '/view',
search: queryString.stringify({url: this.state.url}),
});
}
}
/** Called when the URL input is typed into. */
handleUrlChange(event: React.SyntheticEvent) {
this.setState(
Object.assign({}, this.state, {
url: (event.target as HTMLInputElement).value,
}),
);
}
render() {
const loadFromUrlModal = (
<Modal
open={this.state.loadUrlDialogOpen}
onClose={() => this.handleClose()}
centered={false}
>
<Header icon="cloud download" content="Load from URL" />
<Modal.Content>
<Form onSubmit={() => this.handleLoad()}>
<Input
placeholder="https://"
fluid
onChange={(e) => this.handleUrlChange(e)}
ref={(ref) => (this.inputRef = ref!)}
/>
<p className="comment">
Data from the URL will be loaded through{' '}
<a href="https://cors-anywhere.herokuapp.com/">
cors-anywhere.herokuapp.com
</a>{' '}
to avoid CORS issues.
</p>
</Form>
</Modal.Content>
<Modal.Actions>
<Button
secondary
content="Cancel"
onClick={() => this.handleClose()}
/>
<Button primary content="Load" onClick={() => this.handleLoad()} />
</Modal.Actions>
</Modal>
);
return (
<Menu attached="top" inverted color="blue" size="large">
<Link to="/">
<Menu.Item>
<b>Topola Genealogy</b>
</Menu.Item>
</Link>
<Menu.Item as="a" onClick={() => this.handleLoadFromUrl()}>
<Icon name="cloud download" />
Load from URL
</Menu.Item>
<input
className="hidden"
type="file"
id="fileInput"
onChange={(e) => this.handleUpload(e)}
/>
<label htmlFor="fileInput">
<Menu.Item as="a">
<Icon name="folder open" />
Load from file
</Menu.Item>
</label>
{loadFromUrlModal}
</Menu>
);
}
}

31
tsconfig.json Normal file
View File

@ -0,0 +1,31 @@
{
"compilerOptions": {
"baseUrl": ".",
"outDir": "build/dist",
"module": "esnext",
"target": "es5",
"lib": ["es6", "dom"],
"sourceMap": true,
"allowJs": true,
"jsx": "react",
"moduleResolution": "node",
"rootDir": "src",
"types": [],
"forceConsistentCasingInFileNames": true,
"noImplicitReturns": true,
"noImplicitThis": true,
"noImplicitAny": true,
"strictNullChecks": true,
"suppressImplicitAnyIndexErrors": true,
"noUnusedLocals": true
},
"exclude": [
"node_modules",
"build",
"scripts",
"acceptance-tests",
"webpack",
"jest",
"src/setupTests.ts"
]
}

1
tsconfig.prod.json Normal file
View File

@ -0,0 +1 @@
{ "extends": "./tsconfig.json" }

59
tslint.json Normal file
View File

@ -0,0 +1,59 @@
{
"extends": [
"tslint-config-prettier"
],
"rules": {
"array-type": [true, "array-simple"],
"arrow-return-shorthand": true,
"ban": [true,
{"name": "parseInt", "message": "tsstyle#type-coercion"},
{"name": "parseFloat", "message": "tsstyle#type-coercion"},
{"name": "Array", "message": "tsstyle#array-constructor"}
],
"ban-types": [true,
["Object", "Use {} instead."],
["String", "Use 'string' instead."],
["Number", "Use 'number' instead."],
["Boolean", "Use 'boolean' instead."]
],
"class-name": true,
"curly": [true, "ignore-same-line"],
"deprecation": true,
"forin": true,
"interface-name": [true, "never-prefix"],
"jsdoc-format": true,
"label-position": true,
"member-access": [true, "no-public"],
"new-parens": true,
"no-angle-bracket-type-assertion": true,
"no-any": true,
"no-arg": true,
"no-conditional-assignment": true,
"no-construct": true,
"no-debugger": true,
"no-default-export": true,
"no-duplicate-variable": true,
"no-inferrable-types": true,
"no-namespace": [true, "allow-declarations"],
"no-reference": true,
"no-string-throw": true,
"no-unused-expression": true,
"no-var-keyword": true,
"object-literal-shorthand": true,
"only-arrow-functions": [true, "allow-declarations", "allow-named-functions"],
"prefer-const": true,
"radix": true,
"semicolon": [true, "always", "ignore-bound-class-methods"],
"switch-default": true,
"triple-equals": [true, "allow-null-check"],
"use-isnan": true,
"variable-name": [
true,
"check-format",
"ban-keywords",
"allow-leading-underscore",
"allow-trailing-underscore",
"allow-pascal-case"
]
}
}