diff --git a/src/components/map/SceneryLayer.js b/src/components/map/SceneryLayer.js index a6147fc..c42f2f7 100644 --- a/src/components/map/SceneryLayer.js +++ b/src/components/map/SceneryLayer.js @@ -85,6 +85,7 @@ function StatelessSceneryLayer({ name, Renderer, objects, type, types, trackSour ref={layerRef} className={`scenery-layer-${name}`} pointerEvents={pointerEvents ? "all" : "none"} + export-force-visible="true" > {objects.map((obj) => { if (type !== undefined && type !== obj.type) return null; diff --git a/src/components/map/object-renderers/TrackRenderer.js b/src/components/map/object-renderers/TrackRenderer.js index 3e23eb8..6c1fbc3 100644 --- a/src/components/map/object-renderers/TrackRenderer.js +++ b/src/components/map/object-renderers/TrackRenderer.js @@ -56,9 +56,9 @@ function StatelessTrackRenderer(props) { if(details) { unscaledPathRef.current.style.display = 'none'; - scaledPathRef.current.style.display = 'block'; + scaledPathRef.current.style.display = ''; } else { - unscaledPathRef.current.style.display = 'block'; + unscaledPathRef.current.style.display = ''; scaledPathRef.current.style.display = 'none'; } }, []); @@ -87,6 +87,7 @@ function StatelessTrackRenderer(props) { d={path} stroke={color} className="track-unscaled" + no-export="true" /> + onClick={onClick} + export-force-visible="true" + /> ); } diff --git a/src/components/sidemenu/ExportViewButton.js b/src/components/sidemenu/ExportViewButton.js new file mode 100644 index 0000000..4de3556 --- /dev/null +++ b/src/components/sidemenu/ExportViewButton.js @@ -0,0 +1,23 @@ +import { useContext } from "react"; +import SceneryContext from "../../contexts/SceneryContext"; +import ExportHelper from "../../helpers/exportHelper"; + +export default function ExportViewButton() { + const { scenery } = useContext(SceneryContext); + + const onButtonClick = () => { + const filename = (scenery.getName() || 'export') + '.svg'; + ExportHelper.exportSvg(filename); + }; + + return ( +
+ +
+ ); +} diff --git a/src/components/sidemenu/SideMenu.css b/src/components/sidemenu/SideMenu.css index 446b8a0..1ed3ba8 100644 --- a/src/components/sidemenu/SideMenu.css +++ b/src/components/sidemenu/SideMenu.css @@ -66,7 +66,8 @@ } .distance-meter-button button, -.scenery-info-button button { +.scenery-info-button button, +.export-view-button button { width: 100%; } diff --git a/src/components/sidemenu/SideMenu.js b/src/components/sidemenu/SideMenu.js index f07d4e8..5b2a3b2 100644 --- a/src/components/sidemenu/SideMenu.js +++ b/src/components/sidemenu/SideMenu.js @@ -6,6 +6,7 @@ import DistanceMeterButton from './DistanceMeterButton'; import InfoFooter from './InfoFooter'; import LayerOptionsMenu from './LayerOptionsMenu'; import SideMenuContext from '../../contexts/SideMenuContext'; +import ExportViewButton from './ExportViewButton'; export default function SideMenu() { const { sideMenuOpen } = useContext(SideMenuContext); @@ -17,6 +18,7 @@ export default function SideMenu() {
+ diff --git a/src/helpers/exportHelper.js b/src/helpers/exportHelper.js new file mode 100644 index 0000000..81b023b --- /dev/null +++ b/src/helpers/exportHelper.js @@ -0,0 +1,95 @@ +const applyStyles = ['fill', 'stroke', 'stroke-width', 'font-size', 'font-family', 'text-anchor', 'dominant-baseline']; + +function processExportNode(svgNode, currentGroupStyles = {}) { + if (!svgNode) return; + + // apply export-force-visible attribute + if(svgNode.hasAttribute('export-force-visible')) { + svgNode.removeAttribute('export-force-visible'); + + if (svgNode.style.display === 'none') { + svgNode.style.display = ''; + } + } + + // remove empty style attribute + if (svgNode.hasAttribute('style')) { + const style = svgNode.getAttribute('style'); + if (!style || style.trim() === '') { + svgNode.removeAttribute('style'); + } + } + + // apply styles + const computedStyles = getComputedStyle(svgNode); + const newGroupStyles = { ...currentGroupStyles }; + for (const style of applyStyles) { + if (computedStyles[style] && currentGroupStyles[style] !== computedStyles[style]) { + svgNode.setAttribute(style, computedStyles[style]); + newGroupStyles[style] = computedStyles[style]; + } + } + + svgNode.removeAttribute('pointer-events'); + + if (svgNode.nodeType !== Node.ELEMENT_NODE) return; + if (svgNode.children && svgNode.children.length > 0) { + const nodesToRemove = []; + + // process children recursively + for (const child of svgNode.children) { + if (child.hasAttribute('no-export')) { + nodesToRemove.push(child); + continue; + } + + processExportNode(child, newGroupStyles); + } + + // remove nodes with no-export attribute + for (const node of nodesToRemove) { + svgNode.removeChild(node); + } + } +} + +function serializeAndDownload(svgNode, filename) { + const serializer = new XMLSerializer(); + const svgString = serializer.serializeToString(svgNode); + const blob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); +} + +function exportSvg(filename) { + const mapElement = document.getElementsByClassName('map')[0]; + if (!mapElement) return false; + + const svgOrig = mapElement.querySelector('svg'); + if (!svgOrig) return false; + + const svg = svgOrig.cloneNode(true); + mapElement.appendChild(svg); + + svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); + svg.setAttribute('version', '1.1'); + svg.classList.add('light-mode'); + processExportNode(svg); + + serializeAndDownload(svg, filename); + + mapElement.removeChild(svg); + return true; +} + +const ExportHelper = { + exportSvg +}; + +export default ExportHelper; diff --git a/src/model/scenery.js b/src/model/scenery.js index 392be7f..1d1d321 100644 --- a/src/model/scenery.js +++ b/src/model/scenery.js @@ -9,6 +9,10 @@ export default class Scenery trackElevationBounds = { minY: Infinity, maxY: -Infinity }; electrificationResolved = ElectrificationResolutionStatus.NOT_RESOLVED; + getName() { + return this.objects.special?.SceneryInfo?.name || null; + } + getBounds () { return { ...this.bounds }; }