mirror of
https://github.com/PeWu/topola-viewer.git
synced 2026-04-23 15:06:14 +00:00
Initial version
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
build
|
||||||
|
node_modules
|
||||||
|
package-lock.json
|
||||||
6
.prettierrc.json
Normal file
6
.prettierrc.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"bracketSpacing": false,
|
||||||
|
"arrowParens": "always",
|
||||||
|
"trailingComma": "all"
|
||||||
|
}
|
||||||
39
package.json
Normal file
39
package.json
Normal 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
16
public/index.html
Normal 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
129
src/chart.tsx
Normal 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
233
src/chart_view.tsx
Normal 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
109
src/gedcom_util.ts
Normal 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
35
src/index.css
Normal 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
21
src/index.tsx
Normal 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
79
src/intro.tsx
Normal 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
145
src/top_bar.tsx
Normal 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
31
tsconfig.json
Normal 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
1
tsconfig.prod.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{ "extends": "./tsconfig.json" }
|
||||||
59
tslint.json
Normal file
59
tslint.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user