Files
topola-viewer/src/chart.tsx

604 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 elements 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>
);
}