mirror of
https://github.com/PeWu/topola-viewer.git
synced 2026-03-11 10:03:47 +00:00
573 lines
17 KiB
TypeScript
573 lines
17 KiB
TypeScript
import * as H from 'history';
|
|
import * as queryString from 'query-string';
|
|
import {analyticsEvent} from './util/analytics';
|
|
import {Changelog} from './changelog';
|
|
import {DataSourceEnum, SourceSelection} from './datasource/data_source';
|
|
import {Details} from './details/details';
|
|
import {EmbeddedDataSource, EmbeddedSourceSpec} from './datasource/embedded';
|
|
import {FormattedMessage, useIntl} from 'react-intl';
|
|
import {getI18nMessage} from './util/error_i18n';
|
|
import {IndiInfo} from 'topola';
|
|
import {Intro} from './intro';
|
|
import {Loader, Message, Portal, Tab} from 'semantic-ui-react';
|
|
import {Media} from './util/media';
|
|
import {Redirect, Route, Switch} from 'react-router-dom';
|
|
import {TopBar} from './menu/top_bar';
|
|
import {TopolaData} from './util/gedcom_util';
|
|
import {useEffect, useState} from 'react';
|
|
import {useHistory, useLocation} from 'react-router';
|
|
import {idToIndiMap} from './util/gedcom_util';
|
|
import {
|
|
Chart,
|
|
ChartType,
|
|
downloadPdf,
|
|
downloadPng,
|
|
downloadSvg,
|
|
printChart,
|
|
} from './chart';
|
|
import {
|
|
argsToConfig,
|
|
Config,
|
|
ConfigPanel,
|
|
configToArgs,
|
|
DEFALUT_CONFIG,
|
|
Ids,
|
|
Sex,
|
|
} from './config';
|
|
import {
|
|
getSelection,
|
|
UploadSourceSpec,
|
|
UrlSourceSpec,
|
|
GedcomUrlDataSource,
|
|
UploadedDataSource,
|
|
} from './datasource/load_data';
|
|
import {
|
|
loadWikiTree,
|
|
PRIVATE_ID_PREFIX,
|
|
WikiTreeDataSource,
|
|
WikiTreeSourceSpec,
|
|
} from './datasource/wikitree';
|
|
|
|
/** Shows an error message in the middle of the screen. */
|
|
function ErrorMessage(props: {message?: string}) {
|
|
return (
|
|
<Message negative className="error">
|
|
<Message.Header>
|
|
<FormattedMessage
|
|
id="error.failed_to_load_file"
|
|
defaultMessage={'Failed to load file'}
|
|
/>
|
|
</Message.Header>
|
|
<p>{props.message}</p>
|
|
</Message>
|
|
);
|
|
}
|
|
|
|
interface ErrorPopupProps {
|
|
message?: string;
|
|
open: boolean;
|
|
onDismiss: () => void;
|
|
}
|
|
|
|
/**
|
|
* Shows a dismissable error message in the bottom left corner of the screen.
|
|
*/
|
|
function ErrorPopup(props: ErrorPopupProps) {
|
|
return (
|
|
<Portal open={props.open} onClose={props.onDismiss}>
|
|
<Message negative className="errorPopup" onDismiss={props.onDismiss}>
|
|
<Message.Header>
|
|
<FormattedMessage id="error.error" defaultMessage={'Error'} />
|
|
</Message.Header>
|
|
<p>{props.message}</p>
|
|
</Message>
|
|
</Portal>
|
|
);
|
|
}
|
|
|
|
enum AppState {
|
|
INITIAL,
|
|
LOADING,
|
|
ERROR,
|
|
SHOWING_CHART,
|
|
LOADING_MORE,
|
|
}
|
|
|
|
type DataSourceSpec =
|
|
| UrlSourceSpec
|
|
| UploadSourceSpec
|
|
| WikiTreeSourceSpec
|
|
| EmbeddedSourceSpec;
|
|
|
|
/**
|
|
* Arguments passed to the application, primarily through URL parameters.
|
|
* Non-optional arguments get populated with default values.
|
|
*/
|
|
interface Arguments {
|
|
sourceSpec?: DataSourceSpec;
|
|
selection?: IndiInfo;
|
|
chartType: ChartType;
|
|
standalone: boolean;
|
|
showWikiTreeMenus: boolean;
|
|
freezeAnimation: boolean;
|
|
showSidePanel: boolean;
|
|
config: Config;
|
|
}
|
|
|
|
function getParamFromSearch(name: string, search: queryString.ParsedQuery<string>) {
|
|
const value = search[name];
|
|
return typeof value === 'string' ? value : undefined;
|
|
}
|
|
|
|
/**
|
|
* Retrieve arguments passed into the application through the URL and uploaded
|
|
* data.
|
|
*/
|
|
function getArguments(location: H.Location<any>): Arguments {
|
|
const search = queryString.parse(location.search);
|
|
const getParam = (name: string) => getParamFromSearch(name, search);
|
|
|
|
const view = getParam('view');
|
|
const chartTypes = new Map<string | undefined, ChartType>([
|
|
['relatives', ChartType.Relatives],
|
|
['fancy', ChartType.Fancy],
|
|
]);
|
|
|
|
const hash = getParam('file');
|
|
const url = getParam('url');
|
|
const embedded = getParam('embedded') === 'true'; // False by default.
|
|
var sourceSpec: DataSourceSpec | undefined = undefined;
|
|
if (getParam('source') === 'wikitree') {
|
|
const windowSearch = queryString.parse(window.location.search);
|
|
sourceSpec = {
|
|
source: DataSourceEnum.WIKITREE,
|
|
authcode: getParam('authcode') || getParamFromSearch('authcode', windowSearch),
|
|
};
|
|
} else if (hash) {
|
|
sourceSpec = {
|
|
source: DataSourceEnum.UPLOADED,
|
|
hash,
|
|
gedcom: location.state && location.state.data,
|
|
images: location.state && location.state.images,
|
|
};
|
|
} else if (url) {
|
|
sourceSpec = {
|
|
source: DataSourceEnum.GEDCOM_URL,
|
|
url,
|
|
handleCors: getParam('handleCors') !== 'false', // True by default.
|
|
};
|
|
} else if (embedded) {
|
|
sourceSpec = {source: DataSourceEnum.EMBEDDED};
|
|
}
|
|
|
|
const indi = getParam('indi');
|
|
const parsedGen = Number(getParam('gen'));
|
|
const selection = indi
|
|
? {id: indi, generation: !isNaN(parsedGen) ? parsedGen : 0}
|
|
: undefined;
|
|
|
|
return {
|
|
sourceSpec,
|
|
selection,
|
|
// Hourglass is the default view.
|
|
chartType: chartTypes.get(view) || ChartType.Hourglass,
|
|
|
|
showSidePanel: getParam('sidePanel') !== 'false', // True by default.
|
|
standalone: getParam('standalone') !== 'false' && !embedded,
|
|
showWikiTreeMenus: getParam('showWikiTreeMenus') !== 'false', // True by default.
|
|
freezeAnimation: getParam('freeze') === 'true', // False by default
|
|
config: argsToConfig(search),
|
|
};
|
|
}
|
|
|
|
export function App() {
|
|
/** State of the application. */
|
|
const [state, setState] = useState<AppState>(AppState.INITIAL);
|
|
/** Loaded data. */
|
|
const [data, setData] = useState<TopolaData>();
|
|
/** Selected individual. */
|
|
const [selection, setSelection] = useState<IndiInfo>();
|
|
/** Error to display. */
|
|
const [error, setError] = useState<string>();
|
|
/** Whether the side panel is shown. */
|
|
const [showSidePanel, setShowSidePanel] = useState(false);
|
|
/** Whether the app is in standalone mode, i.e. showing 'open file' menus. */
|
|
const [standalone, setStandalone] = useState(true);
|
|
/**
|
|
* Whether the app should display WikiTree-specific menus when showing data
|
|
* from WikiTree.
|
|
*/
|
|
const [showWikiTreeMenus, setShowWikiTreeMenus] = useState(true);
|
|
/** Type of displayed chart. */
|
|
const [chartType, setChartType] = useState<ChartType>(ChartType.Hourglass);
|
|
/** Whether to show the error popup. */
|
|
const [showErrorPopup, setShowErrorPopup] = useState(false);
|
|
/** Specification of the source of the data. */
|
|
const [sourceSpec, setSourceSpec] = useState<DataSourceSpec>();
|
|
/** Freeze animations after initial chart render. */
|
|
const [freezeAnimation, setFreezeAnimation] = useState(false);
|
|
const [config, setConfig] = useState(DEFALUT_CONFIG);
|
|
|
|
const intl = useIntl();
|
|
const history = useHistory();
|
|
const location = useLocation();
|
|
|
|
/** Sets the state with a new individual selection and chart type. */
|
|
function updateDisplay(newSelection: IndiInfo) {
|
|
if (
|
|
!selection ||
|
|
selection.id !== newSelection.id ||
|
|
selection!.generation !== newSelection.generation
|
|
) {
|
|
setSelection(newSelection);
|
|
}
|
|
}
|
|
|
|
function toggleDetails(config: Config, data: TopolaData | undefined) {
|
|
if (data === undefined) {
|
|
return;
|
|
}
|
|
let shouldHideIds = config.id === Ids.HIDE;
|
|
let shouldHideSex = config.sex === Sex.HIDE;
|
|
let indiMap = idToIndiMap(data.chartData);
|
|
indiMap.forEach((indi) => {
|
|
indi.hideId = shouldHideIds;
|
|
indi.hideSex = shouldHideSex;
|
|
});
|
|
}
|
|
|
|
/** Sets error message after data load failure. */
|
|
function setErrorMessage(message: string) {
|
|
setError(message);
|
|
setState(AppState.ERROR);
|
|
}
|
|
|
|
const uploadedDataSource = new UploadedDataSource();
|
|
const gedcomUrlDataSource = new GedcomUrlDataSource();
|
|
const wikiTreeDataSource = new WikiTreeDataSource(intl);
|
|
const embeddedDataSource = new EmbeddedDataSource();
|
|
|
|
function isNewData(newSourceSpec: DataSourceSpec, newSelection?: IndiInfo) {
|
|
if (!sourceSpec || sourceSpec.source !== newSourceSpec.source) {
|
|
// New data source means new data.
|
|
return true;
|
|
}
|
|
const newSource = {spec: newSourceSpec, selection: newSelection};
|
|
const oldSouce = {
|
|
spec: sourceSpec,
|
|
selection: selection,
|
|
};
|
|
switch (newSource.spec.source) {
|
|
case DataSourceEnum.UPLOADED:
|
|
return uploadedDataSource.isNewData(
|
|
newSource as SourceSelection<UploadSourceSpec>,
|
|
oldSouce as SourceSelection<UploadSourceSpec>,
|
|
data,
|
|
);
|
|
case DataSourceEnum.GEDCOM_URL:
|
|
return gedcomUrlDataSource.isNewData(
|
|
newSource as SourceSelection<UrlSourceSpec>,
|
|
oldSouce as SourceSelection<UrlSourceSpec>,
|
|
data,
|
|
);
|
|
case DataSourceEnum.WIKITREE:
|
|
return wikiTreeDataSource.isNewData(
|
|
newSource as SourceSelection<WikiTreeSourceSpec>,
|
|
oldSouce as SourceSelection<WikiTreeSourceSpec>,
|
|
data,
|
|
);
|
|
case DataSourceEnum.EMBEDDED:
|
|
return embeddedDataSource.isNewData(
|
|
newSource as SourceSelection<EmbeddedSourceSpec>,
|
|
oldSouce as SourceSelection<EmbeddedSourceSpec>,
|
|
data,
|
|
);
|
|
}
|
|
}
|
|
|
|
function loadData(newSourceSpec: DataSourceSpec, newSelection?: IndiInfo) {
|
|
switch (newSourceSpec.source) {
|
|
case DataSourceEnum.UPLOADED:
|
|
return uploadedDataSource.loadData({
|
|
spec: newSourceSpec,
|
|
selection: newSelection,
|
|
});
|
|
case DataSourceEnum.GEDCOM_URL:
|
|
return gedcomUrlDataSource.loadData({
|
|
spec: newSourceSpec,
|
|
selection: newSelection,
|
|
});
|
|
case DataSourceEnum.WIKITREE:
|
|
return wikiTreeDataSource.loadData({
|
|
spec: newSourceSpec,
|
|
selection: newSelection,
|
|
});
|
|
case DataSourceEnum.EMBEDDED:
|
|
return embeddedDataSource.loadData({
|
|
spec: newSourceSpec,
|
|
selection: newSelection,
|
|
});
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
(async () => {
|
|
if (location.pathname !== '/view') {
|
|
if (state !== AppState.INITIAL) {
|
|
setState(AppState.INITIAL);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const args = getArguments(location);
|
|
|
|
if (!args.sourceSpec) {
|
|
history.replace({pathname: '/'});
|
|
return;
|
|
}
|
|
|
|
if (
|
|
state === AppState.INITIAL ||
|
|
isNewData(args.sourceSpec, args.selection)
|
|
) {
|
|
// Set loading state.
|
|
setState(AppState.LOADING);
|
|
// Set state from URL parameters.
|
|
setSourceSpec(args.sourceSpec);
|
|
setSelection(args.selection);
|
|
setStandalone(args.standalone);
|
|
setShowWikiTreeMenus(args.showWikiTreeMenus);
|
|
setChartType(args.chartType);
|
|
setFreezeAnimation(args.freezeAnimation);
|
|
setConfig(args.config);
|
|
try {
|
|
const data = await loadData(args.sourceSpec, args.selection);
|
|
// Set state with data.
|
|
setData(data);
|
|
toggleDetails(args.config, data);
|
|
setShowSidePanel(args.showSidePanel);
|
|
setState(AppState.SHOWING_CHART);
|
|
} catch (error: any) {
|
|
setErrorMessage(getI18nMessage(error, intl));
|
|
}
|
|
} else if (
|
|
state === AppState.SHOWING_CHART ||
|
|
state === AppState.LOADING_MORE
|
|
) {
|
|
// Update selection if it has changed in the URL.
|
|
const loadMoreFromWikitree =
|
|
args.sourceSpec.source === DataSourceEnum.WIKITREE &&
|
|
(!selection || selection.id !== args.selection?.id);
|
|
setChartType(args.chartType);
|
|
setState(
|
|
loadMoreFromWikitree ? AppState.LOADING_MORE : AppState.SHOWING_CHART,
|
|
);
|
|
updateDisplay(args.selection!);
|
|
if (loadMoreFromWikitree) {
|
|
try {
|
|
const data = await loadWikiTree(args.selection!.id, intl);
|
|
const newSelection = getSelection(data.chartData, args.selection);
|
|
setData(data);
|
|
setSelection(newSelection);
|
|
setState(AppState.SHOWING_CHART);
|
|
} catch (error: any) {
|
|
setState(AppState.SHOWING_CHART);
|
|
displayErrorPopup(
|
|
intl.formatMessage(
|
|
{
|
|
id: 'error.failed_wikitree_load_more',
|
|
defaultMessage: 'Failed to load data from WikiTree. {error}',
|
|
},
|
|
{error},
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
})();
|
|
});
|
|
|
|
function updateUrl(args: queryString.ParsedQuery<any>) {
|
|
const search = queryString.parse(location.search);
|
|
for (const key in args) {
|
|
search[key] = args[key];
|
|
}
|
|
location.search = queryString.stringify(search);
|
|
history.push(location);
|
|
}
|
|
|
|
/**
|
|
* Called when the user clicks an individual box in the chart.
|
|
* Updates the browser URL.
|
|
*/
|
|
function onSelection(selection: IndiInfo) {
|
|
// Don't allow selecting WikiTree private profiles.
|
|
if (selection.id.startsWith(PRIVATE_ID_PREFIX)) {
|
|
return;
|
|
}
|
|
analyticsEvent('selection_changed');
|
|
updateUrl({
|
|
indi: selection.id,
|
|
gen: selection.generation,
|
|
});
|
|
}
|
|
|
|
function onPrint() {
|
|
analyticsEvent('print');
|
|
printChart();
|
|
}
|
|
|
|
function displayErrorPopup(message: string) {
|
|
setShowErrorPopup(true);
|
|
setError(message);
|
|
}
|
|
|
|
async function onDownloadPdf() {
|
|
analyticsEvent('download_pdf');
|
|
try {
|
|
await downloadPdf();
|
|
} catch (e) {
|
|
displayErrorPopup(
|
|
intl.formatMessage({
|
|
id: 'error.failed_pdf',
|
|
defaultMessage:
|
|
'Failed to generate PDF file.' +
|
|
' Please try with a smaller diagram or download an SVG file.',
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
async function onDownloadPng() {
|
|
analyticsEvent('download_png');
|
|
try {
|
|
await downloadPng();
|
|
} catch (e) {
|
|
displayErrorPopup(
|
|
intl.formatMessage({
|
|
id: 'error.failed_png',
|
|
defaultMessage:
|
|
'Failed to generate PNG file.' +
|
|
' Please try with a smaller diagram or download an SVG file.',
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
function onDownloadSvg() {
|
|
analyticsEvent('download_svg');
|
|
downloadSvg();
|
|
}
|
|
|
|
function onDismissErrorPopup() {
|
|
setShowErrorPopup(false);
|
|
}
|
|
|
|
function renderMainArea() {
|
|
switch (state) {
|
|
case AppState.SHOWING_CHART:
|
|
case AppState.LOADING_MORE:
|
|
const updatedSelection = getSelection(data!.chartData, selection);
|
|
const sidePanelTabs = [
|
|
{
|
|
menuItem: intl.formatMessage({
|
|
id: 'tab.info',
|
|
defaultMessage: 'Info',
|
|
}),
|
|
render: () => (
|
|
<Details gedcom={data!.gedcom} indi={updatedSelection.id} />
|
|
),
|
|
},
|
|
{
|
|
menuItem: intl.formatMessage({
|
|
id: 'tab.settings',
|
|
defaultMessage: 'Settings',
|
|
}),
|
|
render: () => (
|
|
<ConfigPanel
|
|
config={config}
|
|
onChange={(config) => {
|
|
setConfig(config);
|
|
toggleDetails(config, data);
|
|
updateUrl(configToArgs(config));
|
|
}}
|
|
/>
|
|
),
|
|
},
|
|
];
|
|
return (
|
|
<div id="content">
|
|
<ErrorPopup
|
|
open={showErrorPopup}
|
|
message={error}
|
|
onDismiss={onDismissErrorPopup}
|
|
/>
|
|
{state === AppState.LOADING_MORE ? (
|
|
<Loader active size="small" className="loading-more" />
|
|
) : null}
|
|
<Chart
|
|
data={data!.chartData}
|
|
selection={updatedSelection}
|
|
chartType={chartType}
|
|
onSelection={onSelection}
|
|
freezeAnimation={freezeAnimation}
|
|
colors={config.color}
|
|
hideIds={config.id}
|
|
hideSex={config.sex}
|
|
/>
|
|
{showSidePanel ? (
|
|
<Media greaterThanOrEqual="large" className="sidePanel">
|
|
<Tab panes={sidePanelTabs} />
|
|
</Media>
|
|
) : null}
|
|
<Changelog />
|
|
</div>
|
|
);
|
|
|
|
case AppState.ERROR:
|
|
return <ErrorMessage message={error!} />;
|
|
|
|
case AppState.INITIAL:
|
|
case AppState.LOADING:
|
|
return <Loader active size="large" />;
|
|
}
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<Route
|
|
render={() => (
|
|
<TopBar
|
|
data={data?.chartData}
|
|
allowAllRelativesChart={
|
|
sourceSpec?.source !== DataSourceEnum.WIKITREE
|
|
}
|
|
showingChart={
|
|
history.location.pathname === '/view' &&
|
|
(state === AppState.SHOWING_CHART ||
|
|
state === AppState.LOADING_MORE)
|
|
}
|
|
standalone={standalone}
|
|
eventHandlers={{
|
|
onSelection,
|
|
onPrint,
|
|
onDownloadPdf,
|
|
onDownloadPng,
|
|
onDownloadSvg,
|
|
}}
|
|
showWikiTreeMenus={
|
|
sourceSpec?.source === DataSourceEnum.WIKITREE &&
|
|
showWikiTreeMenus
|
|
}
|
|
/>
|
|
)}
|
|
/>
|
|
<Switch>
|
|
<Route exact path="/" component={Intro} />
|
|
<Route exact path="/view" render={renderMainArea} />
|
|
<Redirect to={'/'} />
|
|
</Switch>
|
|
</>
|
|
);
|
|
}
|