commit
7c7de43749
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
.scenery-info-button button {
|
||||
.scenery-info-button button,
|
||||
.export-view-button button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
||||
@ -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 />
|
||||
|
||||
@ -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
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 };
|
||||
electrificationResolved = ElectrificationResolutionStatus.NOT_RESOLVED;
|
||||
|
||||
getName() {
|
||||
return this.objects.special?.SceneryInfo?.name || null;
|
||||
}
|
||||
|
||||
getBounds () {
|
||||
return { ...this.bounds };
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user