+ 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..a9ee8bc
--- /dev/null
+++ b/src/components/sidemenu/ExportViewButton.js
@@ -0,0 +1,36 @@
+import { useCallback, useContext } from "react";
+import SceneryContext from "../../contexts/SceneryContext";
+import SideMenuContext from "../../contexts/SideMenuContext";
+import ExportHelper from "../../helpers/exportHelper";
+import Constants from "../../helpers/constants";
+
+export default function ExportViewButton() {
+ const { scenery } = useContext(SceneryContext);
+ const { setSideMenuOpen } = useContext(SideMenuContext);
+
+ const onButtonClick = useCallback(async () => {
+ const mapElement = document.querySelector('.map');
+
+ setSideMenuOpen(false);
+ await new Promise(resolve => setTimeout(resolve, Constants.svgExport.hidePanelDuration));
+ mapElement.classList.add('export-border');
+ await new Promise(resolve => setTimeout(resolve, Constants.svgExport.borderDuration));
+ mapElement.classList.remove('export-border');
+
+ const filename = (scenery.getName() || 'export') + '.svg';
+ ExportHelper.exportSvg(filename);
+
+ setSideMenuOpen(true);
+ }, [scenery, setSideMenuOpen]);
+
+ 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/constants.js b/src/helpers/constants.js
index 574dd57..9fd877c 100644
--- a/src/helpers/constants.js
+++ b/src/helpers/constants.js
@@ -30,6 +30,11 @@ const Constants = {
skipPlatforms: false,
resolveElectrificationStepMode: false,
},
+ svgExport: {
+ hidePanelDuration: 500,
+ borderDuration: 100,
+ originalPublicBuildUrl: 'https://maseuko.pl/soft/td2-visualizer-2/', // do NOT change
+ },
warnings: {
all: false, // enable all warnings
trackObjectCannotBeApplied: false,
diff --git a/src/helpers/exportHelper.js b/src/helpers/exportHelper.js
new file mode 100644
index 0000000..3623371
--- /dev/null
+++ b/src/helpers/exportHelper.js
@@ -0,0 +1,166 @@
+import Constants from "./constants";
+
+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 checkNodeVisible(svgNode, trimClientRect) {
+ if(!svgNode) return false;
+
+ if (svgNode.children?.length > 0) {
+ const visibleNodes = Array.from(svgNode.children).filter(child => {
+ return checkNodeVisible(child, trimClientRect);
+ });
+
+ return visibleNodes.length > 0;
+ }
+
+ const clientRect = svgNode.getBoundingClientRect();
+ if (clientRect.left + clientRect.width < trimClientRect.left) return false;
+ if (clientRect.left > trimClientRect.left + trimClientRect.width) return false;
+ if (clientRect.top + clientRect.height < trimClientRect.top) return false;
+ if (clientRect.top > trimClientRect.top + trimClientRect.height) return false;
+
+ return true;
+}
+
+function removeNonVisibleObjects(svg) {
+ const sceneryRoot = svg.querySelector('g.scenery-root');
+ if (!sceneryRoot) {
+ throw new Error("Scenery root not found");
+ }
+
+ const trimClientRect = svg.getBoundingClientRect();
+
+ for (const layer of sceneryRoot.children || []) {
+ const objectsToDelete = Array.from(layer.children).filter(child => {
+ return !checkNodeVisible(child, trimClientRect);
+ });
+
+ for (const node of objectsToDelete) {
+ layer.removeChild(node);
+ }
+ }
+
+ return true;
+}
+
+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 addMetadata(svg) {
+ const metadata = document.createElementNS('http://www.w3.org/2000/svg', 'metadata');
+ const rdf = document.createElementNS('http://www.w3.org/1999/02/22-rdf-syntax-ns#', 'rdf:RDF');
+ const description = document.createElementNS('http://purl.org/dc/elements/1.1/', 'dc:description');
+ description.textContent = `Exported using TD2 Visualizer v${Constants.buildVersion}`;
+ const source = document.createElementNS('http://purl.org/dc/elements/1.1/', 'dc:source');
+ source.textContent = Constants.svgExport.originalPublicBuildUrl;
+
+ rdf.appendChild(description);
+ rdf.appendChild(source);
+ metadata.appendChild(rdf);
+ svg.appendChild(metadata);
+
+ svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
+ svg.setAttribute('version', '1.1');
+}
+
+function exportSvg(filename) {
+ const mapElement = document.getElementsByClassName('map')[0];
+ if (!mapElement) {
+ console.error("Map element not found");
+ return false;
+ }
+
+ const svgOrig = mapElement.querySelector('svg');
+ if (!svgOrig) {
+ console.error("SVG root element not found");
+ return false;
+ }
+
+ const exportContainer = document.createElement('div');
+ exportContainer.classList.add('export-container', 'light-mode');
+
+ const svg = svgOrig.cloneNode(true);
+ mapElement.appendChild(exportContainer);
+ exportContainer.appendChild(svg);
+
+ try {
+ addMetadata(svg);
+ processExportNode(svg);
+ removeNonVisibleObjects(svg);
+ serializeAndDownload(svg, filename);
+ } catch (error) {
+ console.error(error);
+ }
+
+ mapElement.removeChild(exportContainer);
+}
+
+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 };
}