mirror of
https://github.com/PeWu/topola-viewer.git
synced 2026-03-12 18:43:46 +00:00
331 lines
9.6 KiB
TypeScript
331 lines
9.6 KiB
TypeScript
import * as d3 from 'd3';
|
|
import * as React from 'react';
|
|
import jsPDF from 'jspdf';
|
|
import {intlShape} from 'react-intl';
|
|
import {saveAs} from 'file-saver';
|
|
import {
|
|
JsonGedcomData,
|
|
ChartHandle,
|
|
IndiInfo,
|
|
createChart,
|
|
DetailedRenderer,
|
|
HourglassChart,
|
|
RelativesChart,
|
|
FancyChart,
|
|
CircleRenderer,
|
|
} from 'topola';
|
|
|
|
/** Called when the view is dragged with the mouse. */
|
|
function zoomed() {
|
|
const svg = d3.select('#chart');
|
|
const parent = (svg.node() as HTMLElement).parentElement!;
|
|
parent.scrollLeft = -d3.event.transform.x;
|
|
parent.scrollTop = -d3.event.transform.y;
|
|
}
|
|
|
|
/** Called when the scrollbars are used. */
|
|
function scrolled() {
|
|
const svg = d3.select('#chart');
|
|
const parent = (svg.node() as HTMLElement).parentElement as Element;
|
|
const x = parent.scrollLeft + parent.clientWidth / 2;
|
|
const y = parent.scrollTop + parent.clientHeight / 2;
|
|
d3.select(parent).call(d3.zoom().translateTo, x, y);
|
|
}
|
|
|
|
/** 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 drawOnCanvas(image: HTMLImageElement) {
|
|
const canvas = document.createElement('canvas');
|
|
// Scale image for better quality.
|
|
canvas.width = image.width * 2;
|
|
canvas.height = image.height * 2;
|
|
|
|
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);
|
|
});
|
|
}
|
|
|
|
/** Supported chart types. */
|
|
export enum ChartType {
|
|
Hourglass,
|
|
Relatives,
|
|
Fancy,
|
|
}
|
|
|
|
export interface ChartProps {
|
|
data: JsonGedcomData;
|
|
selection: IndiInfo;
|
|
chartType: ChartType;
|
|
onSelection: (indiInfo: IndiInfo) => void;
|
|
}
|
|
|
|
/** Component showing the genealogy chart and handling transition animations. */
|
|
export class Chart extends React.PureComponent<ChartProps, {}> {
|
|
private chart?: ChartHandle;
|
|
/** Animation is in progress. */
|
|
private animating = false;
|
|
/** Rendering is required after the current animation finishes. */
|
|
private rerenderRequired = false;
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
private renderChart(args: {initialRender: boolean} = {initialRender: false}) {
|
|
// Wait for animation to finish if animation is in progress.
|
|
if (!args.initialRender && this.animating) {
|
|
this.rerenderRequired = true;
|
|
return;
|
|
}
|
|
|
|
if (args.initialRender) {
|
|
(d3.select('#chart').node() as HTMLElement).innerHTML = '';
|
|
this.chart = createChart({
|
|
json: this.props.data,
|
|
chartType: this.getChartType(),
|
|
renderer: this.getRendererType(),
|
|
svgSelector: '#chart',
|
|
indiCallback: (info) => this.props.onSelection(info),
|
|
animate: true,
|
|
updateSvgSize: false,
|
|
locale: this.context.intl.locale,
|
|
});
|
|
} else {
|
|
this.chart!.setData(this.props.data);
|
|
}
|
|
const chartInfo = this.chart!.render({
|
|
startIndi: this.props.selection.id,
|
|
baseGeneration: this.props.selection.generation,
|
|
});
|
|
const svg = d3.select('#chart');
|
|
const parent = (svg.node() as HTMLElement).parentElement as Element;
|
|
|
|
d3.select(parent)
|
|
.on('scroll', scrolled)
|
|
.call(
|
|
d3
|
|
.zoom()
|
|
.scaleExtent([1, 1])
|
|
.translateExtent([[0, 0], chartInfo.size])
|
|
.on('zoom', zoomed),
|
|
);
|
|
|
|
const scrollTopTween = (scrollTop: number) => {
|
|
return () => {
|
|
const i = d3.interpolateNumber(parent.scrollTop, scrollTop);
|
|
return (t: number) => {
|
|
parent.scrollTop = i(t);
|
|
};
|
|
};
|
|
};
|
|
const scrollLeftTween = (scrollLeft: number) => {
|
|
return () => {
|
|
const i = d3.interpolateNumber(parent.scrollLeft, scrollLeft);
|
|
return (t: number) => {
|
|
parent.scrollLeft = i(t);
|
|
};
|
|
};
|
|
};
|
|
|
|
const dx = parent.clientWidth / 2 - chartInfo.origin[0];
|
|
const dy = parent.clientHeight / 2 - chartInfo.origin[1];
|
|
const offsetX = d3.max([0, (parent.clientWidth - chartInfo.size[0]) / 2]);
|
|
const offsetY = d3.max([0, (parent.clientHeight - chartInfo.size[1]) / 2]);
|
|
const svgTransition = svg
|
|
.transition()
|
|
.delay(200)
|
|
.duration(500);
|
|
const transition = args.initialRender ? svg : svgTransition;
|
|
transition
|
|
.attr('transform', `translate(${offsetX}, ${offsetY})`)
|
|
.attr('width', chartInfo.size[0])
|
|
.attr('height', chartInfo.size[1]);
|
|
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;
|
|
this.renderChart({initialRender: false});
|
|
}
|
|
});
|
|
}
|
|
|
|
componentDidMount() {
|
|
this.renderChart({initialRender: true});
|
|
}
|
|
|
|
componentDidUpdate(prevProps: ChartProps) {
|
|
const initialRender = this.props.chartType !== prevProps.chartType;
|
|
this.renderChart({initialRender});
|
|
}
|
|
|
|
/** Make intl appear in this.context. */
|
|
static contextTypes = {
|
|
intl: intlShape,
|
|
};
|
|
|
|
render() {
|
|
return (
|
|
<div id="svgContainer">
|
|
<svg id="chart" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
private getSvgContents() {
|
|
const svg = document.getElementById('chart')!.cloneNode(true) as Element;
|
|
svg.removeAttribute('transform');
|
|
return new XMLSerializer().serializeToString(svg);
|
|
}
|
|
|
|
private async getSvgContentsWithInlinedImages() {
|
|
const svg = document.getElementById('chart')!.cloneNode(true) as Element;
|
|
svg.removeAttribute('transform');
|
|
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() {
|
|
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');
|
|
}
|
|
}
|