mirror of
https://github.com/PeWu/topola-viewer.git
synced 2025-12-23 18:50:04 +00:00
Refactored Chart component from class-based to functional
This commit is contained in:
parent
01ed52bb5c
commit
2d4ca439d5
19
src/app.tsx
19
src/app.tsx
@ -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">
|
||||
|
||||
340
src/chart.tsx
340
src/chart.tsx
@ -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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user