diff --git a/src/components/ag-grid/station-ag-grid.tsx b/src/components/ag-grid/station-ag-grid.tsx
index 4c31dcc4c..414020085 100644
--- a/src/components/ag-grid/station-ag-grid.tsx
+++ b/src/components/ag-grid/station-ag-grid.tsx
@@ -13,7 +13,7 @@ import {
import { RmgStyle, SidePanelMode, StationInfo } from '../../constants/constants';
import { useTranslation } from 'react-i18next';
import { HStack } from '@chakra-ui/react';
-import { setIsShareTrackEnabled, setSelectedStation, setSidePanelMode } from '../../redux/app/app-slice';
+import { setIsShareTrackEnabled, setSelectedStations, setSidePanelMode } from '../../redux/app/app-slice';
import { getRowSpanForColine } from '../../redux/param/coline-action';
import GzmtrStationCode from './gzmtr-station-code';
import { MonoColour } from '@railmapgen/rmg-palette-resources';
@@ -31,7 +31,7 @@ interface StationAgGridProps {
type RowDataType = StationInfo & { id: string; rowSpan: [number, string | undefined] };
const rowSelection: RowSelectionOptions = {
- mode: 'singleRow',
+ mode: 'multiRow',
checkboxes: false,
enableClickSelection: true,
};
@@ -168,7 +168,7 @@ export default function StationAgGrid(props: StationAgGridProps) {
if (selectedRowIds?.length) {
dispatch(setSidePanelMode(SidePanelMode.STATION));
- dispatch(setSelectedStation(selectedRowIds[0]));
+ dispatch(setSelectedStations(selectedRowIds));
dispatch(setIsShareTrackEnabled(undefined));
}
}, []);
diff --git a/src/components/modal/add-station-modal.test.tsx b/src/components/modal/add-station-modal.test.tsx
index 1d1370345..ba5c8154a 100644
--- a/src/components/modal/add-station-modal.test.tsx
+++ b/src/components/modal/add-station-modal.test.tsx
@@ -131,7 +131,7 @@ describe('AddStationModal', () => {
expect(addedStations).toHaveLength(1);
// open side panel
- expect(mockStore.getState().app.selectedStation).toBe(addedStations[0]);
+ expect(mockStore.getState().app.selectedStations[0]).toBe(addedStations[0]);
expect(mockStore.getState().app.sidePanelMode).toBe(SidePanelMode.STATION);
});
});
diff --git a/src/components/modal/remove-confirm-modal.test.tsx b/src/components/modal/remove-confirm-modal.test.tsx
index 696d5fb98..325c3f778 100644
--- a/src/components/modal/remove-confirm-modal.test.tsx
+++ b/src/components/modal/remove-confirm-modal.test.tsx
@@ -42,7 +42,7 @@ describe('RemoveConfirmModal', () => {
const mockStore = createTestStore({
app: {
...realStore.app,
- selectedStation: 'stn1',
+ selectedStations: ['stn1'],
},
param: {
...realStore.param,
@@ -99,7 +99,7 @@ describe('RemoveConfirmModal', () => {
const mockStore = createTestStore({
app: {
...realStore.app,
- selectedStation: 'stn2',
+ selectedStations: ['stn2'],
},
param: {
...realStore.param,
@@ -115,6 +115,6 @@ describe('RemoveConfirmModal', () => {
// assertions
expect(mockStore.getState().param.stn_list).not.toHaveProperty('stn2'); // removal of station
expect(mockStore.getState().app.sidePanelMode).toBe(SidePanelMode.CLOSE); // close side panel
- expect(mockStore.getState().app.selectedStation).toBe('linestart'); // reset station selection
+ expect(mockStore.getState().app.selectedStations[0]).toBe('linestart'); // reset station selection
});
});
diff --git a/src/components/modal/remove-confirm-modal.tsx b/src/components/modal/remove-confirm-modal.tsx
index b5c3ee209..2e40ca17d 100644
--- a/src/components/modal/remove-confirm-modal.tsx
+++ b/src/components/modal/remove-confirm-modal.tsx
@@ -30,7 +30,7 @@ export default function RemoveConfirmModal(props: RemoveConfirmModalProps) {
const { t } = useTranslation();
const dispatch = useRootDispatch();
- const selectedStation = useRootSelector(state => state.app.selectedStation);
+ const selectedStation = useRootSelector(state => state.app.selectedStations[0]);
const [error, setError] = useState(false);
diff --git a/src/components/side-panel/batch-station-edit-panel.tsx b/src/components/side-panel/batch-station-edit-panel.tsx
new file mode 100644
index 000000000..fa4b5e648
--- /dev/null
+++ b/src/components/side-panel/batch-station-edit-panel.tsx
@@ -0,0 +1,92 @@
+import { Box, Button, HStack, Text, VStack } from '@chakra-ui/react';
+import { RmgFields, RmgSidePanelFooter } from '@railmapgen/rmg-components';
+import { useTranslation } from 'react-i18next';
+import { Facilities, Services, TEMP } from '../../constants/constants';
+import { useRootDispatch, useRootSelector } from '../../redux';
+import { updateStationsProperty } from '../../redux/param/action';
+import { checkStationCouldBeRemoved, removeStation } from '../../redux/param/remove-station-action';
+import { setSelectedStations } from '../../redux/app/app-slice';
+
+import { SidePanelMode } from '../../constants/constants';
+import { setSidePanelMode } from '../../redux/app/app-slice';
+import { useStationEditFields } from './station-side-panel/use-station-edit-fields';
+
+export default function BatchStationEditPanel() {
+ const { t } = useTranslation();
+ const dispatch = useRootDispatch();
+
+ const selectedStations = useRootSelector(state => state.app.selectedStations);
+ const { style, stn_list, loop } = useRootSelector(state => state.param);
+
+ if (!selectedStations || selectedStations.length <= 1) return null;
+
+ const firstStation = stn_list[selectedStations[0]];
+ const commonServices = firstStation?.services || [];
+ const commonFacility = firstStation?.facility || '';
+ const commonOneLine = firstStation?.one_line || false;
+ const commonIntPadding = firstStation?.int_padding;
+ const commonCharacterSpacing = firstStation?.character_spacing;
+ const commonUnderConstruction = firstStation?.underConstruction;
+
+ const fields = useStationEditFields({
+ style,
+ loop,
+ values: {
+ services: commonServices,
+ facility: commonFacility,
+ one_line: commonOneLine,
+ int_padding: commonIntPadding,
+ character_spacing: commonCharacterSpacing,
+ underConstruction: commonUnderConstruction,
+ },
+ handlers: {
+ onServicesChange: (val: Services[]) => dispatch(updateStationsProperty(selectedStations, 'services', val)),
+ onFacilityChange: (val: string | number) =>
+ dispatch(updateStationsProperty(selectedStations, 'facility', val as Facilities)),
+ onOneLineChange: (val: boolean) => dispatch(updateStationsProperty(selectedStations, 'one_line', val)),
+ onIntPaddingChange: (val: number) => dispatch(updateStationsProperty(selectedStations, 'int_padding', val)),
+ onCharacterSpacingChange: (val: number) =>
+ dispatch(updateStationsProperty(selectedStations, 'character_spacing', val)),
+ onUnderConstructionChange: (val: boolean | TEMP) =>
+ dispatch(updateStationsProperty(selectedStations, 'underConstruction', val)),
+ },
+ });
+
+ const handleDelete = () => {
+ dispatch((dispatch, getState) => {
+ const currentSelected = getState().app.selectedStations;
+ currentSelected.forEach(id => {
+ if (getState().param.stn_list[id]) {
+ if (checkStationCouldBeRemoved(id)(dispatch, getState)) {
+ dispatch(removeStation(id));
+ }
+ }
+ });
+ dispatch(setSelectedStations([]));
+ dispatch(setSidePanelMode(SidePanelMode.CLOSE));
+ });
+ };
+
+ return (
+
+
+
+ {t('StationSidePanel.batch_selected', {
+ count: selectedStations.length,
+ defaultValue: `Selected ${selectedStations.length} stations`,
+ })}
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
diff --git a/src/components/side-panel/side-panel.tsx b/src/components/side-panel/side-panel.tsx
index 7751de676..232780e13 100644
--- a/src/components/side-panel/side-panel.tsx
+++ b/src/components/side-panel/side-panel.tsx
@@ -3,6 +3,7 @@ import { useRootSelector } from '../../redux';
import { closePaletteAppClip, onPaletteAppClipEmit, setSidePanelMode } from '../../redux/app/app-slice';
import { useDispatch } from 'react-redux';
import { SidePanelMode } from '../../constants/constants';
+import BatchStationEditPanel from './batch-station-edit-panel';
import StationSidePanel from './station-side-panel/station-side-panel';
import StyleSidePanel from './style-side-panel/style-side-panel';
import { RmgMultiLineString, RmgSidePanel, RmgSidePanelHeader } from '@railmapgen/rmg-components';
@@ -17,14 +18,20 @@ export default function SidePanel() {
const { t } = useTranslation();
const dispatch = useDispatch();
- const { sidePanelMode, selectedStation, paletteAppClipInput } = useRootSelector(state => state.app);
+ const { sidePanelMode, selectedStations, paletteAppClipInput } = useRootSelector(state => state.app);
+ const selectedStation = selectedStations[0] || 'linestart';
const name = useRootSelector(state => state.param.stn_list[selectedStation]?.localisedName);
const mode: Record = {
STATION: {
- header: ,
- body: ,
- footer: ,
+ header:
+ selectedStations && selectedStations.length > 1 ? (
+ t('StationSidePanel.batch_edit')
+ ) : (
+
+ ),
+ body: selectedStations && selectedStations.length > 1 ? : ,
+ footer: selectedStations && selectedStations.length > 1 ? null : ,
},
STYLE: { header: t('StyleSidePanel.header'), body: },
BRANCH: { header: t('BranchSidePanel.header'), body: },
diff --git a/src/components/side-panel/station-side-panel/branch-section.tsx b/src/components/side-panel/station-side-panel/branch-section.tsx
index fedeba70b..65bdd46e0 100644
--- a/src/components/side-panel/station-side-panel/branch-section.tsx
+++ b/src/components/side-panel/station-side-panel/branch-section.tsx
@@ -13,7 +13,7 @@ export default function BranchSection() {
const { t } = useTranslation();
const dispatch = useRootDispatch();
- const selectedStation = useRootSelector(state => state.app.selectedStation);
+ const selectedStation = useRootSelector(state => state.app.selectedStations[0]);
const stationList = useRootSelector(state => state.param.stn_list);
const { parents, children, branch } = stationList[selectedStation];
diff --git a/src/components/side-panel/station-side-panel/info-section.tsx b/src/components/side-panel/station-side-panel/info-section.tsx
index 45cbcbd62..c22e0b98f 100644
--- a/src/components/side-panel/station-side-panel/info-section.tsx
+++ b/src/components/side-panel/station-side-panel/info-section.tsx
@@ -14,7 +14,7 @@ export default function InfoSection() {
const { t } = useTranslation();
const dispatch = useRootDispatch();
- const selectedStation = useRootSelector(state => state.app.selectedStation);
+ const selectedStation = useRootSelector(state => state.app.selectedStations[0]);
console.log('InfoSection:: Rendering for', selectedStation);
const style = useRootSelector(state => state.param.style);
const { num, localisedName, localisedSecondaryName } = useRootSelector(
diff --git a/src/components/side-panel/station-side-panel/interchange-section.test.tsx b/src/components/side-panel/station-side-panel/interchange-section.test.tsx
index 408f7868c..03fd3c440 100644
--- a/src/components/side-panel/station-side-panel/interchange-section.test.tsx
+++ b/src/components/side-panel/station-side-panel/interchange-section.test.tsx
@@ -12,7 +12,7 @@ const testStationId = realStore.param.stn_list.linestart.children[0];
describe('InterchangeSection', () => {
it('Can render InterchangeCard with headings as expected', () => {
const mockStore = createTestStore({
- app: { ...realStore.app, selectedStation: testStationId },
+ app: { ...realStore.app, selectedStations: [testStationId] },
param: {
...realStore.param,
style: RmgStyle.GZMTR,
@@ -42,7 +42,7 @@ describe('InterchangeSection', () => {
it('Can handle add interchange group as expected', () => {
const mockStore = createTestStore({
- app: { ...realStore.app, selectedStation: testStationId },
+ app: { ...realStore.app, selectedStations: [testStationId] },
param: {
...realStore.param,
style: RmgStyle.GZMTR,
diff --git a/src/components/side-panel/station-side-panel/interchange-section.tsx b/src/components/side-panel/station-side-panel/interchange-section.tsx
index f25dd300d..74f21f5f2 100644
--- a/src/components/side-panel/station-side-panel/interchange-section.tsx
+++ b/src/components/side-panel/station-side-panel/interchange-section.tsx
@@ -20,7 +20,7 @@ export default function InterchangeSection() {
const { t } = useTranslation();
const dispatch = useRootDispatch();
- const selectedStation = useRootSelector(state => state.app.selectedStation);
+ const selectedStation = useRootSelector(state => state.app.selectedStations[0]);
const { theme, style } = useRootSelector(state => state.param);
const { transfer } = useRootSelector(state => state.param.stn_list[selectedStation]);
diff --git a/src/components/side-panel/station-side-panel/more-section.tsx b/src/components/side-panel/station-side-panel/more-section.tsx
index 3cf15d8ea..0f311989e 100644
--- a/src/components/side-panel/station-side-panel/more-section.tsx
+++ b/src/components/side-panel/station-side-panel/more-section.tsx
@@ -1,7 +1,7 @@
import { Box, Heading } from '@chakra-ui/react';
-import { RmgButtonGroup, RmgFields, RmgFieldsField } from '@railmapgen/rmg-components';
+import { RmgFields } from '@railmapgen/rmg-components';
import { useTranslation } from 'react-i18next';
-import { FACILITIES, Facilities, RmgStyle, Services, TEMP } from '../../../constants/constants';
+import { Facilities, Services, TEMP } from '../../../constants/constants';
import { useRootDispatch, useRootSelector } from '../../../redux';
import {
updateStationCharacterSpacing,
@@ -14,136 +14,43 @@ import {
updateStationServices,
updateStationUnderConstruction,
} from '../../../redux/param/action';
+import { useStationEditFields } from './use-station-edit-fields';
export default function MoreSection() {
const { t } = useTranslation();
const dispatch = useRootDispatch();
- const selectedStation = useRootSelector(state => state.app.selectedStation);
+ const selectedStation = useRootSelector(state => state.app.selectedStations[0]);
const { style, loop } = useRootSelector(state => state.param);
const { services, facility, loop_pivot, one_line, int_padding, character_spacing, underConstruction } =
useRootSelector(state => state.param.stn_list[selectedStation]);
- const serviceSelections = Object.values(Services).map(service => {
- return {
- label: t('StationSidePanel.more.' + service),
- value: service,
- disabled: service === Services.local && style !== RmgStyle.SHMetro,
- };
- });
-
- const mtrFacilityOptions = Object.fromEntries(
- Object.entries(FACILITIES)
- .filter(([f]) => !['railway'].includes(f))
- .map(([f, name]) => [f, t(name)])
- );
- const shmetroFacilityOptions = Object.fromEntries(
- Object.entries(FACILITIES)
- .filter(([f]) => !['np360'].includes(f))
- .map(([f, name]) => [f, t(name)])
- );
-
- const fields: RmgFieldsField[] = [
- {
- type: 'custom',
- label: t('StationSidePanel.more.service'),
- component: (
- dispatch(updateStationServices(selectedStation, services))}
- multiSelect
- />
- ),
- hidden: ![RmgStyle.GZMTR, RmgStyle.SHMetro].includes(style),
- },
- {
- type: 'select',
- label: t('StationSidePanel.more.facility'),
- value: facility || '',
- options: { '': t('None'), ...(style === RmgStyle.MTR ? mtrFacilityOptions : shmetroFacilityOptions) },
- onChange: value => dispatch(updateStationFacility(selectedStation, value as Facilities | '')),
- hidden: ![RmgStyle.MTR, RmgStyle.SHMetro].includes(style),
- },
- {
- type: 'switch',
- label: t('StationSidePanel.more.pivot'),
- isChecked: loop_pivot,
- onChange: checked => dispatch(updateStationLoopPivot(selectedStation, checked)),
- hidden: ![RmgStyle.GZMTR, RmgStyle.SHMetro].includes(style) || !loop,
- minW: 'full',
- oneLine: true,
+ const fields = useStationEditFields({
+ style,
+ loop,
+ values: {
+ services,
+ facility: facility || '',
+ loop_pivot,
+ one_line,
+ int_padding,
+ character_spacing,
+ underConstruction,
},
- {
- type: 'switch',
- label: t('StationSidePanel.more.oneLine'),
- isChecked: one_line,
- onChange: checked => dispatch(updateStationOneLine(selectedStation, checked)),
- hidden: ![RmgStyle.SHMetro].includes(style),
- minW: 'full',
- oneLine: true,
+ handlers: {
+ onServicesChange: (val: Services[]) => dispatch(updateStationServices(selectedStation, val)),
+ onFacilityChange: (val: string | number) =>
+ dispatch(updateStationFacility(selectedStation, val as Facilities | '')),
+ onLoopPivotChange: (val: boolean) => dispatch(updateStationLoopPivot(selectedStation, val)),
+ onOneLineChange: (val: boolean) => dispatch(updateStationOneLine(selectedStation, val)),
+ onIntPaddingChange: (val: number) => dispatch(updateStationIntPadding(selectedStation, val)),
+ onCharacterSpacingChange: (val: number) => dispatch(updateStationCharacterSpacing(selectedStation, val)),
+ onUnderConstructionChange: (val: boolean | TEMP) =>
+ dispatch(updateStationUnderConstruction(selectedStation, val)),
+ onApplyIntPaddingToAll: () => dispatch(updateStationIntPaddingToAll(selectedStation)),
+ onApplyCharacterSpacingToAll: () => dispatch(updateStationCharacterSpacingToAll(selectedStation)),
},
- {
- type: 'input',
- label: t('StationSidePanel.more.intPadding'),
- value: int_padding.toString(),
- validator: val => Number.isInteger(val),
- onChange: val => dispatch(updateStationIntPadding(selectedStation, Number(val))),
- hidden: ![RmgStyle.SHMetro].includes(style),
- },
- {
- type: 'custom',
- label: t('StationSidePanel.more.intPaddingApplyGlobal'),
- component: (
- dispatch(updateStationIntPaddingToAll(selectedStation))}
- />
- ),
- oneLine: true,
- hidden: ![RmgStyle.SHMetro].includes(style),
- },
- {
- type: 'input',
- label: t('StationSidePanel.more.characterSpacing'),
- value: character_spacing.toString(),
- validator: val => Number.isInteger(val),
- onChange: val => dispatch(updateStationCharacterSpacing(selectedStation, Number(val))),
- hidden: ![RmgStyle.SHSuburbanRailway].includes(style),
- },
- {
- type: 'custom',
- label: t('StationSidePanel.more.intPaddingApplyGlobal'),
- component: (
- dispatch(updateStationCharacterSpacingToAll(selectedStation))}
- />
- ),
- oneLine: true,
- hidden: ![RmgStyle.SHSuburbanRailway].includes(style),
- },
- {
- type: 'custom',
- label: t('Under construction'),
- component: (
- dispatch(updateStationUnderConstruction(selectedStation, uc))}
- />
- ),
- hidden: ![RmgStyle.GZMTR].includes(style),
- },
- ];
+ });
return (
@@ -155,3 +62,4 @@ export default function MoreSection() {
);
}
+
diff --git a/src/components/side-panel/station-side-panel/station-side-panel-footer.tsx b/src/components/side-panel/station-side-panel/station-side-panel-footer.tsx
index f1dc9cfe1..c5c07f542 100644
--- a/src/components/side-panel/station-side-panel/station-side-panel-footer.tsx
+++ b/src/components/side-panel/station-side-panel/station-side-panel-footer.tsx
@@ -11,7 +11,7 @@ export default function StationSidePanelFooter() {
const { t } = useTranslation();
const dispatch = useRootDispatch();
- const { selectedStation } = useRootSelector(state => state.app);
+ const selectedStation = useRootSelector(state => state.app.selectedStations[0]);
const { loop, style } = useRootSelector(state => state.param);
const [isRemoveModalOpen, setIsRemoveModalOpen] = useState(false);
diff --git a/src/components/side-panel/station-side-panel/use-station-edit-fields.tsx b/src/components/side-panel/station-side-panel/use-station-edit-fields.tsx
new file mode 100644
index 000000000..33fb97041
--- /dev/null
+++ b/src/components/side-panel/station-side-panel/use-station-edit-fields.tsx
@@ -0,0 +1,185 @@
+import { useTranslation } from 'react-i18next';
+import { FACILITIES, RmgStyle, Services, TEMP } from '../../../constants/constants';
+import { RmgButtonGroup, RmgFieldsField } from '@railmapgen/rmg-components';
+
+interface UseStationEditFieldsProps {
+ style: RmgStyle;
+ values: {
+ services: Services[];
+ facility: string;
+ loop_pivot?: boolean;
+ one_line: boolean;
+ int_padding?: number;
+ character_spacing?: number;
+ underConstruction?: boolean | TEMP;
+ };
+ handlers: {
+ onServicesChange: (val: Services[]) => void;
+ onFacilityChange: (val: string | number) => void;
+ onLoopPivotChange?: (val: boolean) => void;
+ onOneLineChange: (val: boolean) => void;
+ onIntPaddingChange: (val: number) => void;
+ onCharacterSpacingChange: (val: number) => void;
+ onUnderConstructionChange: (val: boolean | TEMP) => void;
+ // Optional global apply handlers (only for single station mode)
+ onApplyIntPaddingToAll?: () => void;
+ onApplyCharacterSpacingToAll?: () => void;
+ };
+ loop?: boolean;
+}
+
+// Constants for configuration
+const SERVICE_ALLOW_CONFIG: Record = {
+ [RmgStyle.GZMTR]: [Services.express, Services.direct], // Only express and direct services allowed for Guangzhou Metro
+ default: Object.values(Services), // All services allowed by default (e.g. for SHMetro)
+};
+
+const FACILITY_ALLOW_CONFIG: Record = {
+ [RmgStyle.MTR]: ['airport', 'hsr', 'disney', 'np360'],
+ [RmgStyle.SHMetro]: ['airport', 'hsr', 'railway', 'disney'],
+ default: Object.keys(FACILITIES),
+};
+
+export const useStationEditFields = (props: UseStationEditFieldsProps): RmgFieldsField[] => {
+ const { t } = useTranslation();
+ const { style, values, handlers, loop } = props;
+
+ // Filter available train services based on current style
+ const allowedServices = SERVICE_ALLOW_CONFIG[style] || SERVICE_ALLOW_CONFIG.default;
+ const serviceSelections = Object.values(Services).map(service => ({
+ label: t('StationSidePanel.more.' + service),
+ value: service,
+ disabled: !allowedServices.includes(service),
+ }));
+
+ // Filter available facilities (like toilets, elevators) based on current style
+ const allowedFacilities = FACILITY_ALLOW_CONFIG[style] || FACILITY_ALLOW_CONFIG.default;
+ const facilityOptions = Object.fromEntries([
+ ['', t('None')],
+ ...Object.entries(FACILITIES)
+ .filter(([f]) => allowedFacilities.includes(f))
+ .map(([f, name]) => [f, t(name)]),
+ ]);
+
+ const fields: RmgFieldsField[] = [
+ {
+ type: 'custom',
+ label: t('StationSidePanel.more.service'),
+ component: (
+
+ ),
+ hidden: ![RmgStyle.GZMTR, RmgStyle.SHMetro].includes(style),
+ },
+ {
+ type: 'select',
+ label: t('StationSidePanel.more.facility'),
+ value: values.facility,
+ options: facilityOptions,
+ onChange: handlers.onFacilityChange,
+ hidden: ![RmgStyle.MTR, RmgStyle.SHMetro].includes(style),
+ },
+ // Loop Pivot (Specific to loop lines)
+ ...(handlers.onLoopPivotChange
+ ? ([
+ {
+ type: 'switch',
+ label: t('StationSidePanel.more.pivot'),
+ isChecked: values.loop_pivot ?? false,
+ onChange: handlers.onLoopPivotChange,
+ hidden: ![RmgStyle.GZMTR, RmgStyle.SHMetro].includes(style) || !loop,
+ oneLine: true,
+ minW: 'full',
+ },
+ ] as RmgFieldsField[])
+ : []),
+ {
+ type: 'switch',
+ label: t('StationSidePanel.more.oneLine'),
+ isChecked: values.one_line,
+ onChange: handlers.onOneLineChange,
+ hidden: ![RmgStyle.SHMetro].includes(style),
+ oneLine: true,
+ minW: 'full',
+ },
+ {
+ type: 'input',
+ label: t('StationSidePanel.more.intPadding'),
+ value: values.int_padding?.toString() || '',
+ validator: val => !isNaN(Number(val)),
+ onChange: val => handlers.onIntPaddingChange(Number(val)),
+ hidden: ![RmgStyle.SHMetro].includes(style),
+ debouncedDelay: 300,
+ },
+ // Global apply for int_padding (Single station only)
+ // When the handler is provided, it will be appended as an additional field
+ ...(handlers.onApplyIntPaddingToAll
+ ? ([
+ {
+ type: 'custom',
+ label: t('StationSidePanel.more.intPaddingApplyGlobal'),
+ component: (
+
+ ),
+ oneLine: true,
+ hidden: ![RmgStyle.SHMetro].includes(style),
+ },
+ ] as RmgFieldsField[])
+ : []),
+ {
+ type: 'input',
+ label: t('StationSidePanel.more.characterSpacing'),
+ value: values.character_spacing?.toString() || '',
+ validator: val => !isNaN(Number(val)),
+ onChange: val => handlers.onCharacterSpacingChange(Number(val)),
+ hidden: ![RmgStyle.SHSuburbanRailway].includes(style),
+ debouncedDelay: 300,
+ },
+ // Global apply for character_spacing (Single station only)
+ ...(handlers.onApplyCharacterSpacingToAll
+ ? ([
+ {
+ type: 'custom',
+ label: t('StationSidePanel.more.intPaddingApplyGlobal'),
+ component: (
+
+ ),
+ oneLine: true,
+ hidden: ![RmgStyle.SHSuburbanRailway].includes(style),
+ },
+ ] as RmgFieldsField[])
+ : []),
+ {
+ type: 'custom',
+ label: t('Under construction'),
+ component: (
+
+ ),
+ hidden: ![RmgStyle.GZMTR].includes(style),
+ },
+ ];
+
+ return fields;
+};
diff --git a/src/i18n/translations/en.json b/src/i18n/translations/en.json
index 9a71177a7..67ec27d47 100644
--- a/src/i18n/translations/en.json
+++ b/src/i18n/translations/en.json
@@ -136,6 +136,8 @@
},
"StationSidePanel": {
+ "batch_edit": "Batch Edit",
+ "batch_selected": "Selected {{count}} stations",
"info": {
"title": "Station info",
"num": "Station code",
diff --git a/src/i18n/translations/ja.json b/src/i18n/translations/ja.json
index 663e1c9bc..92f8d3b3b 100644
--- a/src/i18n/translations/ja.json
+++ b/src/i18n/translations/ja.json
@@ -134,6 +134,8 @@
},
"StationSidePanel": {
+ "batch_edit": "一括編集",
+ "batch_selected": "{{count}} 駅を選択中",
"info": {
"title": "駅情報",
"num": "駅番号",
diff --git a/src/i18n/translations/ko.json b/src/i18n/translations/ko.json
index 6c9ec4086..2721ac046 100644
--- a/src/i18n/translations/ko.json
+++ b/src/i18n/translations/ko.json
@@ -133,6 +133,8 @@
},
"StationSidePanel": {
+ "batch_edit": "일괄 편집",
+ "batch_selected": "{{count}}개 역 선택됨",
"info": {
"title": "역 정보",
"num": "역 번호",
diff --git a/src/i18n/translations/zh-Hans.json b/src/i18n/translations/zh-Hans.json
index 2941d351c..91ad70cd3 100644
--- a/src/i18n/translations/zh-Hans.json
+++ b/src/i18n/translations/zh-Hans.json
@@ -134,6 +134,8 @@
},
"StationSidePanel": {
+ "batch_edit": "批量编辑",
+ "batch_selected": "已选中 {{count}} 个车站",
"info": {
"title": "车站资讯",
"num": "车站编号",
diff --git a/src/i18n/translations/zh-Hant.json b/src/i18n/translations/zh-Hant.json
index 45d68f690..dd1826617 100644
--- a/src/i18n/translations/zh-Hant.json
+++ b/src/i18n/translations/zh-Hant.json
@@ -127,6 +127,8 @@
}
},
"StationSidePanel": {
+ "batch_edit": "批次編輯",
+ "batch_selected": "已選取 {{count}} 個車站",
"info": {
"title": "車站資訊",
"num": "車站編碼",
diff --git a/src/redux/app/app-slice.ts b/src/redux/app/app-slice.ts
index fddd0bbf0..6b45e1dd7 100644
--- a/src/redux/app/app-slice.ts
+++ b/src/redux/app/app-slice.ts
@@ -13,7 +13,8 @@ interface AppState {
canvasScale: number;
canvasToShow: CanvasType[];
sidePanelMode: SidePanelMode;
- selectedStation: string;
+ // selectedStation: string; // Removed as redundant
+ selectedStations: string[];
selectedColine?: number;
selectedBranch: number;
isShareTrackEnabled?: string[]; // for main line only, store the selections
@@ -29,7 +30,8 @@ const initialState: AppState = {
canvasScale: 1,
canvasToShow: Object.values(CanvasType),
sidePanelMode: SidePanelMode.CLOSE,
- selectedStation: 'linestart',
+ // selectedStation: 'linestart',
+ selectedStations: ['linestart'],
selectedColine: undefined,
selectedBranch: 0,
isShareTrackEnabled: undefined,
@@ -65,7 +67,8 @@ const appSlice = createSlice({
},
setSelectedStation: (state, action: PayloadAction) => {
- state.selectedStation = action.payload;
+ // state.selectedStation = action.payload;
+ state.selectedStations = [action.payload];
},
setSelectedColine: (state, action: PayloadAction) => {
@@ -122,6 +125,10 @@ const appSlice = createSlice({
state.paletteAppClipOutput = action.payload;
state.paletteAppClipInput = undefined;
},
+
+ setSelectedStations: (state, action: PayloadAction) => {
+ state.selectedStations = action.payload;
+ },
},
});
@@ -143,6 +150,7 @@ export const {
openPaletteAppClip,
closePaletteAppClip,
onPaletteAppClipEmit,
+ setSelectedStations,
} = appSlice.actions;
const appReducer = appSlice.reducer;
diff --git a/src/redux/param/action.ts b/src/redux/param/action.ts
index 7c7804246..40354a8d9 100644
--- a/src/redux/param/action.ts
+++ b/src/redux/param/action.ts
@@ -630,3 +630,23 @@ export const autoNumbering = (branchIndex: number, from: number, maxLength = 2,
}
};
};
+
+export const updateStationsProperty = (
+ stationIds: string[],
+ key: K,
+ value: StationInfo[K]
+) => {
+ return (dispatch: RootDispatch, getState: () => RootState) => {
+ const { stn_list } = getState().param;
+ const nextStationList = { ...stn_list };
+
+ const uniqueIds = Array.from(new Set(stationIds));
+ uniqueIds.forEach(id => {
+ if (nextStationList[id]) {
+ nextStationList[id] = { ...nextStationList[id], [key]: value };
+ }
+ });
+
+ dispatch(setStationsBulk(nextStationList));
+ };
+};