|
| 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 | +} |
0 commit comments