mirror of
https://github.com/PeWu/topola-viewer.git
synced 2026-07-17 17:21:48 +00:00
refactor: move chart implementation, types, and export utilities into a dedicated directory
This commit is contained in:
603
src/chart.tsx
603
src/chart.tsx
@@ -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 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
108
src/chart/chart.tsx
Normal file
108
src/chart/chart.tsx
Normal 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
180
src/chart/chart_export.ts
Normal 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
65
src/chart/chart_types.ts
Normal 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
263
src/chart/chart_wrapper.ts
Normal 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};
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
Reference in New Issue
Block a user