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 {Loader, SidebarPushable, SidebarPusher} from 'semantic-ui-react';
|
||||
import {IndiInfo} from 'topola';
|
||||
import {Chart} from '../chart/chart';
|
||||
import {
|
||||
Chart,
|
||||
ChartType,
|
||||
downloadPdf,
|
||||
downloadPng,
|
||||
downloadSvg,
|
||||
printChart,
|
||||
} from '../chart';
|
||||
} from '../chart/chart_export';
|
||||
import {ChartType} from '../chart/chart_types';
|
||||
import {ErrorMessage, ErrorPopup} from '../components/error_display';
|
||||
import {ProgressPill} from '../components/progress_pill';
|
||||
import {DataSourceEnum} from '../datasource/data_source';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {afterEach, beforeEach, describe, expect, it, jest} from '@jest/globals';
|
||||
import * as H from 'history';
|
||||
import {ChartType} from '../chart';
|
||||
import {ChartType} from '../chart/chart_types';
|
||||
import {DataSourceEnum} from '../datasource/data_source';
|
||||
import {ChartColors, Ids, PlaceDisplay, Sex} from '../sidepanel/config/config';
|
||||
import {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as H from 'history';
|
||||
import type {ParsedQuery} from 'query-string';
|
||||
import {IndiInfo} from 'topola';
|
||||
import {ChartType} from '../chart';
|
||||
import {ChartType} from '../chart/chart_types';
|
||||
import {DataSourceEnum} from '../datasource/data_source';
|
||||
import {EmbeddedSourceSpec} from '../datasource/embedded';
|
||||
import {GoogleDriveSourceSpec} from '../datasource/google_drive';
|
||||
|
||||
Reference in New Issue
Block a user