diff --git a/package-lock.json b/package-lock.json index c9afffcb..38b02177 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4468,6 +4468,7 @@ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.0.2.tgz", "integrity": "sha512-tmyFgixPZCx2+e6VO9TNITWcCQl8+Nl/E8YbAyPVv85QCc7/A3JrdfG2A8gIzvVhWuzMOVrFW1aReaNxrI6tbw==", "dev": true, + "license": "MIT", "dependencies": { "@babel/core": "^7.28.3", "@babel/plugin-transform-react-jsx-self": "^7.27.1", @@ -9711,7 +9712,6 @@ "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "license": "MIT", "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" @@ -10205,7 +10205,6 @@ "version": "7.1.5", "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.5.tgz", "integrity": "sha512-4cKBO9wR75r0BeIWWWId9XK9Lj6La5X846Zw9dFfzMRw38IlTk2iCcUt6hsyiDRcPidc55ZParFYDXi0nXOeLQ==", - "license": "MIT", "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", diff --git a/src/components/page-header/download-actions.tsx b/src/components/page-header/download-actions.tsx index 0b9c94fb..f2ac4ea8 100644 --- a/src/components/page-header/download-actions.tsx +++ b/src/components/page-header/download-actions.tsx @@ -1,4 +1,9 @@ import { + Accordion, + AccordionButton, + AccordionIcon, + AccordionItem, + AccordionPanel, Alert, AlertDescription, AlertIcon, @@ -7,9 +12,12 @@ import { Box, Button, Checkbox, + FormControl, + FormLabel, HStack, Icon, IconButton, + Input, Link, Menu, MenuButton, @@ -31,7 +39,7 @@ import canvasSize from 'canvas-size'; import React from 'react'; import { Trans, useTranslation } from 'react-i18next'; import { MdDownload, MdImage, MdOpenInNew, MdSave, MdSaveAs } from 'react-icons/md'; -import { Events } from '../../constants/constants'; +import { Events, MiscNodeId, StnId } from '../../constants/constants'; import { isTauri } from '../../constants/server'; import { useRootDispatch, useRootSelector } from '../../redux'; import { setGlobalAlert } from '../../redux/runtime/runtime-slice'; @@ -57,6 +65,35 @@ const getTauriUrl = () => { return baseUrl + `/tauri-${tag}/Rail.Map.Toolkit_${ver}_${suffix}`; }; +const getNodeOptions = () => { + const options: { [k: string]: string } = { '': '' }; // Default empty option + + if (window.graph) { + window.graph.forEachNode(nodeId => { + try { + const nodeAttrs = window.graph.getNodeAttributes(nodeId); + const nodeType = nodeAttrs.type; + + // Try to get the display name from the node's attributes + let displayName = nodeId; // fallback to ID + if (nodeType && nodeAttrs[nodeType] && (nodeAttrs[nodeType] as any).names) { + const names = (nodeAttrs[nodeType] as any).names; + if (Array.isArray(names) && names.length > 0) { + displayName = names[0] || names[1] || nodeId; // Use first non-empty name + } + } + + options[nodeId] = `${displayName} (${nodeId})`; + } catch (error) { + // If we can't get the name, just use the ID + options[nodeId] = nodeId; + } + }); + } + + return options; +}; + export default function DownloadActions() { const bgColor = useColorModeValue('white', 'var(--chakra-colors-gray-800)'); const dispatch = useRootDispatch(); @@ -84,6 +121,39 @@ export default function DownloadActions() { const scaleOptions: { [k: number]: string } = Object.fromEntries(scales.map(v => [v, `${v}%`])); const [resvgScaleOptions, setResvgScaleOptions] = React.useState([]); const [isTransparent, setIsTransparent] = React.useState(false); + + // Advanced mode state variables + const [selectedNodeId, setSelectedNodeId] = React.useState(''); + const [distance, setDistance] = React.useState({ + left: 50, + right: 50, + top: 50, + bottom: 50, + }); + const [padding, setPadding] = React.useState({ + left: 0, + right: 0, + top: 0, + bottom: 0, + }); + const [fourCornersPosition, setFourCornersPosition] = React.useState({ + topLeftX: 0, + topLeftY: 0, + topRightX: 0, + topRightY: 0, + bottomLeftX: 0, + bottomLeftY: 0, + bottomRightX: 0, + bottomRightY: 0, + }); + const [isDownloadModalOpen, setIsDownloadModalOpen] = React.useState(false); + const [isTermsAndConditionsModalOpen, setIsTermsAndConditionsModalOpen] = React.useState(false); + const [isSystemFontsOnly, setIsSystemFontsOnly] = React.useState(false); + const [isAttachSelected, setIsAttachSelected] = React.useState(false); + const [isTermsAndConditionsSelected, setIsTermsAndConditionsSelected] = React.useState(false); + const [isDownloadRunning, setIsDownloadRunning] = React.useState(false); + const [isToRmgOpen, setIsToRmgOpen] = React.useState(false); + const fields: RmgFieldsField[] = [ { type: 'select', @@ -120,13 +190,211 @@ export default function DownloadActions() { onChange: setIsTransparent, }, ]; - const [isDownloadModalOpen, setIsDownloadModalOpen] = React.useState(false); - const [isTermsAndConditionsModalOpen, setIsTermsAndConditionsModalOpen] = React.useState(false); - const [isSystemFontsOnly, setIsSystemFontsOnly] = React.useState(false); - const [isAttachSelected, setIsAttachSelected] = React.useState(false); - const [isTermsAndConditionsSelected, setIsTermsAndConditionsSelected] = React.useState(false); - const [isDownloadRunning, setIsDownloadRunning] = React.useState(false); - const [isToRmgOpen, setIsToRmgOpen] = React.useState(false); + + // Advanced mode fields - distance fields + const nodeOptions = React.useMemo(() => getNodeOptions(), [isDownloadModalOpen]); + const distanceFields: RmgFieldsField[] = [ + { + type: 'input', + label: t('header.download.leftDistance'), + value: selectedNodeId ? distance.left.toString() : '', + variant: 'number', + isDisabled: !selectedNodeId, + onChange: value => setDistance({ ...distance, left: Number(value) || 0 }), + }, + { + type: 'input', + label: t('header.download.rightDistance'), + value: selectedNodeId ? distance.right.toString() : '', + variant: 'number', + isDisabled: !selectedNodeId, + onChange: value => setDistance({ ...distance, right: Number(value) || 0 }), + }, + { + type: 'input', + label: t('header.download.topDistance'), + value: selectedNodeId ? distance.top.toString() : '', + variant: 'number', + isDisabled: !selectedNodeId, + onChange: value => setDistance({ ...distance, top: Number(value) || 0 }), + }, + { + type: 'input', + label: t('header.download.bottomDistance'), + value: selectedNodeId ? distance.bottom.toString() : '', + variant: 'number', + isDisabled: !selectedNodeId, + onChange: value => setDistance({ ...distance, bottom: Number(value) || 0 }), + }, + ]; + + // Advanced mode fields - padding fields + const paddingFields: RmgFieldsField[] = [ + { + type: 'input', + label: t('header.download.paddingLeft'), + value: padding.left.toString(), + variant: 'number', + onChange: value => setPadding({ ...padding, left: Number(value) || 0 }), + }, + { + type: 'input', + label: t('header.download.paddingRight'), + value: padding.right.toString(), + variant: 'number', + onChange: value => setPadding({ ...padding, right: Number(value) || 0 }), + }, + { + type: 'input', + label: t('header.download.paddingTop'), + value: padding.top.toString(), + variant: 'number', + onChange: value => setPadding({ ...padding, top: Number(value) || 0 }), + }, + { + type: 'input', + label: t('header.download.paddingBottom'), + value: padding.bottom.toString(), + variant: 'number', + onChange: value => setPadding({ ...padding, bottom: Number(value) || 0 }), + }, + ]; + + const positionFields: RmgFieldsField[] = [ + { + type: 'input', + label: 'Top left X', + value: fourCornersPosition.topLeftX.toString(), + variant: 'number', + onChange: value => setFourCornersPosition({ ...fourCornersPosition, topLeftX: Number(value) || 0 }), + }, + { + type: 'input', + label: 'Top left Y', + value: fourCornersPosition.topLeftY.toString(), + variant: 'number', + onChange: value => setFourCornersPosition({ ...fourCornersPosition, topLeftY: Number(value) || 0 }), + }, + { + type: 'input', + label: 'Top right X', + value: fourCornersPosition.topRightX.toString(), + variant: 'number', + onChange: value => setFourCornersPosition({ ...fourCornersPosition, topRightX: Number(value) || 0 }), + }, + { + type: 'input', + label: 'Top right Y', + value: fourCornersPosition.topRightY.toString(), + variant: 'number', + onChange: value => setFourCornersPosition({ ...fourCornersPosition, topRightY: Number(value) || 0 }), + }, + { + type: 'input', + label: 'Bottom left X', + value: fourCornersPosition.bottomLeftX.toString(), + variant: 'number', + onChange: value => setFourCornersPosition({ ...fourCornersPosition, bottomLeftX: Number(value) || 0 }), + }, + { + type: 'input', + label: 'Bottom left Y', + value: fourCornersPosition.bottomLeftY.toString(), + variant: 'number', + onChange: value => setFourCornersPosition({ ...fourCornersPosition, bottomLeftY: Number(value) || 0 }), + }, + { + type: 'input', + label: 'Bottom right X', + value: fourCornersPosition.bottomRightX.toString(), + variant: 'number', + onChange: value => setFourCornersPosition({ ...fourCornersPosition, bottomRightX: Number(value) || 0 }), + }, + { + type: 'input', + label: 'Bottom right Y', + value: fourCornersPosition.bottomRightY.toString(), + variant: 'number', + onChange: value => setFourCornersPosition({ ...fourCornersPosition, bottomRightY: Number(value) || 0 }), + }, + ]; + + // Initialize position controls with current dimensions and reset advanced properties when modal opens + React.useEffect(() => { + if (isDownloadModalOpen && graph.current) { + const { xMin, yMin, xMax, yMax } = calculateCanvasSize(graph.current); + + // Reset advanced properties to default values when modal opens + setSelectedNodeId(''); + setDistance({ left: 50, right: 50, top: 50, bottom: 50 }); + setPadding({ left: 0, right: 0, top: 0, bottom: 0 }); + + // Initialize position controls with current canvas dimensions + setFourCornersPosition({ + topLeftX: xMin, + topLeftY: yMin, + topRightX: xMax, + topRightY: yMin, + bottomLeftX: xMin, + bottomLeftY: yMax, + bottomRightX: xMax, + bottomRightY: yMax, + }); + } + }, [isDownloadModalOpen]); + + // Update position controls when advanced properties change + React.useEffect(() => { + if (!graph.current) return; + + let xMin, yMin, xMax, yMax; + + if (selectedNodeId && graph.current.hasNode(selectedNodeId)) { + // Center around selected node + const nodeX = graph.current.getNodeAttribute(selectedNodeId, 'x'); + const nodeY = graph.current.getNodeAttribute(selectedNodeId, 'y'); + + xMin = nodeX - distance.left; + yMin = nodeY - distance.top; + xMax = nodeX + distance.right; + yMax = nodeY + distance.bottom; + } else { + // Use current canvas dimensions + const canvasSize = calculateCanvasSize(graph.current); + xMin = canvasSize.xMin; + yMin = canvasSize.yMin; + xMax = canvasSize.xMax; + yMax = canvasSize.yMax; + } + + // Apply padding + xMin -= padding.left; + yMin -= padding.top; + xMax += padding.right; + yMax += padding.bottom; + + // Update position controls + setFourCornersPosition({ + topLeftX: xMin, + topLeftY: yMin, + topRightX: xMax, + topRightY: yMin, + bottomLeftX: xMin, + bottomLeftY: yMax, + bottomRightX: xMax, + bottomRightY: yMax, + }); + }, [ + selectedNodeId, + distance.left, + distance.right, + distance.top, + distance.bottom, + padding.left, + padding.right, + padding.top, + padding.bottom, + ]); // calculate the max canvas area the current browser can support React.useEffect(() => { @@ -142,14 +410,19 @@ export default function DownloadActions() { // disable some scale options that are too big for the current browser to generate React.useEffect(() => { if (isDownloadModalOpen) { - const { xMin, yMin, xMax, yMax } = calculateCanvasSize(graph.current); + // Recalculate canvas size using current position controls + const xMin = Math.min(fourCornersPosition.topLeftX, fourCornersPosition.bottomLeftX); + const yMin = Math.min(fourCornersPosition.topLeftY, fourCornersPosition.topRightY); + const xMax = Math.max(fourCornersPosition.topRightX, fourCornersPosition.bottomRightX); + const yMax = Math.max(fourCornersPosition.bottomLeftY, fourCornersPosition.bottomRightY); + const [width, height] = [xMax - xMin, yMax - yMin]; const disabledScales = scales.filter( scale => (width * scale) / 100 > maxArea.width && (height * scale) / 100 > maxArea.height ); setResvgScaleOptions(disabledScales); } - }, [isDownloadModalOpen]); + }, [isDownloadModalOpen, fourCornersPosition, maxArea]); const handleDownloadJson = async () => { if (isAllowAppTelemetry) @@ -180,12 +453,20 @@ export default function DownloadActions() { isAllowProjectTelemetry ? { numberOfNodes: graph.current.order, numberOfEdges: graph.current.size } : {} ); + const customViewBox = { + xMin: Math.min(fourCornersPosition.topLeftX, fourCornersPosition.bottomLeftX), + yMin: Math.min(fourCornersPosition.topLeftY, fourCornersPosition.topRightY), + xMax: Math.max(fourCornersPosition.topRightX, fourCornersPosition.bottomRightX), + yMax: Math.max(fourCornersPosition.bottomLeftY, fourCornersPosition.bottomRightY), + }; + const { elem, width, height } = await makeRenderReadySVGElement( graph.current, isAttachSelected, isSystemFontsOnly, languages, - svgVersion + svgVersion, + customViewBox ); // white spaces will be converted to   and will fail the canvas render process // in fact other named characters might also break such as `& -> &`, let's fix if someone reports @@ -278,6 +559,88 @@ export default function DownloadActions() { {format === 'svg' && } {format === 'png' && } + + + + + + {t('header.download.advancedOptions')} + + + + + + + + + {t('header.download.width')} + + + {Math.abs( + fourCornersPosition.topRightX - fourCornersPosition.topLeftX + ).toFixed(1)} + + + + + {t('header.download.height')} + + + {Math.abs( + fourCornersPosition.bottomLeftY - fourCornersPosition.topLeftY + ).toFixed(1)} + + + + + {t('header.download.ratio')} + + + {(() => { + const width = Math.abs(fourCornersPosition.topRightX - fourCornersPosition.topLeftX); + const height = Math.abs(fourCornersPosition.bottomLeftY - fourCornersPosition.topLeftY); + const gcd = (a: number, b: number): number => b === 0 ? a : gcd(b, a % b); + const ratio1 = Math.round(width * 100); + const ratio2 = Math.round(height * 100); + const divisor = gcd(ratio1, ratio2); + return `${ratio1 / divisor}:${ratio2 / divisor}`; + })()} + + + + + + {t('header.download.centerNode')} + setSelectedNodeId(e.target.value)} + placeholder={t('header.download.centerNodePlaceholder')} + /> + + {Object.entries(nodeOptions).map(([value, label]) => ( + + ))} + + + + + + + + + + + {t('header.download.positionControls')} + + + + + + +
{ // get the minimum and maximum of the graph - const { xMin, yMin, xMax, yMax } = calculateCanvasSize(graph); + const canvasSize = calculateCanvasSize(graph); + const { xMin, yMin, xMax, yMax } = customViewBox || canvasSize; const [width, height] = [xMax - xMin, yMax - yMin]; const elem = document.getElementById('canvas')!.cloneNode(true) as SVGSVGElement; // append rmp info if user does not want to share rmp info - if (!generateRMPInfo) elem.appendChild(await generateRmpInfo(xMax - 400, yMax - 120)); + if (!generateRMPInfo) { + // Use original canvas size for RMP info positioning when using custom viewbox + const originalSize = customViewBox ? canvasSize : { xMax, yMax }; + elem.appendChild(await generateRmpInfo(originalSize.xMax - 400, originalSize.yMax - 120)); + } // reset svg viewBox to display all the nodes in the graph // otherwise the later drawImage won't be able to show all of them elem.setAttribute('viewBox', `${xMin} ${yMin} ${width} ${height}`);