Refactored Chart component from class-based to functional

This commit is contained in:
Przemek Wiech 2021-11-02 00:01:41 +01:00
parent 01ed52bb5c
commit 2d4ca439d5
2 changed files with 193 additions and 166 deletions

View File

@ -3,7 +3,6 @@ import * as queryString from 'query-string';
import React from 'react';
import {analyticsEvent} from './util/analytics';
import {Changelog} from './changelog';
import {Chart, ChartComponent, ChartType} from './chart';
import {DataSourceEnum, SourceSelection} from './datasource/data_source';
import {Details} from './details/details';
import {EmbeddedDataSource, EmbeddedSourceSpec} from './datasource/embedded';
@ -16,6 +15,14 @@ import {Media} from './util/media';
import {Redirect, Route, RouteComponentProps, Switch} from 'react-router-dom';
import {TopBar} from './menu/top_bar';
import {TopolaData} from './util/gedcom_util';
import {
Chart,
ChartType,
downloadPdf,
downloadPng,
downloadSvg,
printChart,
} from './chart';
import {
argsToConfig,
Config,
@ -212,7 +219,6 @@ class AppComponent extends React.Component<
showErrorPopup: false,
config: DEFALUT_CONFIG,
};
chartRef: ChartComponent | null = null;
/** Sets the state with a new individual selection and chart type. */
private updateDisplay(
@ -418,7 +424,7 @@ class AppComponent extends React.Component<
private onPrint = () => {
analyticsEvent('print');
this.chartRef && this.chartRef.print();
printChart();
};
private showErrorPopup(message: string, otherStateChanges?: Partial<State>) {
@ -438,7 +444,7 @@ class AppComponent extends React.Component<
private onDownloadPdf = async () => {
analyticsEvent('download_pdf');
try {
this.chartRef && (await this.chartRef.downloadPdf());
await downloadPdf();
} catch (e) {
this.showErrorPopup(
this.props.intl.formatMessage({
@ -454,7 +460,7 @@ class AppComponent extends React.Component<
private onDownloadPng = async () => {
analyticsEvent('download_png');
try {
this.chartRef && (await this.chartRef.downloadPng());
await downloadPng();
} catch (e) {
this.showErrorPopup(
this.props.intl.formatMessage({
@ -469,7 +475,7 @@ class AppComponent extends React.Component<
private onDownloadSvg = () => {
analyticsEvent('download_svg');
this.chartRef && this.chartRef.downloadSvg();
downloadSvg();
};
private onDismissErrorPopup = () => {
@ -530,7 +536,6 @@ class AppComponent extends React.Component<
onSelection={this.onSelection}
freezeAnimation={this.state.freezeAnimation}
colors={this.state.config.color}
ref={(ref) => (this.chartRef = ref)}
/>
{this.state.showSidePanel ? (
<Media at="large" className="sidePanel">

View File

@ -1,4 +1,3 @@
import * as React from 'react';
import {ChartColors} from './config';
import {injectIntl, WrappedComponentProps} from 'react-intl';
import {interpolateNumber} from 'd3-interpolate';
@ -6,6 +5,7 @@ import {max, min} from 'd3-array';
import {Media} from './util/media';
import {saveAs} from 'file-saver';
import {select, Selection} from 'd3-selection';
import {useEffect, useRef} from 'react';
import 'd3-transition';
import {
D3ZoomEvent,
@ -107,7 +107,7 @@ function loadImage(blob: Blob): Promise<HTMLImageElement> {
}
/** Draw image on a new canvas and return the canvas. */
function drawOnCanvas(image: HTMLImageElement) {
function drawImageOnCanvas(image: HTMLImageElement) {
const canvas = document.createElement('canvas');
// Scale image for better quality.
canvas.width = image.width * 2;
@ -135,6 +135,84 @@ function canvasToBlob(canvas: HTMLCanvasElement, type: string) {
});
}
/** Return a copy of the SVG chart but without scaling and positioning. */
function getStrippedSvg() {
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),
);
svg.querySelector('#chart')!.removeAttribute('transform');
return svg;
}
function getSvgContents() {
return new XMLSerializer().serializeToString(getStrippedSvg());
}
async function getSvgContentsWithInlinedImages() {
const svg = getStrippedSvg();
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 canvas = await drawOnCanvas();
const doc = new jspdf({
orientation: canvas.width > canvas.height ? 'l' : 'p',
unit: 'pt',
format: [canvas.width, canvas.height],
});
doc.addImage(canvas, 'PNG', 0, 0, canvas.width, canvas.height, 'NONE');
doc.save('topola.pdf');
}
/** Supported chart types. */
export enum ChartType {
Hourglass,
@ -148,6 +226,30 @@ const chartColors = new Map<ChartColors, TopolaChartColors>([
[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;
}
}
export interface ChartProps {
data: JsonGedcomData;
selection: IndiInfo;
@ -157,11 +259,9 @@ export interface ChartProps {
colors?: ChartColors;
}
/** Component showing the genealogy chart and handling transition animations. */
export class ChartComponent extends React.PureComponent<
ChartProps & WrappedComponentProps,
{}
> {
type ChartComponentProps = ChartProps & WrappedComponentProps;
class ChartWrapper {
private chart?: ChartHandle;
/** Animation is in progress. */
private animating = false;
@ -169,32 +269,10 @@ export class ChartComponent extends React.PureComponent<
private rerenderRequired = false;
/** The d3 zoom behavior object. */
private zoomBehavior?: ZoomBehavior<Element, any>;
/** Props that will be used for rerendering. */
private rerenderProps?: ChartComponentProps;
private getChartType() {
switch (this.props.chartType) {
case ChartType.Hourglass:
return HourglassChart;
case ChartType.Relatives:
return RelativesChart;
case ChartType.Fancy:
return FancyChart;
default:
// Fall back to hourglass chart.
return HourglassChart;
}
}
private getRendererType() {
switch (this.props.chartType) {
case ChartType.Fancy:
return CircleRenderer;
default:
// Use DetailedRenderer by default.
return DetailedRenderer;
}
}
private zoom(factor: number) {
zoom(factor: number) {
const parent = select('#svgContainer') as Selection<Element, any, any, any>;
this.zoomBehavior!.scaleBy(parent, factor);
}
@ -204,7 +282,8 @@ export class ChartComponent extends React.PureComponent<
* If indiInfo is not given, it means that it is the initial render and no
* animation is performed.
*/
private renderChart(
renderChart(
props: ChartComponentProps,
args: {initialRender: boolean; resetPosition: boolean} = {
initialRender: false,
resetPosition: false,
@ -213,33 +292,34 @@ export class ChartComponent extends React.PureComponent<
// Wait for animation to finish if animation is in progress.
if (!args.initialRender && this.animating) {
this.rerenderRequired = true;
this.rerenderProps = props;
return;
}
// Freeze changing selection after initial rendering.
if (!args.initialRender && this.props.freezeAnimation) {
if (!args.initialRender && props.freezeAnimation) {
return;
}
if (args.initialRender) {
(select('#chart').node() as HTMLElement).innerHTML = '';
this.chart = createChart({
json: this.props.data,
chartType: this.getChartType(),
renderer: this.getRendererType(),
json: props.data,
chartType: getChartType(props.chartType),
renderer: getRendererType(props.chartType),
svgSelector: '#chart',
indiCallback: (info) => this.props.onSelection(info),
colors: chartColors.get(this.props.colors!),
indiCallback: (info) => props.onSelection(info),
colors: chartColors.get(props.colors!),
animate: true,
updateSvgSize: false,
locale: this.props.intl.locale,
locale: props.intl.locale,
});
} else {
this.chart!.setData(this.props.data);
this.chart!.setData(props.data);
}
const chartInfo = this.chart!.render({
startIndi: this.props.selection.id,
baseGeneration: this.props.selection.generation,
startIndi: props.selection.id,
baseGeneration: props.selection.generation,
});
const svg = select('#chartSvg');
const parent = select('#svgContainer').node() as Element;
@ -309,123 +389,65 @@ export class ChartComponent extends React.PureComponent<
this.animating = false;
if (this.rerenderRequired) {
this.rerenderRequired = false;
this.renderChart({initialRender: false, resetPosition: false});
// Use `this.rerenderProps` instead of the props in scope because
// the props may have been updated in the meantime.
this.renderChart(this.rerenderProps!, {
initialRender: false,
resetPosition: false,
});
}
});
}
componentDidMount() {
this.renderChart({initialRender: true, resetPosition: true});
}
componentDidUpdate(prevProps: ChartProps) {
const initialRender =
this.props.chartType !== prevProps.chartType ||
this.props.colors !== prevProps.colors;
const resetPosition = this.props.chartType !== prevProps.chartType;
this.renderChart({initialRender, resetPosition});
}
render() {
return (
<div id="svgContainer">
<Media at="large" className="zoom">
<button className="zoom-in" onClick={() => this.zoom(ZOOM_FACTOR)}>
+
</button>
<button
className="zoom-out"
onClick={() => this.zoom(1 / ZOOM_FACTOR)}
>
</button>
</Media>
<svg id="chartSvg">
<g id="chart" />
</svg>
</div>
);
}
/** Return a copy of the SVG chart but without scaling and positioning. */
private getStrippedSvg() {
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),
);
svg.querySelector('#chart')!.removeAttribute('transform');
return svg;
}
private getSvgContents() {
return new XMLSerializer().serializeToString(this.getStrippedSvg());
}
private async getSvgContentsWithInlinedImages() {
const svg = this.getStrippedSvg();
await inlineImages(svg);
return new XMLSerializer().serializeToString(svg);
}
/** Shows the print dialog to print the currently displayed chart. */
print() {
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(this.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);
}
async downloadSvg() {
const contents = await this.getSvgContentsWithInlinedImages();
const blob = new Blob([contents], {type: 'image/svg+xml'});
saveAs(blob, 'topola.svg');
}
private async drawOnCanvas(): Promise<HTMLCanvasElement> {
const contents = await this.getSvgContentsWithInlinedImages();
const blob = new Blob([contents], {type: 'image/svg+xml'});
return await drawOnCanvas(await loadImage(blob));
}
async downloadPng() {
const canvas = await this.drawOnCanvas();
const blob = await canvasToBlob(canvas, 'image/png');
saveAs(blob, 'topola.png');
}
async downloadPdf() {
// Lazy load jspdf.
const {default: jspdf} = await import('jspdf');
const canvas = await this.drawOnCanvas();
const doc = new jspdf({
orientation: canvas.width > canvas.height ? 'l' : 'p',
unit: 'pt',
format: [canvas.width, canvas.height],
});
doc.addImage(canvas, 'PNG', 0, 0, canvas.width, canvas.height, 'NONE');
doc.save('topola.pdf');
}
}
export const Chart = injectIntl(ChartComponent, {forwardRef: true});
function usePrevious<T>(value: T): T | undefined {
const ref = useRef<T>();
useEffect(() => {
ref.current = value;
});
return ref.current;
}
function ChartComponent(props: ChartComponentProps) {
const chartWrapper = useRef(new ChartWrapper());
const prevProps = usePrevious(props);
useEffect(() => {
if (prevProps) {
const initialRender =
props.chartType !== prevProps?.chartType ||
props.colors !== prevProps?.colors;
const resetPosition = props.chartType !== prevProps?.chartType;
chartWrapper.current.renderChart(props, {initialRender, resetPosition});
} else {
chartWrapper.current.renderChart(props, {
initialRender: true,
resetPosition: true,
});
}
});
return (
<div id="svgContainer">
<Media at="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" />
</svg>
</div>
);
}
export const Chart = injectIntl(ChartComponent);