mirror of
https://github.com/PeWu/topola-viewer.git
synced 2026-02-18 02:55:48 +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:
parent
730642fb4e
commit
018bbe9ff0
@ -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",
|
||||
|
||||
44
src/app.tsx
44
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<RouteComponentProps, {}> {
|
||||
@ -65,7 +69,8 @@ export class App extends React.Component<RouteComponentProps, {}> {
|
||||
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<RouteComponentProps, {}> {
|
||||
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<RouteComponentProps, {}> {
|
||||
);
|
||||
} 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<RouteComponentProps, {}> {
|
||||
private renderMainArea = () => {
|
||||
if (this.state.data && this.state.selection) {
|
||||
return (
|
||||
<Chart
|
||||
data={this.state.data}
|
||||
onSelection={this.onSelection}
|
||||
selection={this.state.selection}
|
||||
ref={(ref) => (this.chartRef = ref)}
|
||||
/>
|
||||
<div id="content">
|
||||
<Chart
|
||||
data={this.state.data.chartData}
|
||||
onSelection={this.onSelection}
|
||||
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) {
|
||||
|
||||
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) {
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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<JsonGedcomData> {
|
||||
): Promise<TopolaData> {
|
||||
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<JsonGedcomData> {
|
||||
export function loadGedcom(hash: string, gedcom?: string): Promise<TopolaData> {
|
||||
try {
|
||||
return Promise.resolve(loadGedcomSync(hash, gedcom));
|
||||
} 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[];
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user