diff --git a/package.json b/package.json index fbe1ad5..acb97b6 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "history": "^4.7.2", "jspdf": "^1.5.3", "md5": "^2.2.1", + "parse-gedcom": "^1.0.4", "query-string": "^5.1.1", "react": "latest", "react-dom": "latest", @@ -17,7 +18,7 @@ "react-router-dom": "^4.3.1", "semantic-ui-css": "^2.4.1", "semantic-ui-react": "^0.84.0", - "topola": "^2.2" + "topola": "^2.2.2" }, "devDependencies": { "@types/d3": "^5.5.0", diff --git a/src/app.tsx b/src/app.tsx index b1374fd..2fcdf6d 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -1,12 +1,14 @@ import * as queryString from 'query-string'; import * as React from 'react'; import {Chart} from './chart'; +import {Details} from './details'; import {getSelection, loadFromUrl, loadGedcom} from './load_data'; -import {IndiInfo, JsonGedcomData} from 'topola'; +import {IndiInfo} from 'topola'; import {Intro} from './intro'; import {Loader, Message} from 'semantic-ui-react'; -import {Route, RouteComponentProps, Switch, Redirect} from 'react-router-dom'; +import {Redirect, Route, RouteComponentProps, Switch} from 'react-router-dom'; import {TopBar} from './top_bar'; +import {TopolaData} from './gedcom_util'; /** Shows an error message. */ export function ErrorMessage(props: {message: string}) { @@ -20,7 +22,7 @@ export function ErrorMessage(props: {message: string}) { interface State { /** Loaded data. */ - data?: JsonGedcomData; + data?: TopolaData; /** Selected individual. */ selection?: IndiInfo; /** Hash of the GEDCOM contents. */ @@ -31,6 +33,8 @@ interface State { loading: boolean; /** URL of the data that is loaded or is being loaded. */ url?: string; + /** Whether the side panel is shoen. */ + showSidePanel?: boolean; } export class App extends React.Component { @@ -65,7 +69,8 @@ export class App extends React.Component { const parsedGen = Number(getParam('gen')); const generation = !isNaN(parsedGen) ? parsedGen : undefined; const hash = getParam('file'); - const handleCors = getParam('handleCors') !== 'false'; + const handleCors = getParam('handleCors') !== 'false'; // True by default. + const showSidePanel = getParam('sidePanel') === 'true'; // False by default. if (!url && !hash) { this.props.history.replace({pathname: '/'}); @@ -80,10 +85,11 @@ export class App extends React.Component { Object.assign({}, this.state, { data, hash, - selection: getSelection(data, indi, generation), + selection: getSelection(data.chartData, indi, generation), error: undefined, loading: false, url, + showSidePanel, }), ); }, @@ -110,7 +116,11 @@ export class App extends React.Component { ); } 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); + const selection = getSelection( + this.state.data.chartData, + indi, + generation, + ); if ( this.state.selection.id !== selection.id || this.state.selection.generation !== selection.generation @@ -140,12 +150,22 @@ export class App extends React.Component { private renderMainArea = () => { if (this.state.data && this.state.selection) { return ( - (this.chartRef = ref)} - /> +
+ (this.chartRef = ref)} + /> + {this.state.showSidePanel ? ( +
+
+
+ ) : null} +
); } if (this.state.error) { diff --git a/src/details.tsx b/src/details.tsx new file mode 100644 index 0000000..1aff637 --- /dev/null +++ b/src/details.tsx @@ -0,0 +1,118 @@ +import * as React from 'react'; +import {GedcomData} from './gedcom_util'; +import {GedcomEntry} from 'parse-gedcom'; + +interface Props { + gedcom: GedcomData; + indi: string; +} + +function eventDetails(entry: GedcomEntry, header: string) { + const lines = []; + const date = entry.tree.find((subentry) => subentry.tag === 'DATE'); + if (date && date.data) { + lines.push(date.data); + } + const place = entry.tree.find((subentry) => subentry.tag === 'PLAC'); + if (place && place.data) { + lines.push(place.data); + } + entry.tree + .filter((subentry) => subentry.tag === 'NOTE') + .forEach((note) => { + lines.push({note.data}); + }); + if (!lines.length) { + return null; + } + return ( + <> +
{header}
+ + {lines.map((line) => ( + <> + {line} +
+ + ))} +
+ + ); +} + +function dataDetails(entry: GedcomEntry, header: string) { + const lines = []; + if (entry.data) { + lines.push(entry.data); + } + entry.tree + .filter((subentry) => subentry.tag === 'NOTE') + .forEach((note) => { + lines.push({note.data}); + }); + if (!lines.length) { + return null; + } + return ( + <> +
{header}
+ + {lines.map((line) => ( + <> + {line} +
+ + ))} +
+ + ); +} + +function nameDetails(entry: GedcomEntry, header: string) { + return ( +

+ {entry.data + .split('/') + .filter((name) => !!name) + .map((name) => ( + <> + {name} +
+ + ))} +

+ ); +} + +function getDetails( + entries: GedcomEntry[], + tags: string[], + detailsFunction: (entry: GedcomEntry, header: string) => JSX.Element | null, +): JSX.Element[] { + return tags + .flatMap((tag) => + entries + .filter((entry) => entry.tag === tag) + .map((entry) => detailsFunction(entry, tag)), + ) + .filter((element) => element !== null) + .map((element) =>
{element}
); +} + +const NAME_TAGS = ['NAME']; +const EVENT_TAGS = ['BIRT', 'BAPM', 'CHR', 'DEAT', 'BURI']; +const DATA_TAGS = ['TITL', 'OCCU', 'WWW', 'EMAIL']; + +export class Details extends React.Component { + render() { + const entries = this.props.gedcom.indis[this.props.indi].tree; + + return ( +
+ {getDetails(entries, NAME_TAGS, nameDetails)} + {getDetails(entries, EVENT_TAGS, eventDetails)} + {getDetails(entries, DATA_TAGS, dataDetails)} +
+ ); + } +} diff --git a/src/gedcom_util.ts b/src/gedcom_util.ts index f2d719f..cd1540f 100644 --- a/src/gedcom_util.ts +++ b/src/gedcom_util.ts @@ -1,4 +1,38 @@ -import {gedcomToJson, JsonFam, JsonGedcomData, JsonIndi} from 'topola'; +import {JsonFam, JsonGedcomData, JsonIndi, gedcomEntriesToJson} from 'topola'; +import {GedcomEntry, parse as parseGedcom} from 'parse-gedcom'; + +export interface GedcomData { + head: GedcomEntry; + indis: {[key: string]: GedcomEntry}; + fams: {[key: string]: GedcomEntry}; +} + +export interface TopolaData { + chartData: JsonGedcomData; + gedcom: GedcomData; +} + +/** + * Returns the identifier extracted from a pointer string. + * E.g. '@I123@' -> 'I123' + */ +function pointerToId(pointer: string): string { + return pointer.substring(1, pointer.length - 1); +} + +function prepareGedcom(entries: GedcomEntry[]): GedcomData { + const head = entries.find((entry) => entry.tag === 'HEAD')!; + const indis: {[key: string]: GedcomEntry} = {}; + const fams: {[key: string]: GedcomEntry} = {}; + entries.forEach((entry) => { + if (entry.tag === 'INDI') { + indis[pointerToId(entry.pointer)] = entry; + } else if (entry.tag === 'FAM') { + fams[pointerToId(entry.pointer)] = entry; + } + }); + return {head, indis, fams}; +} function strcmp(a: string, b: string) { if (a < b) { @@ -94,8 +128,9 @@ function filterImages(gedcom: JsonGedcomData): JsonGedcomData { * - sort children by birth date * - remove images that are not HTTP links. */ -export function convertGedcom(gedcom: string): JsonGedcomData { - const json = gedcomToJson(gedcom); +export function convertGedcom(gedcom: string): TopolaData { + const entries = parseGedcom(gedcom); + const json = gedcomEntriesToJson(entries); if ( !json || !json.indis || @@ -105,5 +140,9 @@ export function convertGedcom(gedcom: string): JsonGedcomData { ) { throw new Error('Failed to read GEDCOM file'); } - return filterImages(sortChildren(json)); + + return { + chartData: filterImages(sortChildren(json)), + gedcom: prepareGedcom(entries), + }; } diff --git a/src/index.css b/src/index.css index eb53f88..6588c12 100644 --- a/src/index.css +++ b/src/index.css @@ -12,9 +12,20 @@ body, html { flex-direction: column; } +#content { + flex: 1 1 auto; + display: flex; +} + #svgContainer { flex: 1 1 auto; - overflow: scroll; + overflow: auto; +} + +#sidePanel { + flex: 0 0 320px; + overflow: auto; + border-left: solid #ccc 1px; } .hidden { diff --git a/src/load_data.ts b/src/load_data.ts index fd3adb9..f086de5 100644 --- a/src/load_data.ts +++ b/src/load_data.ts @@ -1,4 +1,4 @@ -import {convertGedcom} from './gedcom_util'; +import {convertGedcom, TopolaData} from './gedcom_util'; import {IndiInfo, JsonGedcomData} from 'topola'; /** @@ -16,11 +16,22 @@ export function getSelection( }; } +function prepareData(gedcom: string, cacheId: string): TopolaData { + const data = convertGedcom(gedcom); + const serializedData = JSON.stringify(data); + try { + sessionStorage.setItem(cacheId, serializedData); + } catch (e) { + console.warn('Failed to store data in session storage: ' + e); + } + return data; +} + /** Fetches data from the given URL. Uses cors-anywhere if handleCors is true. */ export function loadFromUrl( url: string, handleCors: boolean, -): Promise { +): Promise { const cachedData = sessionStorage.getItem(url); if (cachedData) { return Promise.resolve(JSON.parse(cachedData)); @@ -38,10 +49,7 @@ export function loadFromUrl( return response.text(); }) .then((gedcom) => { - const data = convertGedcom(gedcom); - const serializedData = JSON.stringify(data); - sessionStorage.setItem(url, serializedData); - return data; + return prepareData(gedcom, url); }); } @@ -54,17 +62,11 @@ function loadGedcomSync(hash: string, gedcom?: string) { if (!gedcom) { throw new Error('Error loading data. Please upload your file again.'); } - const data = convertGedcom(gedcom); - const serializedData = JSON.stringify(data); - sessionStorage.setItem(hash, serializedData); - return data; + return prepareData(gedcom, hash); } /** Loads data from the given GEDCOM file contents. */ -export function loadGedcom( - hash: string, - gedcom?: string, -): Promise { +export function loadGedcom(hash: string, gedcom?: string): Promise { try { return Promise.resolve(loadGedcomSync(hash, gedcom)); } catch (e) { diff --git a/src/parse-gedcom.d.ts b/src/parse-gedcom.d.ts new file mode 100644 index 0000000..233496c --- /dev/null +++ b/src/parse-gedcom.d.ts @@ -0,0 +1,12 @@ +// Data type definitions for the parse-gedcom library. +declare module 'parse-gedcom' { + interface GedcomEntry { + level: number; + pointer: string; + tag: string; + data: string; + tree: GedcomEntry[]; + } + + export function parse(input: string): GedcomEntry[]; +}