From 4c849d96bb4d8b6b4ed8b03a39838cbf04d7c5b3 Mon Sep 17 00:00:00 2001 From: izawartka <59137928+izawartka@users.noreply.github.com> Date: Sun, 17 Aug 2025 12:09:16 +0200 Subject: [PATCH 1/7] Added SVG export +Added basic SVG export option --- src/components/map/SceneryLayer.js | 1 + .../map/object-renderers/TrackRenderer.js | 9 +- src/components/sidemenu/ExportViewButton.js | 23 +++++ src/components/sidemenu/SideMenu.css | 3 +- src/components/sidemenu/SideMenu.js | 2 + src/helpers/exportHelper.js | 95 +++++++++++++++++++ src/model/scenery.js | 4 + 7 files changed, 133 insertions(+), 4 deletions(-) create mode 100644 src/components/sidemenu/ExportViewButton.js create mode 100644 src/helpers/exportHelper.js 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 }; } From 6a04b5feecdc1e3f8f566a531528a2ae0744d7b2 Mon Sep 17 00:00:00 2001 From: izawartka <59137928+izawartka@users.noreply.github.com> Date: Sun, 17 Aug 2025 17:49:13 +0200 Subject: [PATCH 2/7] Added export trimming *Objects in the exported SVG are now removed if they lay outside of the view --- src/components/map/Map.css | 6 +++++ src/helpers/exportHelper.js | 48 ++++++++++++++++++++++++++++++++++--- 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/src/components/map/Map.css b/src/components/map/Map.css index f442a23..19a3566 100644 --- a/src/components/map/Map.css +++ b/src/components/map/Map.css @@ -6,6 +6,12 @@ background-color: var(--map-background-color); } +.export-container { + position: relative; + width: 100%; + height: 100%; +} + .map svg path.track { stroke-width: 1.44px; fill: transparent; diff --git a/src/helpers/exportHelper.js b/src/helpers/exportHelper.js index 81b023b..688d81e 100644 --- a/src/helpers/exportHelper.js +++ b/src/helpers/exportHelper.js @@ -53,6 +53,44 @@ function processExportNode(svgNode, currentGroupStyles = {}) { } } +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.x + clientRect.width < trimClientRect.x) return false; + if (clientRect.x > trimClientRect.x + trimClientRect.width) return false; + if (clientRect.y + clientRect.height < trimClientRect.y) return false; + if (clientRect.y > trimClientRect.y + trimClientRect.height) return false; + if (clientRect.width === 0 || clientRect.height === 0) return false; + + return true; +} + +function removeNonVisibleObjects(svg) { + const sceneryRoot = svg.querySelector('g.scenery-root'); + if (!sceneryRoot) return; + + 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); + } + } +} + function serializeAndDownload(svgNode, filename) { const serializer = new XMLSerializer(); const svgString = serializer.serializeToString(svgNode); @@ -74,17 +112,21 @@ function exportSvg(filename) { const svgOrig = mapElement.querySelector('svg'); if (!svgOrig) return false; + const exportContainer = document.createElement('div'); + exportContainer.classList.add('export-container', 'light-mode'); + const svg = svgOrig.cloneNode(true); - mapElement.appendChild(svg); + mapElement.appendChild(exportContainer); + exportContainer.appendChild(svg); svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); svg.setAttribute('version', '1.1'); - svg.classList.add('light-mode'); processExportNode(svg); + removeNonVisibleObjects(svg); serializeAndDownload(svg, filename); - mapElement.removeChild(svg); + mapElement.removeChild(exportContainer); return true; } From cfe28689ff6cf08ce557688968421950d5ff8e95 Mon Sep 17 00:00:00 2001 From: izawartka <59137928+izawartka@users.noreply.github.com> Date: Sun, 17 Aug 2025 18:04:01 +0200 Subject: [PATCH 3/7] Added metadata export +Added basic metadata to the exported svg --- src/helpers/constants.js | 1 + src/helpers/exportHelper.js | 22 ++++++++++++++++++++-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/helpers/constants.js b/src/helpers/constants.js index 574dd57..54ed8f4 100644 --- a/src/helpers/constants.js +++ b/src/helpers/constants.js @@ -1,5 +1,6 @@ const Constants = { buildVersion: '1.6.2', + originalPublicBuildUrl: 'https://maseuko.pl/soft/td2-visualizer-2/', // do NOT change map: { zoomSensitivity: 0.002, zoomMin: 0.03, diff --git a/src/helpers/exportHelper.js b/src/helpers/exportHelper.js index 688d81e..7c01467 100644 --- a/src/helpers/exportHelper.js +++ b/src/helpers/exportHelper.js @@ -1,3 +1,5 @@ +import Constants from "./constants"; + const applyStyles = ['fill', 'stroke', 'stroke-width', 'font-size', 'font-family', 'text-anchor', 'dominant-baseline']; function processExportNode(svgNode, currentGroupStyles = {}) { @@ -105,6 +107,23 @@ function serializeAndDownload(svgNode, filename) { 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.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) return false; @@ -119,8 +138,7 @@ function exportSvg(filename) { mapElement.appendChild(exportContainer); exportContainer.appendChild(svg); - svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); - svg.setAttribute('version', '1.1'); + addMetadata(svg); processExportNode(svg); removeNonVisibleObjects(svg); From 97e1063cb5edded2f57ff38fe89174622925b9a6 Mon Sep 17 00:00:00 2001 From: izawartka <59137928+izawartka@users.noreply.github.com> Date: Sun, 17 Aug 2025 18:04:44 +0200 Subject: [PATCH 4/7] Fixed export visibility check *Fixed export visibility check (some tracks were falsely detected as invisible) --- src/helpers/exportHelper.js | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/helpers/exportHelper.js b/src/helpers/exportHelper.js index 7c01467..0eebcad 100644 --- a/src/helpers/exportHelper.js +++ b/src/helpers/exportHelper.js @@ -67,11 +67,10 @@ function checkNodeVisible(svgNode, trimClientRect) { } const clientRect = svgNode.getBoundingClientRect(); - if (clientRect.x + clientRect.width < trimClientRect.x) return false; - if (clientRect.x > trimClientRect.x + trimClientRect.width) return false; - if (clientRect.y + clientRect.height < trimClientRect.y) return false; - if (clientRect.y > trimClientRect.y + trimClientRect.height) return false; - if (clientRect.width === 0 || clientRect.height === 0) return false; + 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; } From 24ecffe63b4425eabaa97638933c0b465219ac3c Mon Sep 17 00:00:00 2001 From: izawartka <59137928+izawartka@users.noreply.github.com> Date: Sun, 17 Aug 2025 18:13:12 +0200 Subject: [PATCH 5/7] Improved export error handling *Improved svg export error handling --- src/helpers/exportHelper.js | 32 ++++++++++++++++++++++---------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/src/helpers/exportHelper.js b/src/helpers/exportHelper.js index 0eebcad..28397cf 100644 --- a/src/helpers/exportHelper.js +++ b/src/helpers/exportHelper.js @@ -77,11 +77,13 @@ function checkNodeVisible(svgNode, trimClientRect) { function removeNonVisibleObjects(svg) { const sceneryRoot = svg.querySelector('g.scenery-root'); - if (!sceneryRoot) return; + if (!sceneryRoot) { + throw new Error("Scenery root not found"); + } const trimClientRect = svg.getBoundingClientRect(); - for (const layer of sceneryRoot.children) { + for (const layer of sceneryRoot.children || []) { const objectsToDelete = Array.from(layer.children).filter(child => { return !checkNodeVisible(child, trimClientRect); }); @@ -90,6 +92,8 @@ function removeNonVisibleObjects(svg) { layer.removeChild(node); } } + + return true; } function serializeAndDownload(svgNode, filename) { @@ -125,10 +129,16 @@ function addMetadata(svg) { function exportSvg(filename) { const mapElement = document.getElementsByClassName('map')[0]; - if (!mapElement) return false; + if (!mapElement) { + console.error("Map element not found"); + return false; + } const svgOrig = mapElement.querySelector('svg'); - if (!svgOrig) return false; + if (!svgOrig) { + console.error("SVG root element not found"); + return false; + } const exportContainer = document.createElement('div'); exportContainer.classList.add('export-container', 'light-mode'); @@ -137,14 +147,16 @@ function exportSvg(filename) { mapElement.appendChild(exportContainer); exportContainer.appendChild(svg); - addMetadata(svg); - processExportNode(svg); - removeNonVisibleObjects(svg); - - serializeAndDownload(svg, filename); + try { + addMetadata(svg); + processExportNode(svg); + removeNonVisibleObjects(svg); + serializeAndDownload(svg, filename); + } catch (error) { + console.error(error); + } mapElement.removeChild(exportContainer); - return true; } const ExportHelper = { From 6f44d148ed99b8a9450aaa9056d77005cf4243bd Mon Sep 17 00:00:00 2001 From: izawartka <59137928+izawartka@users.noreply.github.com> Date: Mon, 18 Aug 2025 21:25:01 +0200 Subject: [PATCH 6/7] Distance meter export fix *Fixed background rect of the distance meter being exported --- src/components/map/distance-meter/DistanceMeter.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/map/distance-meter/DistanceMeter.js b/src/components/map/distance-meter/DistanceMeter.js index a7f8f60..a17d648 100644 --- a/src/components/map/distance-meter/DistanceMeter.js +++ b/src/components/map/distance-meter/DistanceMeter.js @@ -33,6 +33,7 @@ export default function DistanceMeter() { width={scenery.bounds.maxX - scenery.bounds.minX} height={scenery.bounds.maxZ - scenery.bounds.minZ} onClick={onClick} + no-export="true" /> From 9d2fef5e73b2f9216ec476b78e647edc43c2e79b Mon Sep 17 00:00:00 2001 From: izawartka <59137928+izawartka@users.noreply.github.com> Date: Mon, 18 Aug 2025 21:51:08 +0200 Subject: [PATCH 7/7] Added export animation +Added svg export animation *Moved Constants.originalPublicBuildUrl to Constants.svgExport.originalPublicBuildUrl --- src/components/map/Map.css | 16 ++++++++++++++++ src/components/sidemenu/ExportViewButton.js | 19 ++++++++++++++++--- src/helpers/constants.js | 6 +++++- src/helpers/exportHelper.js | 2 +- 4 files changed, 38 insertions(+), 5 deletions(-) diff --git a/src/components/map/Map.css b/src/components/map/Map.css index 19a3566..721e29a 100644 --- a/src/components/map/Map.css +++ b/src/components/map/Map.css @@ -6,6 +6,22 @@ background-color: var(--map-background-color); } +.map.export-border::after { + border: 5px solid var(--map-text-color); + content: ""; + position: absolute; + + width: 100%; + width: -moz-available; + width: -webkit-fill-available; + width: stretch; + + height: 100%; + height: -moz-available; + height: -webkit-fill-available; + height: stretch; +} + .export-container { position: relative; width: 100%; diff --git a/src/components/sidemenu/ExportViewButton.js b/src/components/sidemenu/ExportViewButton.js index 4de3556..a9ee8bc 100644 --- a/src/components/sidemenu/ExportViewButton.js +++ b/src/components/sidemenu/ExportViewButton.js @@ -1,14 +1,27 @@ -import { useContext } from "react"; +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 onButtonClick = () => { const filename = (scenery.getName() || 'export') + '.svg'; ExportHelper.exportSvg(filename); - }; + + setSideMenuOpen(true); + }, [scenery, setSideMenuOpen]); return (
diff --git a/src/helpers/constants.js b/src/helpers/constants.js index 54ed8f4..9fd877c 100644 --- a/src/helpers/constants.js +++ b/src/helpers/constants.js @@ -1,6 +1,5 @@ const Constants = { buildVersion: '1.6.2', - originalPublicBuildUrl: 'https://maseuko.pl/soft/td2-visualizer-2/', // do NOT change map: { zoomSensitivity: 0.002, zoomMin: 0.03, @@ -31,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 index 28397cf..3623371 100644 --- a/src/helpers/exportHelper.js +++ b/src/helpers/exportHelper.js @@ -116,7 +116,7 @@ function addMetadata(svg) { 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.originalPublicBuildUrl; + source.textContent = Constants.svgExport.originalPublicBuildUrl; rdf.appendChild(description); rdf.appendChild(source);