mirror of
https://github.com/PeWu/topola-viewer.git
synced 2026-07-17 17:21:48 +00:00
Extract useGenealogyLoader from view_page.tsx
This commit is contained in:
349
src/hooks/use_genealogy_loader.ts
Normal file
349
src/hooks/use_genealogy_loader.ts
Normal file
@@ -0,0 +1,349 @@
|
||||
import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
|
||||
import {IntlShape} from 'react-intl';
|
||||
import {useLocation, useNavigate} from 'react-router';
|
||||
import {IndiInfo} from 'topola';
|
||||
import {DataSourceEnum, SourceSelection} from '../datasource/data_source';
|
||||
import {EmbeddedSourceSpec} from '../datasource/embedded';
|
||||
import {
|
||||
GoogleDriveAuthError,
|
||||
GoogleDriveSourceSpec,
|
||||
} from '../datasource/google_drive';
|
||||
import {isGoogleDriveConfigured} from '../datasource/google_drive_service';
|
||||
import {
|
||||
embeddedDataSource,
|
||||
gedcomUrlDataSource,
|
||||
googleDriveDataSource,
|
||||
uploadedDataSource,
|
||||
} from '../datasource/instances';
|
||||
import {
|
||||
getSelection,
|
||||
revokeObjectUrls,
|
||||
UploadSourceSpec,
|
||||
UrlSourceSpec,
|
||||
} from '../datasource/load_data';
|
||||
import {
|
||||
loadWikiTree,
|
||||
WikiTreeDataSource,
|
||||
WikiTreeSourceSpec,
|
||||
} from '../datasource/wikitree';
|
||||
import {AppState} from '../pages/view_page';
|
||||
import {getI18nMessage} from '../util/error_i18n';
|
||||
import {TopolaData} from '../util/gedcom_util';
|
||||
import {DataSourceSpec, getArguments} from '../util/url_args';
|
||||
|
||||
/**
|
||||
* Custom React hook that orchestrates loading genealogy data from various sources
|
||||
* (Uploaded files, Google Drive, WikiTree API, URL params, or Embedded data).
|
||||
* It manages the async lifecycle, error boundary popups, and incremental WikiTree loading.
|
||||
*/
|
||||
export function useGenealogyLoader(options: {
|
||||
intl: IntlShape;
|
||||
urlSelection?: IndiInfo;
|
||||
urlDetail?: string;
|
||||
/** Callback triggered when a Google Drive authorization error occurs. */
|
||||
onAuthError: (fileId: string) => void;
|
||||
}) {
|
||||
const {intl, urlSelection, urlDetail, onAuthError} = options;
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const [state, setState] = useState<AppState>(AppState.INITIAL);
|
||||
const [loadingStatus, setLoadingStatus] = useState('Loading…');
|
||||
const [data, setData] = useState<TopolaData>();
|
||||
const [error, setError] = useState<string>();
|
||||
const [showErrorPopup, setShowErrorPopup] = useState(false);
|
||||
const [sourceSpec, setSourceSpec] = useState<DataSourceSpec>();
|
||||
|
||||
const isMountedRef = useRef(true);
|
||||
const fetchIdRef = useRef(0);
|
||||
const loadedSelectionRef = useRef<IndiInfo>();
|
||||
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const wikiTreeDataSource = useMemo(
|
||||
() => new WikiTreeDataSource(intl),
|
||||
[intl],
|
||||
);
|
||||
|
||||
const isNewData = useCallback(
|
||||
(newSourceSpec: DataSourceSpec, newSelection?: IndiInfo) => {
|
||||
if (!sourceSpec || sourceSpec.source !== newSourceSpec.source) {
|
||||
return true;
|
||||
}
|
||||
const newSource = {spec: newSourceSpec, selection: newSelection};
|
||||
const oldSource = {
|
||||
spec: sourceSpec,
|
||||
selection: loadedSelectionRef.current,
|
||||
};
|
||||
switch (newSource.spec.source) {
|
||||
case DataSourceEnum.UPLOADED:
|
||||
return uploadedDataSource.isNewData(
|
||||
newSource as unknown as SourceSelection<UploadSourceSpec>,
|
||||
oldSource as unknown as SourceSelection<UploadSourceSpec>,
|
||||
data,
|
||||
);
|
||||
case DataSourceEnum.GEDCOM_URL:
|
||||
return gedcomUrlDataSource.isNewData(
|
||||
newSource as unknown as SourceSelection<UrlSourceSpec>,
|
||||
oldSource as unknown as SourceSelection<UrlSourceSpec>,
|
||||
data,
|
||||
);
|
||||
case DataSourceEnum.WIKITREE:
|
||||
return wikiTreeDataSource.isNewData(
|
||||
newSource as unknown as SourceSelection<WikiTreeSourceSpec>,
|
||||
oldSource as unknown as SourceSelection<WikiTreeSourceSpec>,
|
||||
data,
|
||||
);
|
||||
case DataSourceEnum.EMBEDDED:
|
||||
return embeddedDataSource.isNewData(
|
||||
newSource as unknown as SourceSelection<EmbeddedSourceSpec>,
|
||||
oldSource as unknown as SourceSelection<EmbeddedSourceSpec>,
|
||||
data,
|
||||
);
|
||||
case DataSourceEnum.GOOGLE_DRIVE:
|
||||
return googleDriveDataSource.isNewData(
|
||||
newSource as unknown as SourceSelection<GoogleDriveSourceSpec>,
|
||||
oldSource as unknown as SourceSelection<GoogleDriveSourceSpec>,
|
||||
data,
|
||||
);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[sourceSpec, data, wikiTreeDataSource],
|
||||
);
|
||||
|
||||
const loadData = useCallback(
|
||||
(
|
||||
newSourceSpec: DataSourceSpec,
|
||||
newSelection?: IndiInfo,
|
||||
onProgress?: (status: string) => void,
|
||||
) => {
|
||||
switch (newSourceSpec.source) {
|
||||
case DataSourceEnum.UPLOADED:
|
||||
return uploadedDataSource.loadData(
|
||||
{
|
||||
spec: newSourceSpec as UploadSourceSpec,
|
||||
selection: newSelection,
|
||||
},
|
||||
onProgress,
|
||||
);
|
||||
case DataSourceEnum.GEDCOM_URL:
|
||||
return gedcomUrlDataSource.loadData(
|
||||
{spec: newSourceSpec as UrlSourceSpec, selection: newSelection},
|
||||
onProgress,
|
||||
);
|
||||
case DataSourceEnum.WIKITREE:
|
||||
return wikiTreeDataSource.loadData(
|
||||
{
|
||||
spec: newSourceSpec as WikiTreeSourceSpec,
|
||||
selection: newSelection,
|
||||
},
|
||||
onProgress,
|
||||
);
|
||||
case DataSourceEnum.EMBEDDED:
|
||||
return embeddedDataSource.loadData(
|
||||
{
|
||||
spec: newSourceSpec as EmbeddedSourceSpec,
|
||||
selection: newSelection,
|
||||
},
|
||||
onProgress,
|
||||
);
|
||||
case DataSourceEnum.GOOGLE_DRIVE:
|
||||
if (!isGoogleDriveConfigured()) {
|
||||
throw new Error('Google Drive integration is not configured.');
|
||||
}
|
||||
return googleDriveDataSource.loadData(
|
||||
{
|
||||
spec: newSourceSpec as GoogleDriveSourceSpec,
|
||||
selection: newSelection,
|
||||
},
|
||||
onProgress,
|
||||
);
|
||||
default:
|
||||
throw new Error('Unsupported data source');
|
||||
}
|
||||
},
|
||||
[wikiTreeDataSource],
|
||||
);
|
||||
|
||||
const setErrorMessage = useCallback((message: string) => {
|
||||
setError(message);
|
||||
setState(AppState.ERROR);
|
||||
}, []);
|
||||
|
||||
const displayErrorPopup = useCallback((message: string) => {
|
||||
setShowErrorPopup(true);
|
||||
setError(message);
|
||||
}, []);
|
||||
|
||||
const onDismissErrorPopup = useCallback(() => {
|
||||
setShowErrorPopup(false);
|
||||
}, []);
|
||||
|
||||
const resetLoader = useCallback(() => {
|
||||
setState(AppState.INITIAL);
|
||||
}, []);
|
||||
|
||||
const clearData = useCallback(() => {
|
||||
setData(undefined);
|
||||
loadedSelectionRef.current = undefined;
|
||||
}, []);
|
||||
|
||||
// Main data loading and updating side-effect
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (location.pathname !== '/view') {
|
||||
if (state !== AppState.INITIAL) {
|
||||
setState(AppState.INITIAL);
|
||||
}
|
||||
setData(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const args = getArguments(location);
|
||||
|
||||
if (!args.sourceSpec) {
|
||||
navigate({pathname: '/'}, {replace: true});
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
(state === AppState.INITIAL ||
|
||||
isNewData(args.sourceSpec, args.selection)) &&
|
||||
state !== AppState.LOADING &&
|
||||
state !== AppState.LOADING_MORE
|
||||
) {
|
||||
setState(AppState.LOADING);
|
||||
setSourceSpec(args.sourceSpec);
|
||||
loadedSelectionRef.current = args.selection;
|
||||
const currentFetchId = ++fetchIdRef.current;
|
||||
setLoadingStatus('Loading…');
|
||||
try {
|
||||
const data = await loadData(
|
||||
args.sourceSpec,
|
||||
args.selection,
|
||||
(status) => {
|
||||
if (isMountedRef.current) setLoadingStatus(status);
|
||||
},
|
||||
);
|
||||
if (!isMountedRef.current || fetchIdRef.current !== currentFetchId) {
|
||||
return;
|
||||
}
|
||||
setLoadingStatus(
|
||||
`Rendering chart (${data.chartData.indis.length.toLocaleString()} people)…`,
|
||||
);
|
||||
setData(data);
|
||||
loadedSelectionRef.current = getSelection(
|
||||
data.chartData,
|
||||
args.selection,
|
||||
);
|
||||
setState(AppState.SHOWING_CHART);
|
||||
} catch (error: unknown) {
|
||||
if (!isMountedRef.current || fetchIdRef.current !== currentFetchId) {
|
||||
return;
|
||||
}
|
||||
if (error instanceof GoogleDriveAuthError) {
|
||||
if (args.sourceSpec.source === DataSourceEnum.GOOGLE_DRIVE) {
|
||||
onAuthError((args.sourceSpec as GoogleDriveSourceSpec).fileId);
|
||||
}
|
||||
} else {
|
||||
setErrorMessage(getI18nMessage(error as Error, intl));
|
||||
}
|
||||
}
|
||||
} else if (
|
||||
state === AppState.SHOWING_CHART ||
|
||||
state === AppState.LOADING_MORE
|
||||
) {
|
||||
const loadMoreFromWikitree =
|
||||
args.sourceSpec.source === DataSourceEnum.WIKITREE &&
|
||||
!!args.selection &&
|
||||
(!loadedSelectionRef.current ||
|
||||
loadedSelectionRef.current.id !== args.selection.id);
|
||||
setState(
|
||||
loadMoreFromWikitree ? AppState.LOADING_MORE : AppState.SHOWING_CHART,
|
||||
);
|
||||
if (loadMoreFromWikitree && args.selection) {
|
||||
const currentFetchId = ++fetchIdRef.current;
|
||||
try {
|
||||
const data = await loadWikiTree(args.selection.id, intl);
|
||||
if (
|
||||
!isMountedRef.current ||
|
||||
fetchIdRef.current !== currentFetchId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const newSelection = getSelection(data.chartData, args.selection);
|
||||
setData(data);
|
||||
loadedSelectionRef.current = newSelection;
|
||||
setState(AppState.SHOWING_CHART);
|
||||
} catch (error: unknown) {
|
||||
if (
|
||||
!isMountedRef.current ||
|
||||
fetchIdRef.current !== currentFetchId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setState(AppState.SHOWING_CHART);
|
||||
displayErrorPopup(
|
||||
intl.formatMessage(
|
||||
{
|
||||
id: 'error.failed_wikitree_load_more',
|
||||
defaultMessage: 'Failed to load data from WikiTree. {error}',
|
||||
},
|
||||
{error: (error as Error).message || String(error)},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
})();
|
||||
}, [
|
||||
location,
|
||||
state,
|
||||
data,
|
||||
navigate,
|
||||
intl,
|
||||
isNewData,
|
||||
loadData,
|
||||
onAuthError,
|
||||
setErrorMessage,
|
||||
displayErrorPopup,
|
||||
]);
|
||||
|
||||
// Clean up object URLs created for uploaded images/files when the dataset
|
||||
// changes or the app unmounts to prevent memory leaks.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
revokeObjectUrls(data?.images);
|
||||
};
|
||||
}, [data]);
|
||||
|
||||
const updatedSelection = useMemo(() => {
|
||||
return data ? getSelection(data.chartData, urlSelection) : undefined;
|
||||
}, [data, urlSelection]);
|
||||
|
||||
const detailIndi = urlDetail || updatedSelection?.id;
|
||||
|
||||
return {
|
||||
state,
|
||||
loadingStatus,
|
||||
data,
|
||||
error,
|
||||
showErrorPopup,
|
||||
sourceSpec,
|
||||
updatedSelection,
|
||||
detailIndi,
|
||||
loadedSelectionRef,
|
||||
onDismissErrorPopup,
|
||||
resetLoader,
|
||||
clearData,
|
||||
setLoadingStatus,
|
||||
displayErrorPopup,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
|
||||
import {useCallback, useMemo} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {useLocation, useNavigate} from 'react-router';
|
||||
import {Loader, SidebarPushable, SidebarPusher} from 'semantic-ui-react';
|
||||
import {IndiInfo} from 'topola';
|
||||
import {
|
||||
@@ -13,31 +12,9 @@ import {
|
||||
} from '../chart';
|
||||
import {ErrorMessage, ErrorPopup} from '../components/error_display';
|
||||
import {ProgressPill} from '../components/progress_pill';
|
||||
import {DataSourceEnum, SourceSelection} from '../datasource/data_source';
|
||||
import {EmbeddedSourceSpec} from '../datasource/embedded';
|
||||
import {
|
||||
GoogleDriveAuthError,
|
||||
GoogleDriveSourceSpec,
|
||||
} from '../datasource/google_drive';
|
||||
import {isGoogleDriveConfigured} from '../datasource/google_drive_service';
|
||||
import {
|
||||
embeddedDataSource,
|
||||
gedcomUrlDataSource,
|
||||
googleDriveDataSource,
|
||||
uploadedDataSource,
|
||||
} from '../datasource/instances';
|
||||
import {
|
||||
getSelection,
|
||||
revokeObjectUrls,
|
||||
UploadSourceSpec,
|
||||
UrlSourceSpec,
|
||||
} from '../datasource/load_data';
|
||||
import {
|
||||
loadWikiTree,
|
||||
WikiTreeDataSource,
|
||||
WikiTreeSourceSpec,
|
||||
} from '../datasource/wikitree';
|
||||
import {DataSourceEnum} from '../datasource/data_source';
|
||||
import {DonatsoChart} from '../donatso-chart';
|
||||
import {useGenealogyLoader} from '../hooks/use_genealogy_loader';
|
||||
import {useGoogleDriveAuthFlow} from '../hooks/use_google_drive_auth_flow';
|
||||
import {useUrlState} from '../hooks/use_url_state';
|
||||
import {useWebMcpBridge} from '../hooks/use_webmcp_bridge';
|
||||
@@ -46,10 +23,7 @@ import {TopBar} from '../menu/top_bar';
|
||||
import {Config, Ids, Sex} from '../sidepanel/config/config';
|
||||
import {SidePanel} from '../sidepanel/side-panel';
|
||||
import {analyticsEvent} from '../util/analytics';
|
||||
import {TopolaError} from '../util/error';
|
||||
import {getI18nMessage} from '../util/error_i18n';
|
||||
import {idToIndiMap, TopolaData} from '../util/gedcom_util';
|
||||
import {DataSourceSpec, getArguments} from '../util/url_args';
|
||||
|
||||
export enum AppState {
|
||||
INITIAL,
|
||||
@@ -59,46 +33,30 @@ export enum AppState {
|
||||
LOADING_MORE,
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the chart data nodes with custom display parameters (such as hiding IDs or genders)
|
||||
* in place prior to rendering D3 canvas.
|
||||
*/
|
||||
function updateChartWithConfig(config: Config, data: TopolaData | undefined) {
|
||||
if (data === undefined) {
|
||||
return;
|
||||
}
|
||||
const shouldHideIds = config.id === Ids.HIDE;
|
||||
const shouldHideSex = config.sex === Sex.HIDE;
|
||||
const indiMap = idToIndiMap(data.chartData);
|
||||
indiMap.forEach((indi) => {
|
||||
indi.hideId = shouldHideIds;
|
||||
indi.hideSex = shouldHideSex;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* ViewPage is the page component that orchestrates the genealogy chart workspace.
|
||||
* It manages asynchronous data loading, configuration parameters derived from the URL,
|
||||
* chart rendering (both D3 and Donatso), side panel settings, and Google Drive auth workflows.
|
||||
*/
|
||||
export function ViewPage() {
|
||||
/** State of the application. */
|
||||
const [state, setState] = useState<AppState>(AppState.INITIAL);
|
||||
/** Progress message shown during LOADING and initial chart render. */
|
||||
const [loadingStatus, setLoadingStatus] = useState('Loading…');
|
||||
/** Loaded data. */
|
||||
const [data, setData] = useState<TopolaData>();
|
||||
|
||||
/** Error to display. */
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
/** Whether to show the error popup. */
|
||||
const [showErrorPopup, setShowErrorPopup] = useState(false);
|
||||
/** Specification of the source of the data. */
|
||||
const [sourceSpec, setSourceSpec] = useState<DataSourceSpec>();
|
||||
|
||||
// Manage Google Drive auth and session flows
|
||||
const {
|
||||
showAuthModal,
|
||||
failedFileId,
|
||||
hasGoogleToken,
|
||||
setHasGoogleToken,
|
||||
onGoogleSignOut,
|
||||
triggerAuthError,
|
||||
onAuthSuccess,
|
||||
onCancel,
|
||||
} = useGoogleDriveAuthFlow({
|
||||
onSignOut: useCallback(() => {
|
||||
setData(undefined);
|
||||
loadedSelectionRef.current = undefined;
|
||||
}, []),
|
||||
onAuthSuccess: useCallback(() => {
|
||||
setState(AppState.INITIAL);
|
||||
}, []),
|
||||
});
|
||||
const intl = useIntl();
|
||||
|
||||
const {
|
||||
chartType,
|
||||
@@ -115,289 +73,47 @@ export function ViewPage() {
|
||||
onConfigChange,
|
||||
} = useUrlState();
|
||||
|
||||
const intl = useIntl();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const {
|
||||
state,
|
||||
loadingStatus,
|
||||
data,
|
||||
error,
|
||||
showErrorPopup,
|
||||
sourceSpec,
|
||||
updatedSelection,
|
||||
detailIndi,
|
||||
onDismissErrorPopup,
|
||||
resetLoader,
|
||||
clearData,
|
||||
setLoadingStatus,
|
||||
displayErrorPopup,
|
||||
} = useGenealogyLoader({
|
||||
intl,
|
||||
urlSelection,
|
||||
urlDetail,
|
||||
onAuthError: useCallback((fileId) => {
|
||||
triggerAuthError(fileId);
|
||||
}, []),
|
||||
});
|
||||
|
||||
const {
|
||||
showAuthModal,
|
||||
failedFileId,
|
||||
hasGoogleToken,
|
||||
setHasGoogleToken,
|
||||
onGoogleSignOut,
|
||||
triggerAuthError,
|
||||
onAuthSuccess,
|
||||
onCancel,
|
||||
} = useGoogleDriveAuthFlow({
|
||||
onSignOut: clearData,
|
||||
onAuthSuccess: resetLoader,
|
||||
});
|
||||
|
||||
useMemo(() => {
|
||||
updateChartWithConfig(config, data);
|
||||
}, [config, data]);
|
||||
|
||||
/** The currently selected individual. Fallback to default individual from loaded data if not specified. */
|
||||
const updatedSelection = useMemo(() => {
|
||||
return data ? getSelection(data.chartData, urlSelection) : undefined;
|
||||
}, [data, urlSelection]);
|
||||
|
||||
/** The individual displayed in the details pane. */
|
||||
const detailIndi = urlDetail || updatedSelection?.id;
|
||||
|
||||
/** Tracks whether the component is currently mounted to prevent state updates after unmount. */
|
||||
const isMountedRef = useRef(true);
|
||||
/** Incremented with each load request to ensure only the latest asynchronous load result is applied. */
|
||||
const fetchIdRef = useRef(0);
|
||||
/** Tracks the currently loaded selection to check if new data needs to be fetched. */
|
||||
const loadedSelectionRef = useRef<IndiInfo>();
|
||||
|
||||
/** Manages the mount lifecycle ref to avoid setting state on unmounted components. */
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
function updateChartWithConfig(config: Config, data: TopolaData | undefined) {
|
||||
if (data === undefined) {
|
||||
return;
|
||||
}
|
||||
const shouldHideIds = config.id === Ids.HIDE;
|
||||
const shouldHideSex = config.sex === Sex.HIDE;
|
||||
const indiMap = idToIndiMap(data.chartData);
|
||||
indiMap.forEach((indi) => {
|
||||
indi.hideId = shouldHideIds;
|
||||
indi.hideSex = shouldHideSex;
|
||||
});
|
||||
}
|
||||
|
||||
/** Sets error message after data load failure. */
|
||||
|
||||
/** Sets error message after data load failure. */
|
||||
function setErrorMessage(message: string) {
|
||||
setError(message);
|
||||
setState(AppState.ERROR);
|
||||
}
|
||||
|
||||
const wikiTreeDataSource = useMemo(
|
||||
() => new WikiTreeDataSource(intl),
|
||||
[intl],
|
||||
);
|
||||
|
||||
const isNewData = useCallback(
|
||||
(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 oldSource = {
|
||||
spec: sourceSpec,
|
||||
selection: loadedSelectionRef.current,
|
||||
};
|
||||
switch (newSource.spec.source) {
|
||||
case DataSourceEnum.UPLOADED:
|
||||
return uploadedDataSource.isNewData(
|
||||
newSource as SourceSelection<UploadSourceSpec>,
|
||||
oldSource as SourceSelection<UploadSourceSpec>,
|
||||
data,
|
||||
);
|
||||
case DataSourceEnum.GEDCOM_URL:
|
||||
return gedcomUrlDataSource.isNewData(
|
||||
newSource as SourceSelection<UrlSourceSpec>,
|
||||
oldSource as SourceSelection<UrlSourceSpec>,
|
||||
data,
|
||||
);
|
||||
case DataSourceEnum.WIKITREE:
|
||||
return wikiTreeDataSource.isNewData(
|
||||
newSource as SourceSelection<WikiTreeSourceSpec>,
|
||||
oldSource as SourceSelection<WikiTreeSourceSpec>,
|
||||
data,
|
||||
);
|
||||
case DataSourceEnum.EMBEDDED:
|
||||
return embeddedDataSource.isNewData(
|
||||
newSource as SourceSelection<EmbeddedSourceSpec>,
|
||||
oldSource as SourceSelection<EmbeddedSourceSpec>,
|
||||
data,
|
||||
);
|
||||
case DataSourceEnum.GOOGLE_DRIVE:
|
||||
return googleDriveDataSource.isNewData(
|
||||
newSource as SourceSelection<GoogleDriveSourceSpec>,
|
||||
oldSource as SourceSelection<GoogleDriveSourceSpec>,
|
||||
data,
|
||||
);
|
||||
}
|
||||
},
|
||||
[sourceSpec, data, wikiTreeDataSource],
|
||||
);
|
||||
|
||||
const loadData = useCallback(
|
||||
(
|
||||
newSourceSpec: DataSourceSpec,
|
||||
newSelection?: IndiInfo,
|
||||
onProgress?: (status: string) => void,
|
||||
) => {
|
||||
switch (newSourceSpec.source) {
|
||||
case DataSourceEnum.UPLOADED:
|
||||
return uploadedDataSource.loadData(
|
||||
{spec: newSourceSpec, selection: newSelection},
|
||||
onProgress,
|
||||
);
|
||||
case DataSourceEnum.GEDCOM_URL:
|
||||
return gedcomUrlDataSource.loadData(
|
||||
{spec: newSourceSpec, selection: newSelection},
|
||||
onProgress,
|
||||
);
|
||||
case DataSourceEnum.WIKITREE:
|
||||
return wikiTreeDataSource.loadData(
|
||||
{spec: newSourceSpec, selection: newSelection},
|
||||
onProgress,
|
||||
);
|
||||
case DataSourceEnum.EMBEDDED:
|
||||
return embeddedDataSource.loadData(
|
||||
{spec: newSourceSpec, selection: newSelection},
|
||||
onProgress,
|
||||
);
|
||||
case DataSourceEnum.GOOGLE_DRIVE:
|
||||
if (!isGoogleDriveConfigured()) {
|
||||
throw new TopolaError(
|
||||
'GOOGLE_DRIVE_NOT_CONFIGURED',
|
||||
'Google Drive integration is not configured.',
|
||||
);
|
||||
}
|
||||
return googleDriveDataSource.loadData(
|
||||
{
|
||||
spec: newSourceSpec as GoogleDriveSourceSpec,
|
||||
selection: newSelection,
|
||||
},
|
||||
onProgress,
|
||||
);
|
||||
}
|
||||
},
|
||||
[wikiTreeDataSource],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (location.pathname !== '/view') {
|
||||
if (state !== AppState.INITIAL) {
|
||||
setState(AppState.INITIAL);
|
||||
}
|
||||
setData(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const args = getArguments(location);
|
||||
|
||||
if (!args.sourceSpec) {
|
||||
navigate({pathname: '/'}, {replace: true});
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
(state === AppState.INITIAL ||
|
||||
isNewData(args.sourceSpec, args.selection)) &&
|
||||
state !== AppState.LOADING &&
|
||||
state !== AppState.LOADING_MORE
|
||||
) {
|
||||
// Set loading state.
|
||||
setState(AppState.LOADING);
|
||||
// Set state from URL parameters.
|
||||
setSourceSpec(args.sourceSpec);
|
||||
loadedSelectionRef.current = args.selection;
|
||||
const currentFetchId = ++fetchIdRef.current;
|
||||
setLoadingStatus('Loading…');
|
||||
try {
|
||||
const data = await loadData(
|
||||
args.sourceSpec,
|
||||
args.selection,
|
||||
(status) => {
|
||||
if (isMountedRef.current) setLoadingStatus(status);
|
||||
},
|
||||
);
|
||||
if (!isMountedRef.current || fetchIdRef.current !== currentFetchId) {
|
||||
return;
|
||||
}
|
||||
// Show "Rendering chart…" while the initial D3 layout runs (which
|
||||
// happens in the Chart useEffect after SHOWING_CHART is set).
|
||||
setLoadingStatus(
|
||||
`Rendering chart (${data.chartData.indis.length.toLocaleString()} people)…`,
|
||||
);
|
||||
setData(data);
|
||||
loadedSelectionRef.current = getSelection(
|
||||
data.chartData,
|
||||
args.selection,
|
||||
);
|
||||
setState(AppState.SHOWING_CHART);
|
||||
} catch (error: unknown) {
|
||||
if (!isMountedRef.current || fetchIdRef.current !== currentFetchId) {
|
||||
return;
|
||||
}
|
||||
if (error instanceof GoogleDriveAuthError) {
|
||||
if (args.sourceSpec.source === DataSourceEnum.GOOGLE_DRIVE) {
|
||||
triggerAuthError(args.sourceSpec.fileId);
|
||||
}
|
||||
} else {
|
||||
setErrorMessage(getI18nMessage(error as 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 &&
|
||||
!!args.selection &&
|
||||
(!loadedSelectionRef.current ||
|
||||
loadedSelectionRef.current.id !== args.selection.id);
|
||||
setState(
|
||||
loadMoreFromWikitree ? AppState.LOADING_MORE : AppState.SHOWING_CHART,
|
||||
);
|
||||
if (loadMoreFromWikitree) {
|
||||
const currentFetchId = ++fetchIdRef.current;
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const data = await loadWikiTree(args.selection!.id, intl);
|
||||
if (
|
||||
!isMountedRef.current ||
|
||||
fetchIdRef.current !== currentFetchId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const newSelection = getSelection(data.chartData, args.selection);
|
||||
setData(data);
|
||||
loadedSelectionRef.current = newSelection;
|
||||
setState(AppState.SHOWING_CHART);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (error: any) {
|
||||
if (
|
||||
!isMountedRef.current ||
|
||||
fetchIdRef.current !== currentFetchId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setState(AppState.SHOWING_CHART);
|
||||
displayErrorPopup(
|
||||
intl.formatMessage(
|
||||
{
|
||||
id: 'error.failed_wikitree_load_more',
|
||||
defaultMessage: 'Failed to load data from WikiTree. {error}',
|
||||
},
|
||||
{error},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
})();
|
||||
}, [
|
||||
location,
|
||||
state,
|
||||
data,
|
||||
navigate,
|
||||
intl,
|
||||
isNewData,
|
||||
loadData,
|
||||
triggerAuthError,
|
||||
]);
|
||||
|
||||
// Clean up object URLs created for uploaded images/files when the dataset
|
||||
// changes or the app unmounts to prevent memory leaks.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
revokeObjectUrls(data?.images);
|
||||
};
|
||||
}, [data]);
|
||||
|
||||
useWebMcpBridge(data, detailIndi, onSelection);
|
||||
|
||||
function onPrint() {
|
||||
@@ -405,11 +121,6 @@ export function ViewPage() {
|
||||
printChart();
|
||||
}
|
||||
|
||||
function displayErrorPopup(message: string) {
|
||||
setShowErrorPopup(true);
|
||||
setError(message);
|
||||
}
|
||||
|
||||
async function onDownloadPdf() {
|
||||
analyticsEvent('download_pdf');
|
||||
try {
|
||||
@@ -447,10 +158,6 @@ export function ViewPage() {
|
||||
downloadSvg();
|
||||
}
|
||||
|
||||
function onDismissErrorPopup() {
|
||||
setShowErrorPopup(false);
|
||||
}
|
||||
|
||||
function renderChart(selection: IndiInfo) {
|
||||
if (!data) {
|
||||
return null;
|
||||
|
||||
Reference in New Issue
Block a user