refactor: move chart implementation, types, and export utilities into a dedicated directory

This commit is contained in:
Przemek Więch
2026-07-06 16:56:07 +02:00
parent b3e4a0aabd
commit 689f9f253f
8 changed files with 621 additions and 608 deletions

View File

@@ -1,603 +0,0 @@
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>
);
}

108
src/chart/chart.tsx Normal file
View File

@@ -0,0 +1,108 @@
import {useEffect, useMemo, useRef} from 'react';
import {useIntl} from 'react-intl';
import {PlaceDisplay} 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';
import {ChartProps} from './chart_types';
import {ChartWrapper, ZOOM_FACTOR} from './chart_wrapper';
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>
);
}

180
src/chart/chart_export.ts Normal file
View File

@@ -0,0 +1,180 @@
import {select} from 'd3-selection';
import 'd3-transition';
import {zoomTransform} from 'd3-zoom';
import {saveAs} from 'file-saver';
/** 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');
}

65
src/chart/chart_types.ts Normal file
View File

@@ -0,0 +1,65 @@
import {
CircleRenderer,
DetailedRenderer,
FancyChart,
HourglassChart,
IndiInfo,
JsonGedcomData,
RelativesChart,
ChartColors as TopolaChartColors,
} from 'topola';
import {ChartColors, Ids, PlaceDisplay, Sex} from '../sidepanel/config/config';
/** Supported chart types. */
export enum ChartType {
Hourglass,
Relatives,
Donatso,
Fancy,
}
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;
}
export 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],
]);
export 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;
}
}
export function getRendererType(chartType: ChartType) {
switch (chartType) {
case ChartType.Fancy:
return CircleRenderer;
default:
// Use DetailedRenderer by default.
return DetailedRenderer;
}
}

263
src/chart/chart_wrapper.ts Normal file
View File

@@ -0,0 +1,263 @@
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 {IntlShape} from 'react-intl';
import {ChartHandle, ChartInfo, createChart} from 'topola';
import {
chartColors,
ChartProps,
getChartType,
getRendererType,
} from './chart_types';
/** 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);
}
/** 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 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 {ZOOM_FACTOR};

View File

@@ -2,14 +2,14 @@ import {useCallback, useMemo} from 'react';
import {useIntl} from 'react-intl'; import {useIntl} from 'react-intl';
import {Loader, SidebarPushable, SidebarPusher} from 'semantic-ui-react'; import {Loader, SidebarPushable, SidebarPusher} from 'semantic-ui-react';
import {IndiInfo} from 'topola'; import {IndiInfo} from 'topola';
import {Chart} from '../chart/chart';
import { import {
Chart,
ChartType,
downloadPdf, downloadPdf,
downloadPng, downloadPng,
downloadSvg, downloadSvg,
printChart, printChart,
} from '../chart'; } from '../chart/chart_export';
import {ChartType} from '../chart/chart_types';
import {ErrorMessage, ErrorPopup} from '../components/error_display'; import {ErrorMessage, ErrorPopup} from '../components/error_display';
import {ProgressPill} from '../components/progress_pill'; import {ProgressPill} from '../components/progress_pill';
import {DataSourceEnum} from '../datasource/data_source'; import {DataSourceEnum} from '../datasource/data_source';

View File

@@ -1,6 +1,6 @@
import {afterEach, beforeEach, describe, expect, it, jest} from '@jest/globals'; import {afterEach, beforeEach, describe, expect, it, jest} from '@jest/globals';
import * as H from 'history'; import * as H from 'history';
import {ChartType} from '../chart'; import {ChartType} from '../chart/chart_types';
import {DataSourceEnum} from '../datasource/data_source'; import {DataSourceEnum} from '../datasource/data_source';
import {ChartColors, Ids, PlaceDisplay, Sex} from '../sidepanel/config/config'; import {ChartColors, Ids, PlaceDisplay, Sex} from '../sidepanel/config/config';
import { import {

View File

@@ -1,7 +1,7 @@
import * as H from 'history'; import * as H from 'history';
import type {ParsedQuery} from 'query-string'; import type {ParsedQuery} from 'query-string';
import {IndiInfo} from 'topola'; import {IndiInfo} from 'topola';
import {ChartType} from '../chart'; import {ChartType} from '../chart/chart_types';
import {DataSourceEnum} from '../datasource/data_source'; import {DataSourceEnum} from '../datasource/data_source';
import {EmbeddedSourceSpec} from '../datasource/embedded'; import {EmbeddedSourceSpec} from '../datasource/embedded';
import {GoogleDriveSourceSpec} from '../datasource/google_drive'; import {GoogleDriveSourceSpec} from '../datasource/google_drive';