Merge pull request #11 from izawartka/svg-export

Svg export
This commit is contained in:
Igor Zawartka 2025-08-18 21:52:39 +02:00 committed by GitHub
commit 7c7de43749
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 245 additions and 4 deletions

View File

@ -6,6 +6,28 @@
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%;
height: 100%;
}
.map svg path.track {
stroke-width: 1.44px;
fill: transparent;

View File

@ -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;

View File

@ -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"
/>
<DistanceMeterView distancePoints={distancePoints} setDistancePoints={setDistancePoints} />
</g>

View File

@ -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"
/>
<path
ref={scaledPathRef}
@ -95,7 +96,9 @@ function StatelessTrackRenderer(props) {
className="track"
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
onClick={onClick}/>
onClick={onClick}
export-force-visible="true"
/>
</g>
);
}

View File

@ -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 (
<div className="export-view-button">
<button
onClick={onButtonClick}
disabled={!scenery}
>
Export current view
</button>
</div>
);
}

View File

@ -66,7 +66,8 @@
}
.distance-meter-button button,
.scenery-info-button button {
.scenery-info-button button,
.export-view-button button {
width: 100%;
}

View File

@ -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() {
<div className={sideMenuClass}>
<SceneryInfoButton />
<DistanceMeterButton />
<ExportViewButton />
<LayersMenu />
<LayerOptionsMenu />
<InfoFooter />

View File

@ -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,

166
src/helpers/exportHelper.js Normal file
View File

@ -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;

View File

@ -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 };
}