diff --git a/src/chart.tsx b/src/chart.tsx deleted file mode 100644 index 2338fa9..0000000 --- a/src/chart.tsx +++ /dev/null @@ -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, -) { - 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 { - const reader = new FileReader(); - reader.readAsDataURL(blob); - return new Promise((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 { - 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 { - const image = new Image(); - image.src = URL.createObjectURL(blob); - return new Promise((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((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 { - 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.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; - /** 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 ( -
- - - - - - - -
- ); -} diff --git a/src/chart/chart.tsx b/src/chart/chart.tsx new file mode 100644 index 0000000..2e6e023 --- /dev/null +++ b/src/chart/chart.tsx @@ -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 ( +
+ + + + + + + +
+ ); +} diff --git a/src/chart/chart_export.ts b/src/chart/chart_export.ts new file mode 100644 index 0000000..14fbf32 --- /dev/null +++ b/src/chart/chart_export.ts @@ -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 { + const reader = new FileReader(); + reader.readAsDataURL(blob); + return new Promise((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 { + 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 { + const image = new Image(); + image.src = URL.createObjectURL(blob); + return new Promise((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((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 { + 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'); +} diff --git a/src/chart/chart_types.ts b/src/chart/chart_types.ts new file mode 100644 index 0000000..5bb088f --- /dev/null +++ b/src/chart/chart_types.ts @@ -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.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; + } +} diff --git a/src/chart/chart_wrapper.ts b/src/chart/chart_wrapper.ts new file mode 100644 index 0000000..a262c5d --- /dev/null +++ b/src/chart/chart_wrapper.ts @@ -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, +) { + 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; + /** 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}; diff --git a/src/pages/view_page.tsx b/src/pages/view_page.tsx index 3af9fac..e89d140 100644 --- a/src/pages/view_page.tsx +++ b/src/pages/view_page.tsx @@ -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'; diff --git a/src/util/url_args.spec.ts b/src/util/url_args.spec.ts index 6e20cf7..7015c5b 100644 --- a/src/util/url_args.spec.ts +++ b/src/util/url_args.spec.ts @@ -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 { diff --git a/src/util/url_args.ts b/src/util/url_args.ts index 328a43e..f46fee4 100644 --- a/src/util/url_args.ts +++ b/src/util/url_args.ts @@ -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';