mirror of
https://github.com/PeWu/topola-viewer.git
synced 2026-07-18 01:31:47 +00:00
604 lines
18 KiB
TypeScript
604 lines
18 KiB
TypeScript
import {max, min} from 'd3-array';
|
||
import {interpolateNumber} from 'd3-interpolate';
|
||
import {BaseType, select, Selection} from 'd3-selection';
|
||
import 'd3-transition';
|
||
import {
|
||
D3ZoomEvent,
|
||
zoom,
|
||
ZoomBehavior,
|
||
ZoomedElementBaseType,
|
||
zoomTransform,
|
||
} from 'd3-zoom';
|
||
import {saveAs} from 'file-saver';
|
||
import {useEffect, useMemo, useRef} from 'react';
|
||
import {IntlShape, useIntl} from 'react-intl';
|
||
import {
|
||
ChartHandle,
|
||
ChartInfo,
|
||
CircleRenderer,
|
||
createChart,
|
||
DetailedRenderer,
|
||
FancyChart,
|
||
HourglassChart,
|
||
IndiInfo,
|
||
JsonGedcomData,
|
||
RelativesChart,
|
||
ChartColors as TopolaChartColors,
|
||
} from 'topola';
|
||
import {ChartColors, Ids, PlaceDisplay, Sex} from './sidepanel/config/config';
|
||
import {Media} from './util/media';
|
||
import {DEFAULT_PLACE_DISPLAY_COUNT, shortenPlace} from './util/place_util';
|
||
import {usePrevious} from './util/previous-hook';
|
||
|
||
/** How much to zoom when using the +/- buttons. */
|
||
const ZOOM_FACTOR = 1.3;
|
||
|
||
/**
|
||
* Called when the view is dragged with the mouse.
|
||
*
|
||
* @param size the size of the chart
|
||
*/
|
||
function zoomed(
|
||
size: [number, number],
|
||
event: D3ZoomEvent<ZoomedElementBaseType, unknown>,
|
||
) {
|
||
const parent = select('#svgContainer').node() as Element;
|
||
|
||
const scale = event.transform.k;
|
||
const offsetX = max([0, (parent.clientWidth - size[0] * scale) / 2]);
|
||
const offsetY = max([0, (parent.clientHeight - size[1] * scale) / 2]);
|
||
select('#chartSvg')
|
||
.attr('width', size[0] * scale)
|
||
.attr('height', size[1] * scale)
|
||
.attr('transform', `translate(${offsetX}, ${offsetY})`);
|
||
select('#chart').attr('transform', `scale(${scale})`);
|
||
|
||
parent.scrollLeft = -event.transform.x;
|
||
parent.scrollTop = -event.transform.y;
|
||
}
|
||
|
||
/** Called when the scrollbars are used. */
|
||
function scrolled() {
|
||
const parent = select('#svgContainer').node() as Element;
|
||
const x = parent.scrollLeft + parent.clientWidth / 2;
|
||
const y = parent.scrollTop + parent.clientHeight / 2;
|
||
const scale = zoomTransform(parent).k;
|
||
select(parent).call(zoom().translateTo, x / scale, y / scale);
|
||
}
|
||
|
||
/** Loads blob as data URL. */
|
||
function loadAsDataUrl(blob: Blob): Promise<string> {
|
||
const reader = new FileReader();
|
||
reader.readAsDataURL(blob);
|
||
return new Promise<string>((resolve, _reject) => {
|
||
reader.onload = (e) => resolve((e.target as FileReader).result as string);
|
||
});
|
||
}
|
||
|
||
async function inlineImage(image: SVGImageElement) {
|
||
const href = image.href.baseVal;
|
||
if (!href) {
|
||
return;
|
||
}
|
||
try {
|
||
const response = await fetch(href);
|
||
const blob = await response.blob();
|
||
const dataUrl = await loadAsDataUrl(blob);
|
||
image.href.baseVal = dataUrl;
|
||
} catch (e) {
|
||
console.warn('Failed to load image:', e);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Fetches all images in the SVG and replaces them with inlined images as data
|
||
* URLs. Images are replaced in place. The replacement is done, the returned
|
||
* promise is resolved.
|
||
*/
|
||
async function inlineImages(svg: Element): Promise<void> {
|
||
const images = Array.from(svg.getElementsByTagName('image'));
|
||
await Promise.all(images.map(inlineImage));
|
||
}
|
||
|
||
/** Loads a blob into an image object. */
|
||
function loadImage(blob: Blob): Promise<HTMLImageElement> {
|
||
const image = new Image();
|
||
image.src = URL.createObjectURL(blob);
|
||
return new Promise<HTMLImageElement>((resolve, _reject) => {
|
||
image.addEventListener('load', () => resolve(image));
|
||
});
|
||
}
|
||
|
||
/** Draw image on a new canvas and return the canvas. */
|
||
function drawImageOnCanvas(image: HTMLImageElement) {
|
||
const canvas = document.createElement('canvas');
|
||
// Scale image for better quality.
|
||
canvas.width = image.width * 2;
|
||
canvas.height = image.height * 2;
|
||
|
||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||
const ctx = canvas.getContext('2d')!;
|
||
const oldFill = ctx.fillStyle;
|
||
ctx.fillStyle = 'white';
|
||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||
ctx.fillStyle = oldFill;
|
||
|
||
ctx.drawImage(image, 0, 0, canvas.width, canvas.height);
|
||
return canvas;
|
||
}
|
||
|
||
function canvasToBlob(canvas: HTMLCanvasElement, type: string) {
|
||
return new Promise<Blob>((resolve, reject) => {
|
||
canvas.toBlob((blob) => {
|
||
if (blob) {
|
||
resolve(blob);
|
||
} else {
|
||
reject();
|
||
}
|
||
}, type);
|
||
});
|
||
}
|
||
|
||
/** Return a copy of the SVG chart but without scaling and positioning. */
|
||
function getStrippedSvg() {
|
||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||
const svg = document.getElementById('chartSvg')!.cloneNode(true) as Element;
|
||
|
||
svg.removeAttribute('transform');
|
||
const parent = select('#svgContainer').node() as Element;
|
||
const scale = zoomTransform(parent).k;
|
||
svg.setAttribute('width', String(Number(svg.getAttribute('width')) / scale));
|
||
svg.setAttribute(
|
||
'height',
|
||
String(Number(svg.getAttribute('height')) / scale),
|
||
);
|
||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||
svg.querySelector('#chart')!.removeAttribute('transform');
|
||
|
||
return svg;
|
||
}
|
||
|
||
function getSvgDimensions() {
|
||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||
const svg = document.getElementById('chartSvg')!;
|
||
return {
|
||
width: Number(svg.getAttribute('width')),
|
||
height: Number(svg.getAttribute('height')),
|
||
};
|
||
}
|
||
|
||
function getSvgContents() {
|
||
return new XMLSerializer().serializeToString(getStrippedSvg());
|
||
}
|
||
|
||
async function getSvgContentsWithInlinedImages() {
|
||
const svg = getStrippedSvg();
|
||
|
||
// Set white background because the default background of the SVG
|
||
// is transparent, which causes issues when printing or exporting to PDF.
|
||
const svgNs = 'http://www.w3.org/2000/svg';
|
||
const rect = document.createElementNS(svgNs, 'rect');
|
||
rect.setAttribute('x', '0');
|
||
rect.setAttribute('y', '0');
|
||
rect.setAttribute('width', '100%');
|
||
rect.setAttribute('height', '100%');
|
||
rect.setAttribute('fill', 'white');
|
||
svg.prepend(rect);
|
||
|
||
await inlineImages(svg);
|
||
return new XMLSerializer().serializeToString(svg);
|
||
}
|
||
|
||
/** Shows the print dialog to print the currently displayed chart. */
|
||
export function printChart() {
|
||
const printWindow = document.createElement('iframe');
|
||
printWindow.style.position = 'absolute';
|
||
printWindow.style.top = '-1000px';
|
||
printWindow.style.left = '-1000px';
|
||
printWindow.onload = () => {
|
||
printWindow.contentDocument?.open();
|
||
printWindow.contentDocument?.write(getSvgContents());
|
||
printWindow.contentDocument?.close();
|
||
// Doesn't work on Firefox without the setTimeout.
|
||
setTimeout(() => {
|
||
printWindow.contentWindow?.focus();
|
||
printWindow.contentWindow?.print();
|
||
printWindow.parentNode?.removeChild(printWindow);
|
||
}, 500);
|
||
};
|
||
document.body.appendChild(printWindow);
|
||
}
|
||
|
||
export async function downloadSvg() {
|
||
const contents = await getSvgContentsWithInlinedImages();
|
||
const blob = new Blob([contents], {type: 'image/svg+xml'});
|
||
saveAs(blob, 'topola.svg');
|
||
}
|
||
|
||
async function drawOnCanvas(): Promise<HTMLCanvasElement> {
|
||
const contents = await getSvgContentsWithInlinedImages();
|
||
const blob = new Blob([contents], {type: 'image/svg+xml'});
|
||
return drawImageOnCanvas(await loadImage(blob));
|
||
}
|
||
|
||
export async function downloadPng() {
|
||
const canvas = await drawOnCanvas();
|
||
const blob = await canvasToBlob(canvas, 'image/png');
|
||
saveAs(blob, 'topola.png');
|
||
}
|
||
|
||
export async function downloadPdf() {
|
||
// Lazy load jspdf.
|
||
const {default: jspdf} = await import('jspdf');
|
||
|
||
const {width, height} = getSvgDimensions();
|
||
const doc = new jspdf({
|
||
orientation: width > height ? 'l' : 'p',
|
||
unit: 'pt',
|
||
format: [width, height],
|
||
});
|
||
const contents = await getSvgContentsWithInlinedImages();
|
||
await doc.addSvgAsImage(contents, 0, 0, width, height);
|
||
doc.save('topola.pdf');
|
||
}
|
||
|
||
/** Supported chart types. */
|
||
export enum ChartType {
|
||
Hourglass,
|
||
Relatives,
|
||
Donatso,
|
||
Fancy,
|
||
}
|
||
|
||
const chartColors = new Map<ChartColors, TopolaChartColors>([
|
||
[ChartColors.NO_COLOR, TopolaChartColors.NO_COLOR],
|
||
[ChartColors.COLOR_BY_GENERATION, TopolaChartColors.COLOR_BY_GENERATION],
|
||
[ChartColors.COLOR_BY_SEX, TopolaChartColors.COLOR_BY_SEX],
|
||
]);
|
||
|
||
function getChartType(chartType: ChartType) {
|
||
switch (chartType) {
|
||
case ChartType.Hourglass:
|
||
return HourglassChart;
|
||
case ChartType.Relatives:
|
||
return RelativesChart;
|
||
case ChartType.Fancy:
|
||
return FancyChart;
|
||
default:
|
||
// Fall back to hourglass chart.
|
||
return HourglassChart;
|
||
}
|
||
}
|
||
|
||
function getRendererType(chartType: ChartType) {
|
||
switch (chartType) {
|
||
case ChartType.Fancy:
|
||
return CircleRenderer;
|
||
default:
|
||
// Use DetailedRenderer by default.
|
||
return DetailedRenderer;
|
||
}
|
||
}
|
||
|
||
/** Returns the element’s usable width and height by subtracting the assumed scrollbar size. */
|
||
function getScrollbarAwareSize(
|
||
element: Element,
|
||
scrollbarSize = 20,
|
||
): [number, number] {
|
||
const htmlElement = element as HTMLElement;
|
||
return [
|
||
htmlElement.clientWidth - scrollbarSize,
|
||
htmlElement.clientHeight - scrollbarSize,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Calculates the allowed zoom scale range.
|
||
* Sets the minimum scale so the chart cannot zoom out beyond full visibility,
|
||
* and fixes the maximum scale at 2.
|
||
*/
|
||
function calculateScaleExtent(
|
||
parent: Element,
|
||
scale: number,
|
||
chartInfo: ChartInfo,
|
||
): [number, number] {
|
||
const [availWidth, availHeight] = getScrollbarAwareSize(parent);
|
||
|
||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||
const zoomOutFactor = min([
|
||
1,
|
||
scale,
|
||
availWidth / chartInfo.size[0],
|
||
availHeight / chartInfo.size[1],
|
||
])!;
|
||
|
||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||
return [max([0.1, zoomOutFactor])!, 2];
|
||
}
|
||
|
||
export interface ChartProps {
|
||
data: JsonGedcomData;
|
||
selection: IndiInfo;
|
||
chartType: ChartType;
|
||
onSelection: (indiInfo: IndiInfo) => void;
|
||
onDetailSelection: (indiInfo: IndiInfo) => void;
|
||
freezeAnimation?: boolean;
|
||
colors?: ChartColors;
|
||
hideIds?: Ids;
|
||
hideSex?: Sex;
|
||
placeDisplay?: PlaceDisplay;
|
||
placeCount?: number;
|
||
/** Called once after the initial D3 layout and SVG render completes. */
|
||
onFirstRender?: () => void;
|
||
}
|
||
|
||
class ChartWrapper {
|
||
private chart?: ChartHandle;
|
||
/** Animation is in progress. */
|
||
private animating = false;
|
||
/** Rendering is required after the current animation finishes. */
|
||
private rerenderRequired = false;
|
||
/** The d3 zoom behavior object. */
|
||
private zoomBehavior?: ZoomBehavior<Element, unknown>;
|
||
/** Props that will be used for rerendering. */
|
||
private rerenderProps?: ChartProps;
|
||
private rerenderResetPosition?: boolean;
|
||
|
||
zoom(factor: number) {
|
||
const parent = select('#svgContainer') as Selection<
|
||
Element,
|
||
unknown,
|
||
BaseType,
|
||
unknown
|
||
>;
|
||
this.zoomBehavior?.scaleBy(parent, factor);
|
||
}
|
||
|
||
/**
|
||
* Renders the chart or performs a transition animation to a new state.
|
||
* If indiInfo is not given, it means that it is the initial render and no
|
||
* animation is performed.
|
||
*/
|
||
renderChart(
|
||
props: ChartProps,
|
||
intl: IntlShape,
|
||
args: {initialRender: boolean; resetPosition: boolean} = {
|
||
initialRender: false,
|
||
resetPosition: false,
|
||
},
|
||
) {
|
||
// Nothing changed — the SVG is already correct. Skip re-render.
|
||
// This prevents repeated full D3 layout passes (500ms+ each) that happen
|
||
// when React re-renders for unrelated state changes.
|
||
if (!args.initialRender && !args.resetPosition) {
|
||
return;
|
||
}
|
||
|
||
// Wait for animation to finish if animation is in progress.
|
||
if (!args.initialRender && this.animating) {
|
||
this.rerenderRequired = true;
|
||
this.rerenderProps = props;
|
||
this.rerenderResetPosition = args.resetPosition;
|
||
return;
|
||
}
|
||
|
||
// Freeze changing selection after initial rendering.
|
||
if (!args.initialRender && props.freezeAnimation) {
|
||
return;
|
||
}
|
||
|
||
if (args.initialRender || !this.chart) {
|
||
(select('#chart').node() as HTMLElement).innerHTML = '';
|
||
this.chart = createChart({
|
||
json: props.data,
|
||
chartType: getChartType(props.chartType),
|
||
renderer: getRendererType(props.chartType),
|
||
svgSelector: '#chart',
|
||
indiCallback: (info) => {
|
||
// ths is called when an individual is selected in the chart
|
||
if (info.modifiers?.shiftKey) {
|
||
// If the shift key is pressed, we just update the details tab without changing the selection in the chart.
|
||
// This allows users to quickly view details of multiple individuals without losing their place in the chart.
|
||
props.onDetailSelection(info);
|
||
} else {
|
||
// If the shift key is not pressed, we update the selection in the chart as usual.
|
||
props.onSelection(info);
|
||
}
|
||
},
|
||
colors:
|
||
props.colors !== undefined
|
||
? chartColors.get(props.colors)
|
||
: undefined,
|
||
animate: true,
|
||
updateSvgSize: false,
|
||
locale: intl.locale,
|
||
});
|
||
} else {
|
||
this.chart.setData(props.data);
|
||
}
|
||
const chartInfo = this.chart.render({
|
||
startIndi: props.selection.id,
|
||
baseGeneration: props.selection.generation,
|
||
});
|
||
const svg = select('#chartSvg');
|
||
const parent = select('#svgContainer').node() as Element;
|
||
const scale = zoomTransform(parent).k;
|
||
const extent: [number, number] = calculateScaleExtent(
|
||
parent,
|
||
scale,
|
||
chartInfo,
|
||
);
|
||
|
||
this.zoomBehavior = zoom()
|
||
.scaleExtent(extent)
|
||
.translateExtent([[0, 0], chartInfo.size])
|
||
.on('zoom', (event) => zoomed(chartInfo.size, event));
|
||
|
||
select(parent).on('scroll', scrolled).call(this.zoomBehavior);
|
||
|
||
const scrollTopTween = (scrollTop: number) => {
|
||
return () => {
|
||
const i = interpolateNumber(parent.scrollTop, scrollTop);
|
||
return (t: number) => {
|
||
parent.scrollTop = i(t);
|
||
};
|
||
};
|
||
};
|
||
const scrollLeftTween = (scrollLeft: number) => {
|
||
return () => {
|
||
const i = interpolateNumber(parent.scrollLeft, scrollLeft);
|
||
return (t: number) => {
|
||
parent.scrollLeft = i(t);
|
||
};
|
||
};
|
||
};
|
||
|
||
const dx = parent.clientWidth / 2 - chartInfo.origin[0] * scale;
|
||
const dy = parent.clientHeight / 2 - chartInfo.origin[1] * scale;
|
||
const offsetX = max([
|
||
0,
|
||
(parent.clientWidth - chartInfo.size[0] * scale) / 2,
|
||
]);
|
||
const offsetY = max([
|
||
0,
|
||
(parent.clientHeight - chartInfo.size[1] * scale) / 2,
|
||
]);
|
||
const svgTransition = svg.transition().delay(200).duration(500);
|
||
const transition = args.initialRender ? svg : svgTransition;
|
||
transition.attr('transform', `translate(${offsetX}, ${offsetY})`);
|
||
transition.attr('width', chartInfo.size[0] * scale);
|
||
transition.attr('height', chartInfo.size[1] * scale);
|
||
if (args.resetPosition) {
|
||
if (args.initialRender) {
|
||
parent.scrollLeft = -dx;
|
||
parent.scrollTop = -dy;
|
||
} else {
|
||
svgTransition
|
||
.tween('scrollLeft', scrollLeftTween(-dx))
|
||
.tween('scrollTop', scrollTopTween(-dy));
|
||
}
|
||
}
|
||
|
||
// After the animation is finished, rerender the chart if required.
|
||
this.animating = true;
|
||
chartInfo.animationPromise.then(() => {
|
||
this.animating = false;
|
||
if (this.rerenderRequired) {
|
||
this.rerenderRequired = false;
|
||
// Use `this.rerenderProps` instead of the props in scope because
|
||
// the props may have been updated in the meantime.
|
||
if (this.rerenderProps) {
|
||
this.renderChart(this.rerenderProps, intl, {
|
||
initialRender: false,
|
||
resetPosition: !!this.rerenderResetPosition,
|
||
});
|
||
} else {
|
||
console.error(
|
||
'Rerender required after animation, but rerenderProps was not set.',
|
||
);
|
||
}
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
export function Chart(props: ChartProps) {
|
||
const chartWrapper = useRef(new ChartWrapper());
|
||
const prevProps = usePrevious(props);
|
||
const intl = useIntl();
|
||
|
||
const placeDisplay = props.placeDisplay ?? PlaceDisplay.FULL;
|
||
const placeCount = props.placeCount ?? DEFAULT_PLACE_DISPLAY_COUNT;
|
||
|
||
const processedData = useMemo(() => {
|
||
if (placeDisplay === PlaceDisplay.FULL) {
|
||
return props.data;
|
||
}
|
||
return {
|
||
...props.data,
|
||
indis: props.data.indis.map((indi) => ({
|
||
...indi,
|
||
birth: indi.birth
|
||
? {
|
||
...indi.birth,
|
||
place: shortenPlace(indi.birth.place, placeDisplay, placeCount),
|
||
}
|
||
: undefined,
|
||
death: indi.death
|
||
? {
|
||
...indi.death,
|
||
place: shortenPlace(indi.death.place, placeDisplay, placeCount),
|
||
}
|
||
: undefined,
|
||
})),
|
||
fams: props.data.fams.map((fam) => ({
|
||
...fam,
|
||
marriage: fam.marriage
|
||
? {
|
||
...fam.marriage,
|
||
place: shortenPlace(fam.marriage.place, placeDisplay, placeCount),
|
||
}
|
||
: undefined,
|
||
})),
|
||
};
|
||
}, [props.data, placeDisplay, placeCount]);
|
||
|
||
useEffect(() => {
|
||
const propsWithProcessedData: ChartProps = {...props, data: processedData};
|
||
if (prevProps) {
|
||
const initialRender =
|
||
props.chartType !== prevProps?.chartType ||
|
||
props.colors !== prevProps?.colors ||
|
||
props.hideIds !== prevProps?.hideIds ||
|
||
props.hideSex !== prevProps?.hideSex ||
|
||
props.placeDisplay !== prevProps?.placeDisplay ||
|
||
props.placeCount !== prevProps?.placeCount;
|
||
const resetPosition =
|
||
props.chartType !== prevProps?.chartType ||
|
||
props.data !== prevProps.data ||
|
||
// This does not work as the objects are always different instances.
|
||
//props.selection !== prevProps.selection;
|
||
// Therefore, compare id and generation instead.
|
||
props.selection.id !== prevProps.selection.id ||
|
||
props.selection.generation !== prevProps.selection.generation;
|
||
chartWrapper.current.renderChart(propsWithProcessedData, intl, {
|
||
initialRender,
|
||
resetPosition,
|
||
});
|
||
} else {
|
||
chartWrapper.current.renderChart(propsWithProcessedData, intl, {
|
||
initialRender: true,
|
||
resetPosition: true,
|
||
});
|
||
// Clear the loading pill now that the chart SVG is in the DOM.
|
||
// This fires before the D3 fade-in animation completes (~400ms), which
|
||
// is intentional: the chart is visible and interactive at this point,
|
||
// and keeping the pill visible during the animation would create a
|
||
// confusing overlap.
|
||
props.onFirstRender?.();
|
||
}
|
||
});
|
||
|
||
return (
|
||
<div id="svgContainer">
|
||
<Media greaterThanOrEqual="large" className="zoom">
|
||
<button
|
||
className="zoom-in"
|
||
onClick={() => chartWrapper.current.zoom(ZOOM_FACTOR)}
|
||
>
|
||
+
|
||
</button>
|
||
<button
|
||
className="zoom-out"
|
||
onClick={() => chartWrapper.current.zoom(1 / ZOOM_FACTOR)}
|
||
>
|
||
−
|
||
</button>
|
||
</Media>
|
||
<svg id="chartSvg">
|
||
<g id="chart" data-testid="chart" />
|
||
</svg>
|
||
</div>
|
||
);
|
||
}
|