Skip to content

Commit 26f97a7

Browse files
committed
export to svg
1 parent 9c4eca0 commit 26f97a7

File tree

4 files changed

+201
-9
lines changed

4 files changed

+201
-9
lines changed

gui/README.md

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ git clone <this-repo>
4040
Open this workspace in VS Code.
4141

4242
```bash
43-
cd spikesortingview
43+
cd gui
4444
code .
4545
```
4646

@@ -54,7 +54,6 @@ Install the recommended VS Code extensions
5454
## Install node packages via yarn
5555

5656
```bash
57-
cd spikesortingview
5857
yarn install
5958
```
6059

@@ -64,7 +63,7 @@ This will install a large number of packages in the `node_modules/` folder
6463

6564
```bash
6665
# In VS Code terminal
67-
yarn start
66+
yarn dev
6867
```
6968

7069
If all works as intended, you will get a development server listening on port 3000.

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

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import { ViewToolbar } from '../ViewToolbar';
1010
import { useTimeTicks } from './timeTicks';
1111
import TSV2AxesLayer from './TSV2AxesLayer';
1212
import TSV2CursorLayer from './TSV2CursorLayer';
13+
import { exportToSVG, downloadSVG } from './svgExport';
14+
import GetAppIcon from '@material-ui/icons/GetApp';
1315

1416
type Props = {
1517
width: number
@@ -30,7 +32,7 @@ type Props = {
3032
}
3133

3234
const defaultMargins = {
33-
left: 30,
35+
left: 50,
3436
right: 20,
3537
top: 20,
3638
bottom: 30
@@ -118,6 +120,7 @@ const TimeScrollView2: FunctionComponent<Props> = ({width, height, onCanvasEleme
118120
}, [canvasWidth, canvasHeight, timeRange, margins, currentTimePixels, currentTimeIntervalPixels])
119121

120122
const divRef = useRef<HTMLDivElement | null>(null)
123+
const canvasRef = useRef<HTMLCanvasElement | null>(null)
121124
useEffect(() => suppressWheelScroll(divRef), [divRef])
122125
const panelWidthSeconds = (visibleEndTimeSec ?? 0) - (visibleStartTimeSec ?? 0)
123126
const handleWheel = useTimeScrollZoom(divRef, zoomTimeseriesSelection)
@@ -171,6 +174,40 @@ const TimeScrollView2: FunctionComponent<Props> = ({width, height, onCanvasEleme
171174
onMouseOut && onMouseOut(e)
172175
}, [handleMouseLeave, onMouseOut])
173176

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)
185+
}
186+
}
187+
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+
}
200+
201+
// Generate and download SVG
202+
const svgContent = exportToSVG(svgProps)
203+
downloadSVG(svgContent)
204+
}, [canvasWidth, canvasHeight, margins, timeTicks, yAxisInfo?.showTicks, yTickSet, gridlineOpts, currentTimePixels, currentTimeIntervalPixels])
205+
206+
const handleCanvasElement = useCallback((elmt: HTMLCanvasElement) => {
207+
canvasRef.current = elmt
208+
onCanvasElement(elmt)
209+
}, [onCanvasElement])
210+
174211
const content = useMemo(() => {
175212
return (
176213
<div
@@ -191,16 +228,26 @@ const TimeScrollView2: FunctionComponent<Props> = ({width, height, onCanvasEleme
191228
{axesLayer}
192229
<canvas
193230
style={{position: 'absolute', width: canvasWidth, height: canvasHeight}}
194-
ref={onCanvasElement}
231+
ref={handleCanvasElement}
195232
width={canvasWidth}
196233
height={canvasHeight}
197234
/>
198235
{cursorLayer}
199236
</div>
200237
)
201-
}, [onCanvasElement, axesLayer, cursorLayer, canvasWidth, canvasHeight, handleKeyDown, handleWheel, handleMouseDown2, handleMouseUp2, handleMouseMove2, handleMouseOut2])
238+
}, [handleCanvasElement, axesLayer, cursorLayer, canvasWidth, canvasHeight, handleKeyDown, handleWheel, handleMouseDown2, handleMouseUp2, handleMouseMove2, handleMouseOut2])
202239

203-
const timeControlActions = useActionToolbar()
240+
const exportAction = useMemo(() => ({
241+
type: 'button' as const,
242+
title: 'Export to SVG',
243+
icon: <GetAppIcon />,
244+
callback: handleExportSVG,
245+
selected: false
246+
}), [handleExportSVG])
247+
248+
const timeControlActions = useActionToolbar({
249+
belowDefault: [exportAction]
250+
})
204251

205252
if (hideToolbar) {
206253
return (
@@ -228,4 +275,4 @@ const TimeScrollView2: FunctionComponent<Props> = ({width, height, onCanvasEleme
228275
)
229276
}
230277

231-
export default TimeScrollView2
278+
export default TimeScrollView2
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { TickSet } from "../component-time-scroll-view/YAxisTicks";
2+
import { TimeTick } from "./timeTicks";
3+
4+
export interface SVGExportProps {
5+
width: number
6+
height: number
7+
margins: {left: number, right: number, top: number, bottom: number}
8+
timeTicks: TimeTick[]
9+
yTickSet?: TickSet
10+
gridlineOpts?: {hideX: boolean, hideY: boolean}
11+
currentTimePixels?: number
12+
currentTimeIntervalPixels?: [number, number]
13+
canvasImageData?: string // base64 encoded canvas data
14+
}
15+
16+
export const exportToSVG = (props: SVGExportProps): string => {
17+
const {width, height, margins, timeTicks, yTickSet, gridlineOpts, currentTimePixels, currentTimeIntervalPixels, canvasImageData} = props
18+
19+
// Create SVG elements
20+
const svgElements: string[] = []
21+
22+
// SVG header
23+
svgElements.push(`<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">`)
24+
25+
// Add canvas content as image if available
26+
if (canvasImageData) {
27+
svgElements.push(`<image x="0" y="0" width="${width}" height="${height}" href="${canvasImageData}" />`)
28+
}
29+
30+
// Add axes and gridlines
31+
svgElements.push(...renderAxesToSVG(props))
32+
33+
// Add cursor elements
34+
svgElements.push(...renderCursorToSVG(props))
35+
36+
// SVG footer
37+
svgElements.push('</svg>')
38+
39+
return svgElements.join('\n')
40+
}
41+
42+
const renderAxesToSVG = (props: SVGExportProps): string[] => {
43+
const {width, height, margins, timeTicks, gridlineOpts, yTickSet} = props
44+
const elements: string[] = []
45+
46+
const xAxisVerticalPosition = height - margins.bottom
47+
48+
// Time ticks and gridlines
49+
elements.push(...renderTimeTicksToSVG(timeTicks, xAxisVerticalPosition, margins.top, {hideGridlines: gridlineOpts?.hideX}))
50+
51+
// X-axis line
52+
elements.push(`<line x1="${margins.left}" y1="${xAxisVerticalPosition}" x2="${width - margins.right}" y2="${xAxisVerticalPosition}" stroke="black" />`)
53+
54+
// Y ticks and gridlines
55+
if (yTickSet) {
56+
const topMargin = 0
57+
elements.push(...renderYTicksToSVG(yTickSet, xAxisVerticalPosition, margins.left, width - margins.right, topMargin, {hideGridlines: gridlineOpts?.hideY}))
58+
}
59+
60+
return elements
61+
}
62+
63+
const renderTimeTicksToSVG = (timeTicks: TimeTick[], xAxisPixelHeight: number, plotTopPixelHeight: number, o: {hideGridlines?: boolean}): string[] => {
64+
const elements: string[] = []
65+
const hideTimeAxis = false
66+
67+
if (!timeTicks || timeTicks.length === 0) return elements
68+
69+
const labelOffsetFromGridline = 2
70+
const gridlineBottomEdge = xAxisPixelHeight + (hideTimeAxis ? 0 : 5)
71+
72+
timeTicks.forEach(tick => {
73+
const strokeColor = tick.major ? 'gray' : 'lightgray'
74+
const topPixel = !o.hideGridlines ? plotTopPixelHeight : xAxisPixelHeight
75+
76+
// Gridline
77+
elements.push(`<line x1="${tick.pixelXposition}" y1="${gridlineBottomEdge}" x2="${tick.pixelXposition}" y2="${topPixel}" stroke="${strokeColor}" />`)
78+
79+
// Label
80+
if (!hideTimeAxis) {
81+
const fillColor = tick.major ? 'black' : 'gray'
82+
elements.push(`<text x="${tick.pixelXposition}" y="${gridlineBottomEdge + labelOffsetFromGridline}" text-anchor="middle" dominant-baseline="hanging" fill="${fillColor}" font-family="Arial, sans-serif" font-size="12">${tick.label}</text>`)
83+
}
84+
})
85+
86+
return elements
87+
}
88+
89+
const renderYTicksToSVG = (tickSet: TickSet, xAxisYCoordinate: number, yAxisXCoordinate: number, plotRightPx: number, topMargin: number, o: {hideGridlines?: boolean}): string[] => {
90+
const elements: string[] = []
91+
const labelOffsetFromGridline = 2
92+
const gridlineLeftEdge = yAxisXCoordinate - 5
93+
const labelRightEdge = gridlineLeftEdge - labelOffsetFromGridline
94+
const { ticks } = tickSet
95+
96+
ticks.forEach(tick => {
97+
if (!tick.pixelValue) return
98+
99+
const pixelValueWithMargin = tick.pixelValue + topMargin
100+
const strokeColor = tick.isMajor ? 'gray' : 'lightgray'
101+
const fillColor = tick.isMajor ? 'black' : 'gray'
102+
const rightPixel = !o.hideGridlines ? plotRightPx : yAxisXCoordinate
103+
104+
// Gridline
105+
elements.push(`<line x1="${gridlineLeftEdge}" y1="${pixelValueWithMargin}" x2="${rightPixel}" y2="${pixelValueWithMargin}" stroke="${strokeColor}" />`)
106+
107+
// Label
108+
elements.push(`<text x="${labelRightEdge}" y="${pixelValueWithMargin}" text-anchor="end" dominant-baseline="middle" fill="${fillColor}" font-family="Arial, sans-serif" font-size="12">${tick.label}</text>`)
109+
})
110+
111+
return elements
112+
}
113+
114+
const renderCursorToSVG = (props: SVGExportProps): string[] => {
115+
const {margins, currentTimePixels, currentTimeIntervalPixels, height} = props
116+
const elements: string[] = []
117+
118+
// Current time interval
119+
if (currentTimeIntervalPixels !== undefined) {
120+
const x = currentTimeIntervalPixels[0]
121+
const y = margins.top
122+
const w = currentTimeIntervalPixels[1] - currentTimeIntervalPixels[0]
123+
const h = height - margins.bottom - margins.top
124+
125+
elements.push(`<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="rgba(255, 225, 225, 0.4)" stroke="rgba(150, 50, 50, 0.9)" />`)
126+
}
127+
128+
// Current time
129+
if ((currentTimePixels !== undefined) && (currentTimeIntervalPixels === undefined)) {
130+
elements.push(`<line x1="${currentTimePixels}" y1="${margins.top}" x2="${currentTimePixels}" y2="${height - margins.bottom}" stroke="red" />`)
131+
}
132+
133+
return elements
134+
}
135+
136+
export const downloadSVG = (svgContent: string, filename = 'timeseries-view.svg') => {
137+
const blob = new Blob([svgContent], { type: 'image/svg+xml' })
138+
const url = URL.createObjectURL(blob)
139+
const link = document.createElement('a')
140+
link.href = url
141+
link.download = filename
142+
document.body.appendChild(link)
143+
link.click()
144+
document.body.removeChild(link)
145+
URL.revokeObjectURL(url)
146+
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -278,4 +278,4 @@ function sleepMsec(msec: number) {
278278
})
279279
}
280280

281-
export { }
281+
// export { }

0 commit comments

Comments
 (0)