Added SVG export

+Added basic SVG export option
This commit is contained in:
izawartka 2025-08-17 12:09:16 +02:00
parent e80a28e9c0
commit 4c849d96bb
7 changed files with 133 additions and 4 deletions

View File

@ -85,6 +85,7 @@ function StatelessSceneryLayer({ name, Renderer, objects, type, types, trackSour
ref={layerRef} ref={layerRef}
className={`scenery-layer-${name}`} className={`scenery-layer-${name}`}
pointerEvents={pointerEvents ? "all" : "none"} pointerEvents={pointerEvents ? "all" : "none"}
export-force-visible="true"
> >
{objects.map((obj) => { {objects.map((obj) => {
if (type !== undefined && type !== obj.type) return null; if (type !== undefined && type !== obj.type) return null;

View File

@ -56,9 +56,9 @@ function StatelessTrackRenderer(props) {
if(details) { if(details) {
unscaledPathRef.current.style.display = 'none'; unscaledPathRef.current.style.display = 'none';
scaledPathRef.current.style.display = 'block'; scaledPathRef.current.style.display = '';
} else { } else {
unscaledPathRef.current.style.display = 'block'; unscaledPathRef.current.style.display = '';
scaledPathRef.current.style.display = 'none'; scaledPathRef.current.style.display = 'none';
} }
}, []); }, []);
@ -87,6 +87,7 @@ function StatelessTrackRenderer(props) {
d={path} d={path}
stroke={color} stroke={color}
className="track-unscaled" className="track-unscaled"
no-export="true"
/> />
<path <path
ref={scaledPathRef} ref={scaledPathRef}
@ -95,7 +96,9 @@ function StatelessTrackRenderer(props) {
className="track" className="track"
onMouseEnter={onMouseEnter} onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave} onMouseLeave={onMouseLeave}
onClick={onClick}/> onClick={onClick}
export-force-visible="true"
/>
</g> </g>
); );
} }

View File

@ -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 (
<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, .distance-meter-button button,
.scenery-info-button button { .scenery-info-button button,
.export-view-button button {
width: 100%; width: 100%;
} }

View File

@ -6,6 +6,7 @@ import DistanceMeterButton from './DistanceMeterButton';
import InfoFooter from './InfoFooter'; import InfoFooter from './InfoFooter';
import LayerOptionsMenu from './LayerOptionsMenu'; import LayerOptionsMenu from './LayerOptionsMenu';
import SideMenuContext from '../../contexts/SideMenuContext'; import SideMenuContext from '../../contexts/SideMenuContext';
import ExportViewButton from './ExportViewButton';
export default function SideMenu() { export default function SideMenu() {
const { sideMenuOpen } = useContext(SideMenuContext); const { sideMenuOpen } = useContext(SideMenuContext);
@ -17,6 +18,7 @@ export default function SideMenu() {
<div className={sideMenuClass}> <div className={sideMenuClass}>
<SceneryInfoButton /> <SceneryInfoButton />
<DistanceMeterButton /> <DistanceMeterButton />
<ExportViewButton />
<LayersMenu /> <LayersMenu />
<LayerOptionsMenu /> <LayerOptionsMenu />
<InfoFooter /> <InfoFooter />

View File

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

View File

@ -9,6 +9,10 @@ export default class Scenery
trackElevationBounds = { minY: Infinity, maxY: -Infinity }; trackElevationBounds = { minY: Infinity, maxY: -Infinity };
electrificationResolved = ElectrificationResolutionStatus.NOT_RESOLVED; electrificationResolved = ElectrificationResolutionStatus.NOT_RESOLVED;
getName() {
return this.objects.special?.SceneryInfo?.name || null;
}
getBounds () { getBounds () {
return { ...this.bounds }; return { ...this.bounds };
} }