mirror of
https://github.com/PeWu/topola-viewer.git
synced 2026-05-26 15:16:14 +00:00
First version of side panel with person details.
Currently hidden. Can be shown by adding &sidePanel=true to URL. Will be shown by default when it's ready.
This commit is contained in:
@@ -10,6 +10,7 @@
|
|||||||
"history": "^4.7.2",
|
"history": "^4.7.2",
|
||||||
"jspdf": "^1.5.3",
|
"jspdf": "^1.5.3",
|
||||||
"md5": "^2.2.1",
|
"md5": "^2.2.1",
|
||||||
|
"parse-gedcom": "^1.0.4",
|
||||||
"query-string": "^5.1.1",
|
"query-string": "^5.1.1",
|
||||||
"react": "latest",
|
"react": "latest",
|
||||||
"react-dom": "latest",
|
"react-dom": "latest",
|
||||||
@@ -17,7 +18,7 @@
|
|||||||
"react-router-dom": "^4.3.1",
|
"react-router-dom": "^4.3.1",
|
||||||
"semantic-ui-css": "^2.4.1",
|
"semantic-ui-css": "^2.4.1",
|
||||||
"semantic-ui-react": "^0.84.0",
|
"semantic-ui-react": "^0.84.0",
|
||||||
"topola": "^2.2"
|
"topola": "^2.2.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/d3": "^5.5.0",
|
"@types/d3": "^5.5.0",
|
||||||
|
|||||||
44
src/app.tsx
44
src/app.tsx
@@ -1,12 +1,14 @@
|
|||||||
import * as queryString from 'query-string';
|
import * as queryString from 'query-string';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import {Chart} from './chart';
|
import {Chart} from './chart';
|
||||||
|
import {Details} from './details';
|
||||||
import {getSelection, loadFromUrl, loadGedcom} from './load_data';
|
import {getSelection, loadFromUrl, loadGedcom} from './load_data';
|
||||||
import {IndiInfo, JsonGedcomData} from 'topola';
|
import {IndiInfo} from 'topola';
|
||||||
import {Intro} from './intro';
|
import {Intro} from './intro';
|
||||||
import {Loader, Message} from 'semantic-ui-react';
|
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 {TopBar} from './top_bar';
|
||||||
|
import {TopolaData} from './gedcom_util';
|
||||||
|
|
||||||
/** Shows an error message. */
|
/** Shows an error message. */
|
||||||
export function ErrorMessage(props: {message: string}) {
|
export function ErrorMessage(props: {message: string}) {
|
||||||
@@ -20,7 +22,7 @@ export function ErrorMessage(props: {message: string}) {
|
|||||||
|
|
||||||
interface State {
|
interface State {
|
||||||
/** Loaded data. */
|
/** Loaded data. */
|
||||||
data?: JsonGedcomData;
|
data?: TopolaData;
|
||||||
/** Selected individual. */
|
/** Selected individual. */
|
||||||
selection?: IndiInfo;
|
selection?: IndiInfo;
|
||||||
/** Hash of the GEDCOM contents. */
|
/** Hash of the GEDCOM contents. */
|
||||||
@@ -31,6 +33,8 @@ interface State {
|
|||||||
loading: boolean;
|
loading: boolean;
|
||||||
/** URL of the data that is loaded or is being loaded. */
|
/** URL of the data that is loaded or is being loaded. */
|
||||||
url?: string;
|
url?: string;
|
||||||
|
/** Whether the side panel is shoen. */
|
||||||
|
showSidePanel?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class App extends React.Component<RouteComponentProps, {}> {
|
export class App extends React.Component<RouteComponentProps, {}> {
|
||||||
@@ -65,7 +69,8 @@ export class App extends React.Component<RouteComponentProps, {}> {
|
|||||||
const parsedGen = Number(getParam('gen'));
|
const parsedGen = Number(getParam('gen'));
|
||||||
const generation = !isNaN(parsedGen) ? parsedGen : undefined;
|
const generation = !isNaN(parsedGen) ? parsedGen : undefined;
|
||||||
const hash = getParam('file');
|
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) {
|
if (!url && !hash) {
|
||||||
this.props.history.replace({pathname: '/'});
|
this.props.history.replace({pathname: '/'});
|
||||||
@@ -80,10 +85,11 @@ export class App extends React.Component<RouteComponentProps, {}> {
|
|||||||
Object.assign({}, this.state, {
|
Object.assign({}, this.state, {
|
||||||
data,
|
data,
|
||||||
hash,
|
hash,
|
||||||
selection: getSelection(data, indi, generation),
|
selection: getSelection(data.chartData, indi, generation),
|
||||||
error: undefined,
|
error: undefined,
|
||||||
loading: false,
|
loading: false,
|
||||||
url,
|
url,
|
||||||
|
showSidePanel,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -110,7 +116,11 @@ export class App extends React.Component<RouteComponentProps, {}> {
|
|||||||
);
|
);
|
||||||
} else if (this.state.data && this.state.selection) {
|
} else if (this.state.data && this.state.selection) {
|
||||||
// Update selection if it has changed in the URL.
|
// 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 (
|
if (
|
||||||
this.state.selection.id !== selection.id ||
|
this.state.selection.id !== selection.id ||
|
||||||
this.state.selection.generation !== selection.generation
|
this.state.selection.generation !== selection.generation
|
||||||
@@ -140,12 +150,22 @@ export class App extends React.Component<RouteComponentProps, {}> {
|
|||||||
private renderMainArea = () => {
|
private renderMainArea = () => {
|
||||||
if (this.state.data && this.state.selection) {
|
if (this.state.data && this.state.selection) {
|
||||||
return (
|
return (
|
||||||
<Chart
|
<div id="content">
|
||||||
data={this.state.data}
|
<Chart
|
||||||
onSelection={this.onSelection}
|
data={this.state.data.chartData}
|
||||||
selection={this.state.selection}
|
onSelection={this.onSelection}
|
||||||
ref={(ref) => (this.chartRef = ref)}
|
selection={this.state.selection}
|
||||||
/>
|
ref={(ref) => (this.chartRef = ref)}
|
||||||
|
/>
|
||||||
|
{this.state.showSidePanel ? (
|
||||||
|
<div id="sidePanel">
|
||||||
|
<Details
|
||||||
|
gedcom={this.state.data.gedcom}
|
||||||
|
indi={this.state.selection.id}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (this.state.error) {
|
if (this.state.error) {
|
||||||
|
|||||||
118
src/details.tsx
Normal file
118
src/details.tsx
Normal file
@@ -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(<i>{note.data}</i>);
|
||||||
|
});
|
||||||
|
if (!lines.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="ui sub header">{header}</div>
|
||||||
|
<span>
|
||||||
|
{lines.map((line) => (
|
||||||
|
<>
|
||||||
|
{line}
|
||||||
|
<br />
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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(<i>{note.data}</i>);
|
||||||
|
});
|
||||||
|
if (!lines.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="ui sub header">{header}</div>
|
||||||
|
<span>
|
||||||
|
{lines.map((line) => (
|
||||||
|
<>
|
||||||
|
{line}
|
||||||
|
<br />
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nameDetails(entry: GedcomEntry, header: string) {
|
||||||
|
return (
|
||||||
|
<h2 className="ui header">
|
||||||
|
{entry.data
|
||||||
|
.split('/')
|
||||||
|
.filter((name) => !!name)
|
||||||
|
.map((name) => (
|
||||||
|
<>
|
||||||
|
{name}
|
||||||
|
<br />
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
</h2>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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) => <div className="ui segment">{element}</div>);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Props, {}> {
|
||||||
|
render() {
|
||||||
|
const entries = this.props.gedcom.indis[this.props.indi].tree;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ui segments" id="details">
|
||||||
|
{getDetails(entries, NAME_TAGS, nameDetails)}
|
||||||
|
{getDetails(entries, EVENT_TAGS, eventDetails)}
|
||||||
|
{getDetails(entries, DATA_TAGS, dataDetails)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
function strcmp(a: string, b: string) {
|
||||||
if (a < b) {
|
if (a < b) {
|
||||||
@@ -94,8 +128,9 @@ function filterImages(gedcom: JsonGedcomData): JsonGedcomData {
|
|||||||
* - sort children by birth date
|
* - sort children by birth date
|
||||||
* - remove images that are not HTTP links.
|
* - remove images that are not HTTP links.
|
||||||
*/
|
*/
|
||||||
export function convertGedcom(gedcom: string): JsonGedcomData {
|
export function convertGedcom(gedcom: string): TopolaData {
|
||||||
const json = gedcomToJson(gedcom);
|
const entries = parseGedcom(gedcom);
|
||||||
|
const json = gedcomEntriesToJson(entries);
|
||||||
if (
|
if (
|
||||||
!json ||
|
!json ||
|
||||||
!json.indis ||
|
!json.indis ||
|
||||||
@@ -105,5 +140,9 @@ export function convertGedcom(gedcom: string): JsonGedcomData {
|
|||||||
) {
|
) {
|
||||||
throw new Error('Failed to read GEDCOM file');
|
throw new Error('Failed to read GEDCOM file');
|
||||||
}
|
}
|
||||||
return filterImages(sortChildren(json));
|
|
||||||
|
return {
|
||||||
|
chartData: filterImages(sortChildren(json)),
|
||||||
|
gedcom: prepareGedcom(entries),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,9 +12,20 @@ body, html {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#content {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
#svgContainer {
|
#svgContainer {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
overflow: scroll;
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
#sidePanel {
|
||||||
|
flex: 0 0 320px;
|
||||||
|
overflow: auto;
|
||||||
|
border-left: solid #ccc 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hidden {
|
.hidden {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {convertGedcom} from './gedcom_util';
|
import {convertGedcom, TopolaData} from './gedcom_util';
|
||||||
import {IndiInfo, JsonGedcomData} from 'topola';
|
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. */
|
/** Fetches data from the given URL. Uses cors-anywhere if handleCors is true. */
|
||||||
export function loadFromUrl(
|
export function loadFromUrl(
|
||||||
url: string,
|
url: string,
|
||||||
handleCors: boolean,
|
handleCors: boolean,
|
||||||
): Promise<JsonGedcomData> {
|
): Promise<TopolaData> {
|
||||||
const cachedData = sessionStorage.getItem(url);
|
const cachedData = sessionStorage.getItem(url);
|
||||||
if (cachedData) {
|
if (cachedData) {
|
||||||
return Promise.resolve(JSON.parse(cachedData));
|
return Promise.resolve(JSON.parse(cachedData));
|
||||||
@@ -38,10 +49,7 @@ export function loadFromUrl(
|
|||||||
return response.text();
|
return response.text();
|
||||||
})
|
})
|
||||||
.then((gedcom) => {
|
.then((gedcom) => {
|
||||||
const data = convertGedcom(gedcom);
|
return prepareData(gedcom, url);
|
||||||
const serializedData = JSON.stringify(data);
|
|
||||||
sessionStorage.setItem(url, serializedData);
|
|
||||||
return data;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,17 +62,11 @@ function loadGedcomSync(hash: string, gedcom?: string) {
|
|||||||
if (!gedcom) {
|
if (!gedcom) {
|
||||||
throw new Error('Error loading data. Please upload your file again.');
|
throw new Error('Error loading data. Please upload your file again.');
|
||||||
}
|
}
|
||||||
const data = convertGedcom(gedcom);
|
return prepareData(gedcom, hash);
|
||||||
const serializedData = JSON.stringify(data);
|
|
||||||
sessionStorage.setItem(hash, serializedData);
|
|
||||||
return data;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Loads data from the given GEDCOM file contents. */
|
/** Loads data from the given GEDCOM file contents. */
|
||||||
export function loadGedcom(
|
export function loadGedcom(hash: string, gedcom?: string): Promise<TopolaData> {
|
||||||
hash: string,
|
|
||||||
gedcom?: string,
|
|
||||||
): Promise<JsonGedcomData> {
|
|
||||||
try {
|
try {
|
||||||
return Promise.resolve(loadGedcomSync(hash, gedcom));
|
return Promise.resolve(loadGedcomSync(hash, gedcom));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
12
src/parse-gedcom.d.ts
vendored
Normal file
12
src/parse-gedcom.d.ts
vendored
Normal file
@@ -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[];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user