Skip to content

Commit 1da13ed

Browse files
committed
svg export
1 parent 5a0da79 commit 1da13ed

File tree

6 files changed

+299
-37
lines changed

6 files changed

+299
-37
lines changed
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { SVGExportProps } from './svgExport'
2+
3+
export interface SVGExportCapability {
4+
canExportToSVG: boolean
5+
exportToSVG?: (props: SVGExportProps) => Promise<string[]> // Returns additional SVG elements
6+
}

gui/src/libraries/timeseries-views/component-time-scroll-view-2/TimeScrollView2.tsx

Lines changed: 41 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { useTimeTicks } from './timeTicks';
1111
import TSV2AxesLayer from './TSV2AxesLayer';
1212
import TSV2CursorLayer from './TSV2CursorLayer';
1313
import { exportToSVG, downloadSVG } from './svgExport';
14+
import { SVGExportCapability } from './SVGExportCapability';
1415
import GetAppIcon from '@material-ui/icons/GetApp';
1516

1617
type Props = {
@@ -29,6 +30,7 @@ type Props = {
2930
yMin?: number
3031
yMax?: number
3132
}
33+
svgExportCapability?: SVGExportCapability
3234
}
3335

3436
const defaultMargins = {
@@ -53,7 +55,7 @@ export const useTimeScrollView2 = ({width, height, hideToolbar}: {width: number,
5355
}
5456
}
5557

56-
const TimeScrollView2: FunctionComponent<Props> = ({width, height, onCanvasElement, gridlineOpts, onKeyDown, onMouseDown, onMouseMove, onMouseOut, onMouseUp, hideToolbar, yAxisInfo}) => {
58+
const TimeScrollView2: FunctionComponent<Props> = ({width, height, onCanvasElement, gridlineOpts, onKeyDown, onMouseDown, onMouseMove, onMouseOut, onMouseUp, hideToolbar, yAxisInfo, svgExportCapability}) => {
5759
const { visibleStartTimeSec, visibleEndTimeSec, zoomTimeseriesSelection, panTimeseriesSelection } = useTimeRange()
5860
const {currentTime, currentTimeInterval } = useTimeseriesSelection()
5961
const timeRange = useMemo(() => (
@@ -174,34 +176,45 @@ const TimeScrollView2: FunctionComponent<Props> = ({width, height, onCanvasEleme
174176
onMouseOut && onMouseOut(e)
175177
}, [handleMouseLeave, onMouseOut])
176178

177-
const handleExportSVG = useCallback(() => {
178-
// Get canvas data as base64 image
179-
let canvasImageData: string | undefined
180-
if (canvasRef.current) {
181-
try {
182-
canvasImageData = canvasRef.current.toDataURL('image/png')
183-
} catch (error) {
184-
console.warn('Could not export canvas data:', error)
179+
const handleExportSVG = useCallback(async () => {
180+
try {
181+
// Create SVG export props
182+
const svgProps = {
183+
width: canvasWidth,
184+
height: canvasHeight,
185+
margins,
186+
timeTicks,
187+
yTickSet: yAxisInfo?.showTicks ? yTickSet : undefined,
188+
gridlineOpts,
189+
currentTimePixels,
190+
currentTimeIntervalPixels
185191
}
186-
}
187192

188-
// Create SVG export props
189-
const svgProps = {
190-
width: canvasWidth,
191-
height: canvasHeight,
192-
margins,
193-
timeTicks,
194-
yTickSet: yAxisInfo?.showTicks ? yTickSet : undefined,
195-
gridlineOpts,
196-
currentTimePixels,
197-
currentTimeIntervalPixels,
198-
canvasImageData
199-
}
193+
let svgContent: string
200194

201-
// Generate and download SVG
202-
const svgContent = exportToSVG(svgProps)
203-
downloadSVG(svgContent)
204-
}, [canvasWidth, canvasHeight, margins, timeTicks, yAxisInfo?.showTicks, yTickSet, gridlineOpts, currentTimePixels, currentTimeIntervalPixels])
195+
if (svgExportCapability?.canExportToSVG && svgExportCapability.exportToSVG) {
196+
// Use parent's SVG export capability for proper vector export
197+
const additionalSVGElements = await svgExportCapability.exportToSVG(svgProps)
198+
svgContent = exportToSVG({...svgProps, additionalSVGElements})
199+
} else {
200+
// Fallback to bitmap export (should not happen if button is properly hidden)
201+
let canvasImageData: string | undefined
202+
if (canvasRef.current) {
203+
try {
204+
canvasImageData = canvasRef.current.toDataURL('image/png')
205+
} catch (error) {
206+
console.warn('Could not export canvas data:', error)
207+
}
208+
}
209+
svgContent = exportToSVG({...svgProps, canvasImageData})
210+
}
211+
212+
downloadSVG(svgContent)
213+
} catch (error) {
214+
console.error('Failed to export SVG:', error)
215+
alert('Failed to export SVG. Please try again.')
216+
}
217+
}, [canvasWidth, canvasHeight, margins, timeTicks, yAxisInfo?.showTicks, yTickSet, gridlineOpts, currentTimePixels, currentTimeIntervalPixels, svgExportCapability])
205218

206219
const handleCanvasElement = useCallback((elmt: HTMLCanvasElement) => {
207220
canvasRef.current = elmt
@@ -239,14 +252,14 @@ const TimeScrollView2: FunctionComponent<Props> = ({width, height, onCanvasEleme
239252

240253
const exportAction = useMemo(() => ({
241254
type: 'button' as const,
242-
title: 'Export to SVG',
255+
title: 'Export plot to SVG',
243256
icon: <GetAppIcon />,
244257
callback: handleExportSVG,
245258
selected: false
246259
}), [handleExportSVG])
247260

248261
const timeControlActions = useActionToolbar({
249-
belowDefault: [exportAction]
262+
belowDefault: svgExportCapability?.canExportToSVG ? [exportAction] : []
250263
})
251264

252265
if (hideToolbar) {

gui/src/libraries/timeseries-views/component-time-scroll-view-2/svgExport.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,26 +11,32 @@ export interface SVGExportProps {
1111
currentTimePixels?: number
1212
currentTimeIntervalPixels?: [number, number]
1313
canvasImageData?: string // base64 encoded canvas data
14+
additionalSVGElements?: string[] // Additional SVG elements from parent component
1415
}
1516

1617
export const exportToSVG = (props: SVGExportProps): string => {
17-
const {width, height, margins, timeTicks, yTickSet, gridlineOpts, currentTimePixels, currentTimeIntervalPixels, canvasImageData} = props
18+
const {width, height, margins, timeTicks, yTickSet, gridlineOpts, currentTimePixels, currentTimeIntervalPixels, canvasImageData, additionalSVGElements} = props
1819

1920
// Create SVG elements
2021
const svgElements: string[] = []
2122

2223
// SVG header
2324
svgElements.push(`<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">`)
2425

25-
// Add canvas content as image if available
26+
// Add canvas content as image if available (fallback for non-capable parents)
2627
if (canvasImageData) {
2728
svgElements.push(`<image x="0" y="0" width="${width}" height="${height}" href="${canvasImageData}" />`)
2829
}
2930

30-
// Add axes and gridlines
31+
// Add axes and gridlines (behind the plot data)
3132
svgElements.push(...renderAxesToSVG(props))
3233

33-
// Add cursor elements
34+
// Add additional SVG elements from parent component (proper vector export - in front of axes)
35+
if (additionalSVGElements) {
36+
svgElements.push(...additionalSVGElements)
37+
}
38+
39+
// Add cursor elements (on top of everything)
3440
svgElements.push(...renderCursorToSVG(props))
3541

3642
// SVG footer

gui/src/libraries/timeseries-views/view-timeseries-graph/TimeseriesGraphView.tsx

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import React, { FunctionComponent, useCallback, useEffect, useMemo, useState } from 'react'
22
import TimeScrollView2, { useTimeScrollView2 } from '../component-time-scroll-view-2/TimeScrollView2'
3+
import { SVGExportCapability } from '../component-time-scroll-view-2/SVGExportCapability'
34
import { useTimeRange, useTimeseriesSelectionInitialization } from '../context-timeseries-selection'
45
import { TimeseriesGraphViewData } from './TimeseriesGraphViewData'
5-
import { Opts } from './WorkerTypes'
6+
import { Opts, SVGExportRequest, SVGExportResponse } from './WorkerTypes'
67

78
type Props = {
89
data: TimeseriesGraphViewData
@@ -104,6 +105,42 @@ const TimeseriesGraphView: FunctionComponent<Props> = ({data, width, height}) =>
104105
yMax: maxValue
105106
}), [minValue, maxValue])
106107

108+
// SVG Export capability implementation
109+
const requestSVGFromWorker = useCallback(async (): Promise<string[]> => {
110+
if (!worker) {
111+
throw new Error('Worker not available for SVG export')
112+
}
113+
114+
return new Promise((resolve, reject) => {
115+
const requestId = Math.random().toString(36).substr(2, 9)
116+
const timeout = setTimeout(() => {
117+
reject(new Error('SVG export request timed out'))
118+
}, 10000) // 10 second timeout
119+
120+
const handleMessage = (event: MessageEvent) => {
121+
const data = event.data as SVGExportResponse
122+
if (data.type === 'svgExportData' && data.requestId === requestId) {
123+
clearTimeout(timeout)
124+
worker.removeEventListener('message', handleMessage)
125+
resolve(data.svgElements)
126+
}
127+
}
128+
129+
worker.addEventListener('message', handleMessage)
130+
131+
const request: SVGExportRequest = {
132+
type: 'requestSVGExport',
133+
requestId
134+
}
135+
worker.postMessage(request)
136+
})
137+
}, [worker])
138+
139+
const svgExportCapability: SVGExportCapability = useMemo(() => ({
140+
canExportToSVG: true,
141+
exportToSVG: requestSVGFromWorker
142+
}), [requestSVGFromWorker])
143+
107144
const content = (
108145
<TimeScrollView2
109146
onCanvasElement={elmt => setCanvasElement(elmt)}
@@ -113,6 +150,7 @@ const TimeseriesGraphView: FunctionComponent<Props> = ({data, width, height}) =>
113150
height={height}
114151
yAxisInfo={yAxisInfo}
115152
hideToolbar={hideToolbar}
153+
svgExportCapability={svgExportCapability}
116154
/>
117155
)
118156
return content
@@ -126,4 +164,4 @@ const max = (a: number[]) => {
126164
return a.reduce((prev, current) => (prev > current) ? prev : current, a[0] || 0)
127165
}
128166

129-
export default TimeseriesGraphView
167+
export default TimeseriesGraphView

gui/src/libraries/timeseries-views/view-timeseries-graph/WorkerTypes.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,15 @@ export type ResolvedSeries = {
1818
attributes: {[key: string]: any}
1919
t: number[]
2020
y: number[]
21-
}
21+
}
22+
23+
export type SVGExportRequest = {
24+
type: 'requestSVGExport'
25+
requestId: string
26+
}
27+
28+
export type SVGExportResponse = {
29+
type: 'svgExportData'
30+
requestId: string
31+
svgElements: string[]
32+
}

0 commit comments

Comments
 (0)