commit
7c7de43749
@ -6,6 +6,28 @@
|
|||||||
background-color: var(--map-background-color);
|
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 {
|
.map svg path.track {
|
||||||
stroke-width: 1.44px;
|
stroke-width: 1.44px;
|
||||||
fill: transparent;
|
fill: transparent;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -33,6 +33,7 @@ export default function DistanceMeter() {
|
|||||||
width={scenery.bounds.maxX - scenery.bounds.minX}
|
width={scenery.bounds.maxX - scenery.bounds.minX}
|
||||||
height={scenery.bounds.maxZ - scenery.bounds.minZ}
|
height={scenery.bounds.maxZ - scenery.bounds.minZ}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
no-export="true"
|
||||||
/>
|
/>
|
||||||
<DistanceMeterView distancePoints={distancePoints} setDistancePoints={setDistancePoints} />
|
<DistanceMeterView distancePoints={distancePoints} setDistancePoints={setDistancePoints} />
|
||||||
</g>
|
</g>
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
36
src/components/sidemenu/ExportViewButton.js
Normal file
36
src/components/sidemenu/ExportViewButton.js
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 />
|
||||||
|
|||||||
@ -30,6 +30,11 @@ const Constants = {
|
|||||||
skipPlatforms: false,
|
skipPlatforms: false,
|
||||||
resolveElectrificationStepMode: false,
|
resolveElectrificationStepMode: false,
|
||||||
},
|
},
|
||||||
|
svgExport: {
|
||||||
|
hidePanelDuration: 500,
|
||||||
|
borderDuration: 100,
|
||||||
|
originalPublicBuildUrl: 'https://maseuko.pl/soft/td2-visualizer-2/', // do NOT change
|
||||||
|
},
|
||||||
warnings: {
|
warnings: {
|
||||||
all: false, // enable all warnings
|
all: false, // enable all warnings
|
||||||
trackObjectCannotBeApplied: false,
|
trackObjectCannotBeApplied: false,
|
||||||
|
|||||||
166
src/helpers/exportHelper.js
Normal file
166
src/helpers/exportHelper.js
Normal 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;
|
||||||
@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user