diff options
author | Kristóf Marussy <kristof@marussy.com> | 2024-02-22 21:09:46 +0100 |
---|---|---|
committer | Kristóf Marussy <kristof@marussy.com> | 2024-02-22 21:09:46 +0100 |
commit | 76144b74991110e8f655ce6289b2a2a85d1d9019 (patch) | |
tree | 4aa3727d17ec007559a96488f233eb0c26c59753 /subprojects | |
parent | Merge pull request #53 from kris7t/imports (diff) | |
download | refinery-76144b74991110e8f655ce6289b2a2a85d1d9019.tar.gz refinery-76144b74991110e8f655ce6289b2a2a85d1d9019.tar.zst refinery-76144b74991110e8f655ce6289b2a2a85d1d9019.zip |
feat(web): SVG export
Diffstat (limited to 'subprojects')
-rw-r--r-- | subprojects/frontend/package.json | 5 | ||||
-rw-r--r-- | subprojects/frontend/src/graph/DotGraphVisualizer.tsx | 5 | ||||
-rw-r--r-- | subprojects/frontend/src/graph/ExportButton.tsx | 168 | ||||
-rw-r--r-- | subprojects/frontend/src/graph/GraphArea.tsx | 14 | ||||
-rw-r--r-- | subprojects/frontend/src/graph/GraphTheme.tsx | 94 | ||||
-rw-r--r-- | subprojects/frontend/src/graph/postProcessSVG.ts | 6 |
6 files changed, 256 insertions, 36 deletions
diff --git a/subprojects/frontend/package.json b/subprojects/frontend/package.json index 7d5e19d1..685f7cc5 100644 --- a/subprojects/frontend/package.json +++ b/subprojects/frontend/package.json | |||
@@ -1,6 +1,6 @@ | |||
1 | { | 1 | { |
2 | "//": [ | 2 | "//": [ |
3 | "SPDX-FileCopyrightText: 2021-2023 The Refinery Authors <https://refinery.tools/>", | 3 | "SPDX-FileCopyrightText: 2021-2024 The Refinery Authors <https://refinery.tools/>", |
4 | "", | 4 | "", |
5 | "SPDX-License-Identifier: EPL-2.0" | 5 | "SPDX-License-Identifier: EPL-2.0" |
6 | ], | 6 | ], |
@@ -35,8 +35,11 @@ | |||
35 | "@codemirror/search": "^6.5.6", | 35 | "@codemirror/search": "^6.5.6", |
36 | "@codemirror/state": "^6.4.0", | 36 | "@codemirror/state": "^6.4.0", |
37 | "@codemirror/view": "^6.24.0", | 37 | "@codemirror/view": "^6.24.0", |
38 | "@emotion/cache": "^11.11.0", | ||
38 | "@emotion/react": "^11.11.3", | 39 | "@emotion/react": "^11.11.3", |
40 | "@emotion/serialize": "^1.1.3", | ||
39 | "@emotion/styled": "^11.11.0", | 41 | "@emotion/styled": "^11.11.0", |
42 | "@emotion/utils": "^1.2.1", | ||
40 | "@fontsource-variable/jetbrains-mono": "^5.0.19", | 43 | "@fontsource-variable/jetbrains-mono": "^5.0.19", |
41 | "@fontsource-variable/open-sans": "^5.0.25", | 44 | "@fontsource-variable/open-sans": "^5.0.25", |
42 | "@hpcc-js/wasm": "^2.16.0", | 45 | "@hpcc-js/wasm": "^2.16.0", |
diff --git a/subprojects/frontend/src/graph/DotGraphVisualizer.tsx b/subprojects/frontend/src/graph/DotGraphVisualizer.tsx index 72ac58fa..cc8b5116 100644 --- a/subprojects/frontend/src/graph/DotGraphVisualizer.tsx +++ b/subprojects/frontend/src/graph/DotGraphVisualizer.tsx | |||
@@ -30,11 +30,13 @@ function DotGraphVisualizer({ | |||
30 | fitZoom, | 30 | fitZoom, |
31 | transitionTime, | 31 | transitionTime, |
32 | animateThreshold, | 32 | animateThreshold, |
33 | setSvgContainer, | ||
33 | }: { | 34 | }: { |
34 | graph: GraphStore; | 35 | graph: GraphStore; |
35 | fitZoom?: FitZoomCallback; | 36 | fitZoom?: FitZoomCallback; |
36 | transitionTime?: number; | 37 | transitionTime?: number; |
37 | animateThreshold?: number; | 38 | animateThreshold?: number; |
39 | setSvgContainer?: (container: HTMLElement | undefined) => void; | ||
38 | }): JSX.Element { | 40 | }): JSX.Element { |
39 | const transitionTimeOrDefault = | 41 | const transitionTimeOrDefault = |
40 | transitionTime ?? DotGraphVisualizer.defaultProps.transitionTime; | 42 | transitionTime ?? DotGraphVisualizer.defaultProps.transitionTime; |
@@ -48,6 +50,7 @@ function DotGraphVisualizer({ | |||
48 | 50 | ||
49 | const setElement = useCallback( | 51 | const setElement = useCallback( |
50 | (element: HTMLDivElement | null) => { | 52 | (element: HTMLDivElement | null) => { |
53 | setSvgContainer?.(element ?? undefined); | ||
51 | if (disposerRef.current !== undefined) { | 54 | if (disposerRef.current !== undefined) { |
52 | disposerRef.current(); | 55 | disposerRef.current(); |
53 | disposerRef.current = undefined; | 56 | disposerRef.current = undefined; |
@@ -147,6 +150,7 @@ function DotGraphVisualizer({ | |||
147 | transitionTimeOrDefault, | 150 | transitionTimeOrDefault, |
148 | animateThresholdOrDefault, | 151 | animateThresholdOrDefault, |
149 | animate, | 152 | animate, |
153 | setSvgContainer, | ||
150 | ], | 154 | ], |
151 | ); | 155 | ); |
152 | 156 | ||
@@ -157,6 +161,7 @@ DotGraphVisualizer.defaultProps = { | |||
157 | fitZoom: undefined, | 161 | fitZoom: undefined, |
158 | transitionTime: 250, | 162 | transitionTime: 250, |
159 | animateThreshold: 100, | 163 | animateThreshold: 100, |
164 | setSvgContainer: undefined, | ||
160 | }; | 165 | }; |
161 | 166 | ||
162 | export default observer(DotGraphVisualizer); | 167 | export default observer(DotGraphVisualizer); |
diff --git a/subprojects/frontend/src/graph/ExportButton.tsx b/subprojects/frontend/src/graph/ExportButton.tsx new file mode 100644 index 00000000..91445d00 --- /dev/null +++ b/subprojects/frontend/src/graph/ExportButton.tsx | |||
@@ -0,0 +1,168 @@ | |||
1 | /* | ||
2 | * SPDX-FileCopyrightText: 2024 The Refinery Authors <https://refinery.tools/> | ||
3 | * | ||
4 | * SPDX-License-Identifier: EPL-2.0 | ||
5 | */ | ||
6 | |||
7 | import createCache from '@emotion/cache'; | ||
8 | import { serializeStyles } from '@emotion/serialize'; | ||
9 | import type { StyleSheet } from '@emotion/utils'; | ||
10 | import cancelSVG from '@material-icons/svg/svg/cancel/baseline.svg?raw'; | ||
11 | import labelSVG from '@material-icons/svg/svg/label/baseline.svg?raw'; | ||
12 | import labelOutlinedSVG from '@material-icons/svg/svg/label/outline.svg?raw'; | ||
13 | import SaveAltIcon from '@mui/icons-material/SaveAlt'; | ||
14 | import IconButton from '@mui/material/IconButton'; | ||
15 | import { styled, useTheme, type Theme } from '@mui/material/styles'; | ||
16 | import { useCallback } from 'react'; | ||
17 | |||
18 | import { createGraphTheme } from './GraphTheme'; | ||
19 | import { SVG_NS } from './postProcessSVG'; | ||
20 | |||
21 | const PROLOG = '<?xml version="1.0" encoding="UTF-8" standalone="no"?>'; | ||
22 | |||
23 | const ExportButtonRoot = styled('div', { | ||
24 | name: 'ExportButton-Root', | ||
25 | })(({ theme }) => ({ | ||
26 | position: 'absolute', | ||
27 | padding: theme.spacing(1), | ||
28 | top: 0, | ||
29 | right: 0, | ||
30 | overflow: 'hidden', | ||
31 | display: 'flex', | ||
32 | flexDirection: 'column', | ||
33 | alignItems: 'start', | ||
34 | })); | ||
35 | |||
36 | const ICONS: Map<string, Element> = new Map(); | ||
37 | |||
38 | function importSVG(svgSource: string, className: string): void { | ||
39 | const parser = new DOMParser(); | ||
40 | const svgDocument = parser.parseFromString(svgSource, 'image/svg+xml'); | ||
41 | const root = svgDocument.children[0]; | ||
42 | if (root === undefined) { | ||
43 | return; | ||
44 | } | ||
45 | root.id = className; | ||
46 | root.classList.add(className); | ||
47 | ICONS.set(className, root); | ||
48 | } | ||
49 | |||
50 | importSVG(labelSVG, 'icon-TRUE'); | ||
51 | importSVG(labelOutlinedSVG, 'icon-UNKNOWN'); | ||
52 | importSVG(cancelSVG, 'icon-ERROR'); | ||
53 | |||
54 | function appendStyles( | ||
55 | svgDocument: XMLDocument, | ||
56 | svg: SVGSVGElement, | ||
57 | theme: Theme, | ||
58 | ): void { | ||
59 | const cache = createCache({ | ||
60 | key: 'refinery', | ||
61 | container: svg, | ||
62 | prepend: true, | ||
63 | }); | ||
64 | // @ts-expect-error `CSSObject` types don't match up between `@mui/material` and | ||
65 | // `@emotion/serialize`, but they are compatible in practice. | ||
66 | const styles = serializeStyles([createGraphTheme], cache.registered, { | ||
67 | theme, | ||
68 | colorNodes: true, | ||
69 | noEmbedIcons: true, | ||
70 | }); | ||
71 | const rules: string[] = []; | ||
72 | const sheet = { | ||
73 | insert(rule) { | ||
74 | rules.push(rule); | ||
75 | }, | ||
76 | } as StyleSheet; | ||
77 | cache.insert('', styles, sheet, false); | ||
78 | const styleElement = svgDocument.createElementNS(SVG_NS, 'style'); | ||
79 | svg.prepend(styleElement); | ||
80 | styleElement.innerHTML = rules.join(''); | ||
81 | } | ||
82 | |||
83 | function fixForeignObjects(svgDocument: XMLDocument, svg: SVGSVGElement): void { | ||
84 | const foreignObjects: SVGForeignObjectElement[] = []; | ||
85 | svg | ||
86 | .querySelectorAll('foreignObject') | ||
87 | .forEach((object) => foreignObjects.push(object)); | ||
88 | foreignObjects.forEach((object) => { | ||
89 | const useElement = svgDocument.createElementNS(SVG_NS, 'use'); | ||
90 | let x = Number(object.getAttribute('x') ?? '0'); | ||
91 | let y = Number(object.getAttribute('y') ?? '0'); | ||
92 | const width = Number(object.getAttribute('width') ?? '0'); | ||
93 | const height = Number(object.getAttribute('height') ?? '0'); | ||
94 | const size = Math.min(width, height); | ||
95 | x += (width - size) / 2; | ||
96 | y += (height - size) / 2; | ||
97 | useElement.setAttribute('x', String(x)); | ||
98 | useElement.setAttribute('y', String(y)); | ||
99 | useElement.setAttribute('width', String(size)); | ||
100 | useElement.setAttribute('height', String(size)); | ||
101 | useElement.id = object.id; | ||
102 | object.children[0]?.classList?.forEach((className) => { | ||
103 | useElement.classList.add(className); | ||
104 | if (ICONS.has(className)) { | ||
105 | useElement.setAttribute('href', `#${className}`); | ||
106 | } | ||
107 | }); | ||
108 | object.replaceWith(useElement); | ||
109 | }); | ||
110 | const defs = svgDocument.createElementNS(SVG_NS, 'defs'); | ||
111 | svg.prepend(defs); | ||
112 | ICONS.forEach((value) => { | ||
113 | const importedValue = svgDocument.importNode(value, true); | ||
114 | defs.appendChild(importedValue); | ||
115 | }); | ||
116 | } | ||
117 | |||
118 | function downloadSVG(svgDocument: XMLDocument) { | ||
119 | const serializer = new XMLSerializer(); | ||
120 | const svgText = `${PROLOG}\n${serializer.serializeToString(svgDocument)}`; | ||
121 | const blob = new Blob([svgText], { | ||
122 | type: 'image/svg+xml', | ||
123 | }); | ||
124 | const link = document.createElement('a'); | ||
125 | link.href = window.URL.createObjectURL(blob); | ||
126 | link.download = 'graph.svg'; | ||
127 | link.style.display = 'none'; | ||
128 | document.body.appendChild(link); | ||
129 | link.click(); | ||
130 | document.body.removeChild(link); | ||
131 | } | ||
132 | |||
133 | export default function ExportButton({ | ||
134 | svgContainer, | ||
135 | }: { | ||
136 | svgContainer: HTMLElement | undefined; | ||
137 | }): JSX.Element { | ||
138 | const theme = useTheme(); | ||
139 | const saveCallback = useCallback(() => { | ||
140 | const svg = svgContainer?.querySelector('svg'); | ||
141 | if (!svg) { | ||
142 | return; | ||
143 | } | ||
144 | const svgDocument = document.implementation.createDocument( | ||
145 | SVG_NS, | ||
146 | 'svg', | ||
147 | null, | ||
148 | ); | ||
149 | const copyOfSVG = svgDocument.importNode(svg, true); | ||
150 | const originalRoot = svgDocument.childNodes[0]; | ||
151 | if (originalRoot === undefined) { | ||
152 | svgDocument.appendChild(copyOfSVG); | ||
153 | } else { | ||
154 | svgDocument.replaceChild(copyOfSVG, originalRoot); | ||
155 | } | ||
156 | fixForeignObjects(svgDocument, copyOfSVG); | ||
157 | appendStyles(svgDocument, copyOfSVG, theme); | ||
158 | downloadSVG(svgDocument); | ||
159 | }, [theme, svgContainer]); | ||
160 | |||
161 | return ( | ||
162 | <ExportButtonRoot> | ||
163 | <IconButton aria-label="Save SVG" onClick={saveCallback}> | ||
164 | <SaveAltIcon /> | ||
165 | </IconButton> | ||
166 | </ExportButtonRoot> | ||
167 | ); | ||
168 | } | ||
diff --git a/subprojects/frontend/src/graph/GraphArea.tsx b/subprojects/frontend/src/graph/GraphArea.tsx index d5801b9a..2bf40d1a 100644 --- a/subprojects/frontend/src/graph/GraphArea.tsx +++ b/subprojects/frontend/src/graph/GraphArea.tsx | |||
@@ -1,5 +1,5 @@ | |||
1 | /* | 1 | /* |
2 | * SPDX-FileCopyrightText: 2023 The Refinery Authors <https://refinery.tools/> | 2 | * SPDX-FileCopyrightText: 2023-2024 The Refinery Authors <https://refinery.tools/> |
3 | * | 3 | * |
4 | * SPDX-License-Identifier: EPL-2.0 | 4 | * SPDX-License-Identifier: EPL-2.0 |
5 | */ | 5 | */ |
@@ -7,9 +7,11 @@ | |||
7 | import Box from '@mui/material/Box'; | 7 | import Box from '@mui/material/Box'; |
8 | import { useTheme } from '@mui/material/styles'; | 8 | import { useTheme } from '@mui/material/styles'; |
9 | import { observer } from 'mobx-react-lite'; | 9 | import { observer } from 'mobx-react-lite'; |
10 | import { useState } from 'react'; | ||
10 | import { useResizeDetector } from 'react-resize-detector'; | 11 | import { useResizeDetector } from 'react-resize-detector'; |
11 | 12 | ||
12 | import DotGraphVisualizer from './DotGraphVisualizer'; | 13 | import DotGraphVisualizer from './DotGraphVisualizer'; |
14 | import ExportButton from './ExportButton'; | ||
13 | import type GraphStore from './GraphStore'; | 15 | import type GraphStore from './GraphStore'; |
14 | import VisibilityPanel from './VisibilityPanel'; | 16 | import VisibilityPanel from './VisibilityPanel'; |
15 | import ZoomCanvas from './ZoomCanvas'; | 17 | import ZoomCanvas from './ZoomCanvas'; |
@@ -19,6 +21,7 @@ function GraphArea({ graph }: { graph: GraphStore }): JSX.Element { | |||
19 | const { ref, width, height } = useResizeDetector({ | 21 | const { ref, width, height } = useResizeDetector({ |
20 | refreshMode: 'debounce', | 22 | refreshMode: 'debounce', |
21 | }); | 23 | }); |
24 | const [svgContainer, setSvgContainer] = useState<HTMLElement | undefined>(); | ||
22 | 25 | ||
23 | const breakpoint = breakpoints.values.sm; | 26 | const breakpoint = breakpoints.values.sm; |
24 | const dialog = | 27 | const dialog = |
@@ -36,9 +39,16 @@ function GraphArea({ graph }: { graph: GraphStore }): JSX.Element { | |||
36 | ref={ref} | 39 | ref={ref} |
37 | > | 40 | > |
38 | <ZoomCanvas> | 41 | <ZoomCanvas> |
39 | {(fitZoom) => <DotGraphVisualizer graph={graph} fitZoom={fitZoom} />} | 42 | {(fitZoom) => ( |
43 | <DotGraphVisualizer | ||
44 | graph={graph} | ||
45 | fitZoom={fitZoom} | ||
46 | setSvgContainer={setSvgContainer} | ||
47 | /> | ||
48 | )} | ||
40 | </ZoomCanvas> | 49 | </ZoomCanvas> |
41 | <VisibilityPanel graph={graph} dialog={dialog} /> | 50 | <VisibilityPanel graph={graph} dialog={dialog} /> |
51 | <ExportButton svgContainer={svgContainer} /> | ||
42 | </Box> | 52 | </Box> |
43 | ); | 53 | ); |
44 | } | 54 | } |
diff --git a/subprojects/frontend/src/graph/GraphTheme.tsx b/subprojects/frontend/src/graph/GraphTheme.tsx index 7334f559..b3f55a35 100644 --- a/subprojects/frontend/src/graph/GraphTheme.tsx +++ b/subprojects/frontend/src/graph/GraphTheme.tsx | |||
@@ -52,11 +52,34 @@ function createTypeHashStyles(theme: Theme, colorNodes: boolean): CSSObject { | |||
52 | return result; | 52 | return result; |
53 | } | 53 | } |
54 | 54 | ||
55 | export default styled('div', { | 55 | function iconStyle( |
56 | name: 'GraphTheme', | 56 | svg: string, |
57 | })<{ colorNodes: boolean }>(({ theme, colorNodes }) => ({ | 57 | color: string, |
58 | '& svg': { | 58 | noEmbedIcons?: boolean, |
59 | userSelect: 'none', | 59 | ): CSSObject { |
60 | if (noEmbedIcons) { | ||
61 | return { | ||
62 | fill: color, | ||
63 | }; | ||
64 | } | ||
65 | return { | ||
66 | maskImage: svgURL(svg), | ||
67 | background: color, | ||
68 | }; | ||
69 | } | ||
70 | |||
71 | export function createGraphTheme({ | ||
72 | theme, | ||
73 | colorNodes, | ||
74 | noEmbedIcons, | ||
75 | }: { | ||
76 | theme: Theme; | ||
77 | colorNodes: boolean; | ||
78 | noEmbedIcons?: boolean; | ||
79 | }): CSSObject { | ||
80 | const shadowAlapha = theme.palette.mode === 'dark' ? 0.32 : 0.24; | ||
81 | |||
82 | return { | ||
60 | '.node': { | 83 | '.node': { |
61 | '& text': { | 84 | '& text': { |
62 | fontFamily: theme.typography.fontFamily, | 85 | fontFamily: theme.typography.fontFamily, |
@@ -80,12 +103,15 @@ export default styled('div', { | |||
80 | strokeWidth: 2, | 103 | strokeWidth: 2, |
81 | }, | 104 | }, |
82 | }, | 105 | }, |
83 | '.node-shadow[fill="white"]': { | 106 | '.node-shadow[fill="white"]': noEmbedIcons |
84 | fill: alpha( | 107 | ? { |
85 | theme.palette.text.primary, | 108 | // Inkscape can't handle opacity in exported SVG. |
86 | theme.palette.mode === 'dark' ? 0.32 : 0.24, | 109 | fill: theme.palette.text.primary, |
87 | ), | 110 | opacity: shadowAlapha, |
88 | }, | 111 | } |
112 | : { | ||
113 | fill: alpha(theme.palette.text.primary, shadowAlapha), | ||
114 | }, | ||
89 | '.node-exists-UNKNOWN [stroke="black"]': { | 115 | '.node-exists-UNKNOWN [stroke="black"]': { |
90 | strokeDasharray: '5 2', | 116 | strokeDasharray: '5 2', |
91 | }, | 117 | }, |
@@ -104,30 +130,38 @@ export default styled('div', { | |||
104 | }, | 130 | }, |
105 | ...createEdgeColor('UNKNOWN', theme.palette.text.secondary, 'none'), | 131 | ...createEdgeColor('UNKNOWN', theme.palette.text.secondary, 'none'), |
106 | ...createEdgeColor('ERROR', theme.palette.error.main), | 132 | ...createEdgeColor('ERROR', theme.palette.error.main), |
107 | '.icon': { | 133 | ...(noEmbedIcons |
108 | maskSize: '12px 12px', | 134 | ? {} |
109 | maskPosition: '50% 50%', | 135 | : { |
110 | maskRepeat: 'no-repeat', | 136 | '.icon': { |
111 | width: '100%', | 137 | maskSize: '12px 12px', |
112 | height: '100%', | 138 | maskPosition: '50% 50%', |
113 | }, | 139 | maskRepeat: 'no-repeat', |
114 | '.icon-TRUE': { | 140 | width: '100%', |
115 | maskImage: svgURL(labelSVG), | 141 | height: '100%', |
116 | background: theme.palette.text.primary, | 142 | }, |
117 | }, | 143 | }), |
118 | '.icon-UNKNOWN': { | 144 | '.icon-TRUE': iconStyle(labelSVG, theme.palette.text.primary, noEmbedIcons), |
119 | maskImage: svgURL(labelOutlinedSVG), | 145 | '.icon-UNKNOWN': iconStyle( |
120 | background: theme.palette.text.secondary, | 146 | labelOutlinedSVG, |
121 | }, | 147 | theme.palette.text.secondary, |
122 | '.icon-ERROR': { | 148 | noEmbedIcons, |
123 | maskImage: svgURL(cancelSVG), | 149 | ), |
124 | background: theme.palette.error.main, | 150 | '.icon-ERROR': iconStyle(cancelSVG, theme.palette.error.main, noEmbedIcons), |
125 | }, | ||
126 | 'text.label-UNKNOWN': { | 151 | 'text.label-UNKNOWN': { |
127 | fill: theme.palette.text.secondary, | 152 | fill: theme.palette.text.secondary, |
128 | }, | 153 | }, |
129 | 'text.label-ERROR': { | 154 | 'text.label-ERROR': { |
130 | fill: theme.palette.error.main, | 155 | fill: theme.palette.error.main, |
131 | }, | 156 | }, |
157 | }; | ||
158 | } | ||
159 | |||
160 | export default styled('div', { | ||
161 | name: 'GraphTheme', | ||
162 | })<{ colorNodes: boolean }>((args) => ({ | ||
163 | '& svg': { | ||
164 | userSelect: 'none', | ||
165 | ...createGraphTheme(args), | ||
132 | }, | 166 | }, |
133 | })); | 167 | })); |
diff --git a/subprojects/frontend/src/graph/postProcessSVG.ts b/subprojects/frontend/src/graph/postProcessSVG.ts index a580f5c6..f434f80b 100644 --- a/subprojects/frontend/src/graph/postProcessSVG.ts +++ b/subprojects/frontend/src/graph/postProcessSVG.ts | |||
@@ -1,13 +1,13 @@ | |||
1 | /* | 1 | /* |
2 | * SPDX-FileCopyrightText: 2023 The Refinery Authors <https://refinery.tools/> | 2 | * SPDX-FileCopyrightText: 2023-2024 The Refinery Authors <https://refinery.tools/> |
3 | * | 3 | * |
4 | * SPDX-License-Identifier: EPL-2.0 | 4 | * SPDX-License-Identifier: EPL-2.0 |
5 | */ | 5 | */ |
6 | 6 | ||
7 | import { type BBox, parsePolygonBBox, parsePathBBox } from './parseBBox'; | 7 | import { type BBox, parsePolygonBBox, parsePathBBox } from './parseBBox'; |
8 | 8 | ||
9 | const SVG_NS = 'http://www.w3.org/2000/svg'; | 9 | export const SVG_NS = 'http://www.w3.org/2000/svg'; |
10 | const XLINK_NS = 'http://www.w3.org/1999/xlink'; | 10 | export const XLINK_NS = 'http://www.w3.org/1999/xlink'; |
11 | 11 | ||
12 | function modifyAttribute(element: Element, attribute: string, change: number) { | 12 | function modifyAttribute(element: Element, attribute: string, change: number) { |
13 | const valueString = element.getAttribute(attribute); | 13 | const valueString = element.getAttribute(attribute); |