diff --git a/src/components/side-panel/style-side-panel/design-section.tsx b/src/components/side-panel/style-side-panel/design-section.tsx index 94e1eb15e..ce5396850 100644 --- a/src/components/side-panel/style-side-panel/design-section.tsx +++ b/src/components/side-panel/style-side-panel/design-section.tsx @@ -17,6 +17,10 @@ import { setTheme, staggerStationNames, toggleLineNameBeforeDestination, + setBranchInfoDistanceFactor, + setBranchInfoFirstStationOffset, + setBranchInfoBendType, + setBranchInfoAlignEndpoints, } from '../../../redux/param/param-slice'; import { PanelTypeGZMTR, PanelTypeShmetro, PsdLabel, RmgStyle, ShortDirection } from '../../../constants/constants'; import { MdSwapVert } from 'react-icons/md'; @@ -45,6 +49,12 @@ export default function DesignSection() { info_panel_type, stn_list, loop, + branch_info: { + distance_factor: branchDistanceFactor, + first_station_offset: branchFirstStationOffset, + bend_type: branchBendType, + align_endpoints: branchAlignEndpoints, + }, } = useRootSelector(state => state.param); const lineServices = Math.max(...Object.values(stn_list).map(s => s.services.length)); @@ -226,6 +236,57 @@ export default function DesignSection() { oneLine: true, hidden: ![RmgStyle.SHMetro].includes(style) || lineServices > 1 || loop, }, + { + type: 'slider', + label: t('StyleSidePanel.design.branchDistanceFactor'), + value: branchDistanceFactor, + min: 1, + max: 5, + step: 0.05, + onChange: value => { + dispatch(setBranchInfoDistanceFactor(value)); + }, + hidden: ![RmgStyle.SHMetro].includes(style) || info_panel_type !== 'sh2020' || loop, + }, + { + type: 'slider', + label: t('StyleSidePanel.design.branchFirstStationOffset'), + value: branchFirstStationOffset, + min: 0, + max: 5, + step: 0.05, + onChange: value => { + dispatch(setBranchInfoFirstStationOffset(value)); + }, + hidden: ![RmgStyle.SHMetro].includes(style) || info_panel_type !== 'sh2020' || loop, + }, + { + type: 'select', + label: t('StyleSidePanel.design.branchBendType'), + value: branchBendType, + options: + info_panel_type === 'sh2024' + ? { + rightangle: t('StyleSidePanel.design.branchBendRightAngle'), + '45degree': t('StyleSidePanel.design.branchBendDiagonal'), + } + : { + rightangle: t('StyleSidePanel.design.branchBendRightAngle'), + }, + onChange: value => { + dispatch(setBranchInfoBendType(value as 'rightangle' | '45degree')); + }, + hidden: ![RmgStyle.SHMetro].includes(style) || info_panel_type !== 'sh2020' || loop, + }, + { + type: 'switch', + label: t('StyleSidePanel.design.branchAlignEndpoints'), + isChecked: branchAlignEndpoints, + onChange: value => { + dispatch(setBranchInfoAlignEndpoints(value)); + }, + hidden: ![RmgStyle.SHMetro].includes(style) || info_panel_type !== 'sh2020' || loop, + }, ]; const staggerNameSelections = [ diff --git a/src/components/side-panel/style-side-panel/layout-section.tsx b/src/components/side-panel/style-side-panel/layout-section.tsx index 6a9bdfa8e..95b2aefea 100644 --- a/src/components/side-panel/style-side-panel/layout-section.tsx +++ b/src/components/side-panel/style-side-panel/layout-section.tsx @@ -23,7 +23,7 @@ export default function LayoutSection() { svgWidth, svg_height, y_pc, - branchSpacingPct, + branch_info: { spacing_pct: branchSpacingPct }, padding, direction_gz_x, direction_gz_y, diff --git a/src/constants/constants.ts b/src/constants/constants.ts index ef2e2b56c..7b3100782 100644 --- a/src/constants/constants.ts +++ b/src/constants/constants.ts @@ -214,10 +214,32 @@ export interface RMGParam { * Left and right margin of line (in percentage). */ padding: number; - /** Branch spacing percentage (space between upper and lower branch). - * In SHMetro loop line, this is also used in determining vertical padding. - */ - branchSpacingPct: number; + branch_info: { + /** Branch spacing percentage (space between upper and lower branch). + * In SHMetro loop line, this is also used in determining vertical padding. + */ + spacing_pct: number; + /** + * Factor for the horizontal distance from bifurcation point to first branch station. + * Default is 1, meaning normal spacing. + */ + distance_factor: number; + /** + * Horizontal offset from bifurcation point where the vertical turn begins. + * Default is 0, meaning turn starts at bifurcation point. + */ + first_station_offset: number; + /** + * Type of bend at bifurcation: 'rightangle' (90°) or '45degree' (45°). + * Default is 'rightangle'. + */ + bend_type: 'rightangle' | '45degree'; + /** + * Whether to align shorter branch endpoints to match the longer parallel branch. + * Default is false. + */ + align_endpoints: boolean; + }; direction: ShortDirection; /** * Platform number of the destination canvas. Set to '' will hide the element. diff --git a/src/i18n/translations/en.json b/src/i18n/translations/en.json index 9a71177a7..f0377bdd3 100644 --- a/src/i18n/translations/en.json +++ b/src/i18n/translations/en.json @@ -97,7 +97,13 @@ "legacyDestination": "Display line name on direction sign", "overrideTerminal": "Override terminal", "terminalZhName": "Terminal Chinese name", - "terminalEnName": "Terminal English name" + "terminalEnName": "Terminal English name", + "branchDistanceFactor": "Branch horizontal distance factor", + "branchFirstStationOffset": "Branch horizontal turn offset", + "branchBendType": "Branch bend type", + "branchBendRightAngle": "Right angle (90°)", + "branchBendDiagonal": "Diagonal (45°)", + "branchAlignEndpoints": "Align branch endpoints" }, "note": { "title": "Notes", diff --git a/src/i18n/translations/ja.json b/src/i18n/translations/ja.json index 663e1c9bc..79eda7822 100644 --- a/src/i18n/translations/ja.json +++ b/src/i18n/translations/ja.json @@ -95,7 +95,13 @@ "legacyDestination": "終着駅表示板に路線名を表示", "overrideTerminal": "終着駅を上書き", "terminalZhName": "終着駅の日本語名", - "terminalEnName": "終着駅の英語名" + "terminalEnName": "終着駅の英語名", + "branchDistanceFactor": "分岐水平距離倍率", + "branchFirstStationOffset": "分岐水平曲がりオフセット", + "branchBendType": "分岐カーブ形状", + "branchBendRightAngle": "直角 (90°)", + "branchBendDiagonal": "斜め (45°)", + "branchAlignEndpoints": "分岐端点を揃える" }, "note": { "title": "備考", diff --git a/src/i18n/translations/ko.json b/src/i18n/translations/ko.json index 6c9ec4086..25cfe6e02 100644 --- a/src/i18n/translations/ko.json +++ b/src/i18n/translations/ko.json @@ -94,7 +94,13 @@ "legacyDestination": "터미널에 경로 명칭 표시", "overrideTerminal": "끝점 다시 쓰기", "terminalZhName": "터미널 한자 명칭", - "terminalEnName": "터미널 영문 명칭" + "terminalEnName": "터미널 영문 명칭", + "branchDistanceFactor": "분기 수평 거리 배수", + "branchFirstStationOffset": "분기 수평 회전 오프셋", + "branchBendType": "분기 곡선 유형", + "branchBendRightAngle": "직각 (90°)", + "branchBendDiagonal": "대각선 (45°)", + "branchAlignEndpoints": "분기 끝점 정렬" }, "note": { "title": "설명", diff --git a/src/i18n/translations/zh-Hans.json b/src/i18n/translations/zh-Hans.json index 2941d351c..39c380c1a 100644 --- a/src/i18n/translations/zh-Hans.json +++ b/src/i18n/translations/zh-Hans.json @@ -95,7 +95,13 @@ "legacyDestination": "在终点站牌显示路线名称", "overrideTerminal": "重写终点站", "terminalZhName": "终点站中文名称", - "terminalEnName": "终点站英文名称" + "terminalEnName": "终点站英文名称", + "branchDistanceFactor": "分支水平间距倍数", + "branchFirstStationOffset": "分支水平转弯偏移量", + "branchBendType": "分支弯道类型", + "branchBendRightAngle": "直角 (90°)", + "branchBendDiagonal": "斜线 (45°)", + "branchAlignEndpoints": "对齐分支端点" }, "note": { "title": "备注", diff --git a/src/i18n/translations/zh-Hant.json b/src/i18n/translations/zh-Hant.json index 45d68f690..4a8f8fa48 100644 --- a/src/i18n/translations/zh-Hant.json +++ b/src/i18n/translations/zh-Hant.json @@ -90,7 +90,13 @@ "legacyDestination": "於終點站牌顯示路綫名稱", "overrideTerminal": "覆寫終點站", "terminalZhName": "終點站中文名稱", - "terminalEnName": "終點站英文名稱" + "terminalEnName": "終點站英文名稱", + "branchDistanceFactor": "分支水平間距倍數", + "branchFirstStationOffset": "分支水平轉彎偏移量", + "branchBendType": "分支彎道類型", + "branchBendRightAngle": "直角 (90°)", + "branchBendDiagonal": "斜線 (45°)", + "branchAlignEndpoints": "對齊分支端點" }, "note": { "title": "備註", diff --git a/src/redux/param/param-slice.ts b/src/redux/param/param-slice.ts index 9c97d848b..ff52f4729 100644 --- a/src/redux/param/param-slice.ts +++ b/src/redux/param/param-slice.ts @@ -42,7 +42,7 @@ const paramSlice = createSlice({ }, setBranchSpacingPct: (state, action: PayloadAction) => { - state.branchSpacingPct = action.payload; + state.branch_info.spacing_pct = action.payload; }, setPaddingPercentage: (state, action: PayloadAction) => { @@ -159,6 +159,22 @@ const paramSlice = createSlice({ state.loop_info.clockwise = action.payload; }, + setBranchInfoDistanceFactor: (state, action: PayloadAction) => { + state.branch_info.distance_factor = action.payload; + }, + + setBranchInfoFirstStationOffset: (state, action: PayloadAction) => { + state.branch_info.first_station_offset = action.payload; + }, + + setBranchInfoBendType: (state, action: PayloadAction<'rightangle' | '45degree'>) => { + state.branch_info.bend_type = action.payload; + }, + + setBranchInfoAlignEndpoints: (state, action: PayloadAction) => { + state.branch_info.align_endpoints = action.payload; + }, + setCurrentStation: (state, action: PayloadAction) => { state.current_stn_idx = action.payload; }, @@ -211,6 +227,10 @@ export const { setLoopBottomFactor, setLoopMidpointStation, setLoopClockwise, + setBranchInfoDistanceFactor, + setBranchInfoFirstStationOffset, + setBranchInfoBendType, + setBranchInfoAlignEndpoints, setCurrentStation, setStation, setStations, diff --git a/src/redux/param/util.ts b/src/redux/param/util.ts index 26648c472..2b1eee541 100644 --- a/src/redux/param/util.ts +++ b/src/redux/param/util.ts @@ -93,7 +93,13 @@ export const initParam = (style: RmgStyle, language: LanguageCode): RMGParam => style: style, y_pc: 50, padding: 10, - branchSpacingPct: 33, + branch_info: { + spacing_pct: 33, + distance_factor: 1, + first_station_offset: 0, + bend_type: 'rightangle', + align_endpoints: false, + }, direction: ShortDirection.left, platform_num: '1', theme: initTheme(style), diff --git a/src/svgs/gzmtr/main-gzmtr.tsx b/src/svgs/gzmtr/main-gzmtr.tsx index 71a649353..d949e7788 100644 --- a/src/svgs/gzmtr/main-gzmtr.tsx +++ b/src/svgs/gzmtr/main-gzmtr.tsx @@ -82,7 +82,7 @@ const MainGZMTR = () => { svg_height: svgH, y_pc: yPercentage, padding: paddingPercentage, - branchSpacingPct, + branch_info: { spacing_pct: branchSpacingPct }, direction, line_name: lineName, spanLineNum, diff --git a/src/svgs/gzmtr/rail-map/loop-main.tsx b/src/svgs/gzmtr/rail-map/loop-main.tsx index f8d58ea54..d56b60f49 100644 --- a/src/svgs/gzmtr/rail-map/loop-main.tsx +++ b/src/svgs/gzmtr/rail-map/loop-main.tsx @@ -14,7 +14,7 @@ export default function LoopMain() { svg_height: svgH, y_pc: yPercentage, padding: paddingPercentage, - branchSpacingPct, + branch_info: { spacing_pct: branchSpacingPct }, current_stn_idx: currentStation, loop_info: { midpoint_station: midpointStation, clockwise }, } = useRootSelector(store => store.param); diff --git a/src/svgs/methods/share.test.ts b/src/svgs/methods/share.test.ts new file mode 100644 index 000000000..ee2189885 --- /dev/null +++ b/src/svgs/methods/share.test.ts @@ -0,0 +1,141 @@ +import { drawLine, getStnState } from './share'; + +// Diamond topology used in getStnState tests: +// +// C -- D +// / \ +// A -- B E -- F +// \ / +// G -- H +// +// routes[0]: linestart → A → B → C → D → E → F → lineend +// routes[1]: linestart → A → B → G → H → E → F → lineend + +const TWO_BRANCH_ROUTES: string[][] = [ + ['linestart', 'A', 'B', 'C', 'D', 'E', 'F', 'lineend'], + ['linestart', 'A', 'B', 'G', 'H', 'E', 'F', 'lineend'], +]; + +// Dead-end topology: +// +// <-- a ---| +// c --- d ---- +// <-- b ---| + +const DEAD_END_ROUTES: string[][] = [ + ['linestart', 'b', 'c', 'd', 'lineend'], + ['linestart', 'a', 'c', 'd', 'lineend'], +]; + +describe('getStnState', () => { + it('Can calculate states on simple line (direction r)', () => { + const routes = [['linestart', 'A', 'B', 'C', 'lineend']]; + const states = getStnState('B', routes, 'r'); + expect(states['A']).toBe(-1); + expect(states['B']).toBe(0); + expect(states['C']).toBe(1); + }); + + it('Can calculate states on simple line (direction l)', () => { + const routes = [['linestart', 'A', 'B', 'C', 'lineend']]; + const states = getStnState('B', routes, 'l'); + expect(states['A']).toBe(1); + expect(states['B']).toBe(0); + expect(states['C']).toBe(-1); + }); + + it('Can promote parallel branch when current is on sibling branch (direction r)', () => { + const states = getStnState('D', TWO_BRANCH_ROUTES, 'r'); + expect(states['B']).toBe(-1); + expect(states['C']).toBe(-1); + expect(states['D']).toBe(0); + expect(states['E']).toBe(1); + expect(states['G']).toBe(1); + expect(states['H']).toBe(1); + }); + + it('Can promote parallel branch when current is on sibling branch (direction l)', () => { + const states = getStnState('D', TWO_BRANCH_ROUTES, 'l'); + expect(states['B']).toBe(1); + expect(states['C']).toBe(1); + expect(states['D']).toBe(0); + expect(states['E']).toBe(-1); + expect(states['G']).toBe(1); + expect(states['H']).toBe(1); + }); + + it('Can calculate states at bifurcation point — both branches ahead', () => { + const states = getStnState('B', TWO_BRANCH_ROUTES, 'r'); + expect(states['A']).toBe(-1); + expect(states['B']).toBe(0); + expect(states['C']).toBe(1); + expect(states['D']).toBe(1); + expect(states['E']).toBe(1); + expect(states['F']).toBe(1); + expect(states['G']).toBe(1); + expect(states['H']).toBe(1); + }); + + it('Can calculate states at merge point — both branches behind', () => { + const states = getStnState('E', TWO_BRANCH_ROUTES, 'r'); + expect(states['A']).toBe(-1); + expect(states['B']).toBe(-1); + expect(states['C']).toBe(-1); + expect(states['D']).toBe(-1); + expect(states['E']).toBe(0); + expect(states['F']).toBe(1); + expect(states['G']).toBe(-1); + expect(states['H']).toBe(-1); + }); + + it('Does not promote dead-end parallel branch when junction is behind current', () => { + const states = getStnState('a', DEAD_END_ROUTES, 'l'); + expect(states['a']).toBe(0); + expect(states['c']).toBe(-1); + expect(states['d']).toBe(-1); + expect(states['b']).toBe(-1); + }); + + it('Can promote dead-end parallel branch when junction is ahead of current', () => { + const states = getStnState('a', DEAD_END_ROUTES, 'r'); + expect(states['a']).toBe(0); + expect(states['c']).toBe(1); + expect(states['d']).toBe(1); + expect(states['b']).toBe(1); + }); +}); + +describe('drawLine', () => { + it('Can color promoted parallel branch stations', () => { + const stnStates: { [k: string]: -1 | 0 | 1 } = { B: -1, G: 1, H: 1, E: 1 }; + const { main, pass } = drawLine(['linestart', 'B', 'G', 'H', 'E', 'lineend'], stnStates); + expect(main).toContain('G'); + expect(main).toContain('H'); + expect(main).toContain('E'); + expect(pass).toContain('B'); + }); + + it('Does not generate gray stub when entire branch is state=1', () => { + const stnStates: { [k: string]: -1 | 0 | 1 } = { X: 1, Y: 1, Z: 1 }; + const { main, pass } = drawLine(['linestart', 'X', 'Y', 'Z', 'lineend'], stnStates); + expect(main).toEqual(['X', 'Y', 'Z']); + expect(pass).toHaveLength(0); + }); + + it('Current station (state=0) appears in both main and pass as overlap point', () => { + const stnStates: { [k: string]: -1 | 0 | 1 } = { B: -1, G: 0, H: 1, E: 1 }; + const { main, pass } = drawLine(['linestart', 'B', 'G', 'H', 'E', 'lineend'], stnStates); + expect(pass).toContain('B'); + expect(pass).toContain('G'); + expect(main).toContain('G'); + expect(main).toContain('H'); + expect(main).toContain('E'); + }); + + it('Returns empty main and full pass when entire branch is state=-1', () => { + const stnStates: { [k: string]: -1 | 0 | 1 } = { B: -1, G: -1, H: -1, E: -1 }; + const { main, pass } = drawLine(['linestart', 'B', 'G', 'H', 'E', 'lineend'], stnStates); + expect(main).toHaveLength(0); + expect(pass).toEqual(['B', 'G', 'H', 'E']); + }); +}); diff --git a/src/svgs/methods/share.ts b/src/svgs/methods/share.ts index c9b64597e..ced4a6b4a 100644 --- a/src/svgs/methods/share.ts +++ b/src/svgs/methods/share.ts @@ -110,27 +110,77 @@ const _isSuccessor = (stnId1: string, stnId2: string, routes: string[][]) => { return false; }; +/** + * Compute each station's state relative to the current station and travel direction. + * State: 1 = ahead (colored), 0 = current, -1 = behind (gray). + * Stations on parallel branches are promoted to 1 when their forward junction is ahead. + * @param currentId ID of the current station + * @param routes All possible routes through the line graph + * @param direction Travel direction + */ export const getStnState = ( currentId: string, routes: string[][], direction: 'l' | 'r' ): { [stnId: string]: -1 | 0 | 1 } => { console.log("computing stations' states"); - return [...new Set(([] as string[]).concat(...routes))].reduce( + const allStations = [...new Set(([] as string[]).concat(...routes))]; + + // initial states based on successor/predecessor relationship + const initialStates = allStations.reduce( (acc, cur: string) => ({ ...acc, - [cur]: - cur === currentId - ? 0 - : ( - direction === ShortDirection.right - ? _isSuccessor(currentId, cur, routes) - : _isPredecessor(currentId, cur, routes) - ) - ? 1 - : -1, + [cur]: (cur === currentId + ? 0 + : ( + direction === ShortDirection.right + ? _isSuccessor(currentId, cur, routes) + : _isPredecessor(currentId, cur, routes) + ) + ? 1 + : -1) as -1 | 0 | 1, }), - {} + {} as { [stnId: string]: -1 | 0 | 1 } + ); + + // promote stations on parallel branches whose forward junction is in the future + const stationsInCurrentRoutes = new Set( + ([] as string[]).concat(...routes.filter(route => route.includes(currentId))) + ); + + return allStations.reduce( + (acc, stnId) => { + if (initialStates[stnId] !== -1 || stationsInCurrentRoutes.has(stnId)) { + return { ...acc, [stnId]: initialStates[stnId] }; + } + + // find the nearest current-route station in the forward direction; + // linestart/lineend are virtual nodes and are not valid junctions + const shouldPromote = routes.some(route => { + const idx = route.indexOf(stnId); + if (idx === -1) return false; + + if (direction === ShortDirection.right) { + for (let i = idx + 1; i < route.length; i++) { + if (['linestart', 'lineend'].includes(route[i])) continue; + if (stationsInCurrentRoutes.has(route[i])) { + return initialStates[route[i]] === 1; + } + } + } else { + for (let i = idx - 1; i >= 0; i--) { + if (['linestart', 'lineend'].includes(route[i])) continue; + if (stationsInCurrentRoutes.has(route[i])) { + return initialStates[route[i]] === 1; + } + } + } + return false; + }); + + return { ...acc, [stnId]: shouldPromote ? 1 : -1 }; + }, + {} as { [stnId: string]: -1 | 0 | 1 } ); }; @@ -328,7 +378,7 @@ export const drawLine = (branch: string[], stnStates: { [stnId: string]: -1 | 0 ) { linePassStns = branch; lineMainStns = []; - } else { + } else if (linePassStns.length > 0) { // 1 1 -1 -1 linePassStns.unshift(lineMainStns[lineMainStns.length - 1]); } diff --git a/src/svgs/mtr/main-mtr.tsx b/src/svgs/mtr/main-mtr.tsx index c17bf8996..24d009002 100644 --- a/src/svgs/mtr/main-mtr.tsx +++ b/src/svgs/mtr/main-mtr.tsx @@ -26,7 +26,7 @@ const MainMTR = () => { svg_height: svgH, y_pc: yPercentage, padding: paddingPercentage, - branchSpacingPct, + branch_info: { spacing_pct: branchSpacingPct }, direction, namePosMTR: namePosition, current_stn_idx: currentStationIndex, diff --git a/src/svgs/shmetro/coline-shmetro.tsx b/src/svgs/shmetro/coline-shmetro.tsx index 554b9142b..4178068e3 100644 --- a/src/svgs/shmetro/coline-shmetro.tsx +++ b/src/svgs/shmetro/coline-shmetro.tsx @@ -35,7 +35,7 @@ export const ColineSHMetro = (props: Props) => { direction, stn_list, current_stn_idx, - branchSpacingPct, + branch_info: { spacing_pct: branchSpacingPct }, info_panel_type, coline: colineInfo, } = useRootSelector(store => store.param); @@ -94,6 +94,7 @@ export const ColineSHMetro = (props: Props) => { service, servicesPresent.length, stn_list, + undefined, 'diagonal' ), colors: colineStn.colors, diff --git a/src/svgs/shmetro/indoor/indoor-shmetro.tsx b/src/svgs/shmetro/indoor/indoor-shmetro.tsx index f9c453120..96b54f5e3 100644 --- a/src/svgs/shmetro/indoor/indoor-shmetro.tsx +++ b/src/svgs/shmetro/indoor/indoor-shmetro.tsx @@ -106,7 +106,7 @@ const IndoorSHMetro = () => { const yShares = useMemo(() => StationsSHMetro.getYShares(param.stn_list), [deps]); const ys = Object.keys(yShares).reduce( - (acc, cur) => ({ ...acc, [cur]: (yShares[cur] * param.branchSpacingPct * param.svg_height) / 200 }), + (acc, cur) => ({ ...acc, [cur]: (yShares[cur] * param.branch_info.spacing_pct * param.svg_height) / 200 }), {} as typeof yShares ); @@ -137,7 +137,7 @@ const IndoorSHMetro = () => { lineXs, xs, ys, - (param.branchSpacingPct * param.svg_height) / 200, + (param.branch_info.spacing_pct * param.svg_height) / 200, criticalPath, 0 ); diff --git a/src/svgs/shmetro/loop/loop-shmetro.tsx b/src/svgs/shmetro/loop/loop-shmetro.tsx index d567cd7ba..61c3b7e86 100644 --- a/src/svgs/shmetro/loop/loop-shmetro.tsx +++ b/src/svgs/shmetro/loop/loop-shmetro.tsx @@ -22,7 +22,7 @@ const LoopSHMetro = (props: { bank_angle: boolean; canvas: CanvasType.RailMap | svgWidth: svg_width, svg_height, padding, - branchSpacingPct, + branch_info: { spacing_pct: branchSpacingPct }, direction, info_panel_type, stn_list, diff --git a/src/svgs/shmetro/main-shmetro.tsx b/src/svgs/shmetro/main-shmetro.tsx index d4b88b782..d9c0e765e 100644 --- a/src/svgs/shmetro/main-shmetro.tsx +++ b/src/svgs/shmetro/main-shmetro.tsx @@ -1,7 +1,7 @@ import { adjacencyList, criticalPathMethod, drawLine, getStnState, getXShareMTR } from '../methods/share'; import StationSHMetro from './station-shmetro'; import ColineSHMetro from './coline-shmetro'; -import { AtLeastOneOfPartial, Services, StationDict } from '../../constants/constants'; +import { AtLeastOneOfPartial, PanelTypeShmetro, Services, StationDict, StationInfo } from '../../constants/constants'; import { useRootSelector } from '../../redux'; import { useMemo } from 'react'; @@ -13,16 +13,48 @@ interface servicesPath { type Paths = AtLeastOneOfPartial>; +/** LeftW callback for SHMetro 2020: adds weight at merge points (multiple parents). */ +const createSh2020LeftW = + (k1: number) => + (stnList: { [stnId: string]: StationInfo }, stnId: string): number => { + const stn = stnList[stnId]; + if (!stn) return 0; + return stn.parents.length > 1 ? k1 - 1 : 0; + }; + +/** RightW callback for SHMetro 2020: adds weight at bifurcation points (multiple children). */ +const createSh2020RightW = + (k1: number) => + (stnList: { [stnId: string]: StationInfo }, stnId: string): number => { + const stn = stnList[stnId]; + if (!stn) return 0; + return stn.children.length > 1 ? k1 - 1 : 0; + }; + const MainSHMetro = () => { const { routes, branches, depsStr: deps } = useRootSelector(store => store.helper); const param = useRootSelector(store => store.param); - const { svg_height, stn_list, branchSpacingPct, coline, direction } = useRootSelector(store => store.param); - - const adjMat = adjacencyList( - param.stn_list, - () => 0, - () => 0 + const { svg_height, stn_list, branch_info, coline, direction, info_panel_type } = useRootSelector( + store => store.param ); + const { spacing_pct: branchSpacingPct } = branch_info; + + // Only apply SHMetro 2020-specific layout when the panel type matches. + const sh2020 = info_panel_type === PanelTypeShmetro.sh2020 ? branch_info : undefined; + const k1 = sh2020?.distance_factor ?? 1; + const k2 = sh2020?.first_station_offset ?? 0; + // Bend type: allow '45degree' only for sh2024 panel; otherwise fall back to 'rightangle' + const rawBend = branch_info?.bend_type; + const bendType = rawBend === '45degree' && info_panel_type !== 'sh2024' ? 'rightangle' : (rawBend ?? 'rightangle'); + const alignBranchEndpoints = sh2020?.align_endpoints ?? false; + + const adjMat = useMemo(() => { + return adjacencyList( + param.stn_list, + sh2020 ? createSh2020LeftW(k1) : () => 0, + sh2020 ? createSh2020RightW(k1) : () => 0 + ); + }, [JSON.stringify(param.stn_list), info_panel_type, k1]); const criticalPath = criticalPathMethod('linestart', 'lineend', adjMat); const realCP = criticalPathMethod(criticalPath.nodes[1], criticalPath.nodes.slice(-2)[0], adjMat); @@ -38,10 +70,48 @@ const MainSHMetro = () => { (param.svgWidth.railmap * param.padding) / 100, param.svgWidth.railmap * (1 - param.padding / 100), ]; - const xs = Object.keys(xShares).reduce( - (acc, cur) => ({ ...acc, [cur]: lineXs[0] + (xShares[cur] / realCP.len) * (lineXs[1] - lineXs[0]) }), - {} as typeof xShares - ); + const stationSpacing = (lineXs[1] - lineXs[0]) / realCP.len; + const branchOffset = k2 * stationSpacing; + + // Derive pixel x-positions from xShares, then optionally redistribute shorter + // parallel branch segments so their endpoints align with the longer segment. + const xs = useMemo(() => { + const result = Object.keys(xShares).reduce( + (acc, cur) => ({ ...acc, [cur]: lineXs[0] + (xShares[cur] / realCP.len) * (lineXs[1] - lineXs[0]) }), + {} as typeof xShares + ); + + // Align parallel branch endpoints: the segment with more exclusive stations + // defines the x-range; the shorter segment is redistributed to match. + if (alignBranchEndpoints) { + const mainLine = branches[0]; + + branches.slice(1).forEach(branch => { + const junctions = branch.filter(stnId => mainLine.includes(stnId)); + if (junctions.length < 2) return; + const [jStart, jEnd] = [junctions[0], junctions[junctions.length - 1]]; + + const mainExcl = mainLine.slice(mainLine.indexOf(jStart) + 1, mainLine.indexOf(jEnd)); + const branchExcl = branch.slice(branch.indexOf(jStart) + 1, branch.indexOf(jEnd)); + if (mainExcl.length < 1 || branchExcl.length < 1) return; + + const longer = mainExcl.length >= branchExcl.length ? mainExcl : branchExcl; + const shorter = mainExcl.length >= branchExcl.length ? branchExcl : mainExcl; + const firstX = result[longer[0]]; + const lastX = result[longer[longer.length - 1]]; + + if (shorter.length === 1) { + result[shorter[0]] = (firstX + lastX) / 2; + } else { + shorter.forEach((stnId, i) => { + result[stnId] = firstX + (i / (shorter.length - 1)) * (lastX - firstX); + }); + } + }); + } + + return result; + }, [xShares, lineXs[0], lineXs[1], realCP.len, alignBranchEndpoints, branches.toString()]); // const yShares = React.useMemo( // () => { @@ -125,8 +195,10 @@ const MainSHMetro = () => { direction, service, servicesPresent.length, - stn_list - // info_panel_type === 'sh2020' ? 'rightangle' : 'diagonal' + stn_list, + stnStates, + bendType, + branchOffset ) ) .filter(path => path !== ''), @@ -220,7 +292,9 @@ export const _linePath = ( services: Services, servicesMax: number, stn_list: StationDict, // only used to determine startFromTerminal or endAtTerminal - bend: 'rightangle' | 'diagonal' = 'rightangle' + stnStates?: { [stnId: string]: -1 | 0 | 1 }, // when provided, terminal caps on main paths are only drawn for state=1 stations + bend: 'rightangle' | 'diagonal' | '45degree' = 'rightangle', + branchOffset: number = 0 // k_2: offset from bifurcation point where vertical turn begins ) => { let [prevY, prevX] = [] as number[]; const path: { [key: string]: number[] } = {}; @@ -232,22 +306,24 @@ export const _linePath = ( }[services]; // TODO: enum Services could be a better idea? const servicesPassDelta = servicesMax > 1 ? 50 : 0; - // extra short line on either end - let e1 = 30; // check if path starts from or ends at the terminal - // and change e1 to 0 if it matches + let endAtTerminal = false; + let startFromTerminal = false; if (stnIds.length > 0) { - let startFromTerminal = false, - endAtTerminal = false; if (stn_list[stnIds.at(-1) || 0].children.some(stnId => ['linestart', 'lineend'].includes(stnId))) { endAtTerminal = true; - } else if (stn_list[stnIds.at(0) || 0].parents.some(stnId => ['linestart', 'lineend'].includes(stnId))) { + } + if (stn_list[stnIds.at(0) || 0].parents.some(stnId => ['linestart', 'lineend'].includes(stnId))) { startFromTerminal = true; } - e1 = startFromTerminal || endAtTerminal ? e1 : 0; } + // extra short line on either end (only at terminals) + const e1 = startFromTerminal || endAtTerminal ? 30 : 0; + // main-path terminal caps: only drawn when the terminal station is truly colored (state=1) + const startCap = startFromTerminal && stnStates?.[stnIds.at(0) ?? ''] === 1 ? e1 + servicesDelta : 0; + const endCap = endAtTerminal && stnStates?.[stnIds.at(-1) ?? ''] === 1 ? e1 + servicesDelta : 0; - // diagonal use e2 to make soft line + // smooth diagonal offset (used by 'diagonal' bend for coline) const e2 = 30; stnIds.forEach(stnId => { @@ -261,12 +337,12 @@ export const _linePath = ( if (y === 0) { // merge back to main line if (y !== prevY) { - path['bifurcate'] = [prevX, prevY]; + path['bifurcate'] = [prevX, prevY, x]; // [branchX, branchY, bifurcateX] } } else { // on the branch line if (y !== prevY) { - path['bifurcate'] = [x, y]; + path['bifurcate'] = [x, y, prevX]; // [branchX, branchY, bifurcateX] } } path['end'] = [x, y]; @@ -304,11 +380,7 @@ export const _linePath = ( const [x, y] = path['start'], h = path['end'][0]; if (type === 'main') { - if (direction === 'l') { - return `M ${x - e1 - servicesDelta},${y} H ${h}`; - } else { - return `M ${x},${y} H ${h + e1 + servicesDelta}`; - } + return `M ${x - startCap},${y} H ${h + endCap}`; } else { // type === 'pass' if (direction === 'l') { @@ -318,69 +390,68 @@ export const _linePath = ( } } } else { - // main line bifurcate here to become the branch line - // and path return here are only branch line - // keys in path: start, bifurcate, end - // TODO: make diagonal available to `sh` - + // branch line path, keys: start, bifurcate, end const [x, y] = path['start']; - const xb = path['bifurcate'][0]; + const xBranch = path['bifurcate'][0]; + const xBifurcate = path['bifurcate'][2]; const [xm, ym] = path['end']; + + // turnX: vertical turn position, offset from bifurcation point towards branch + // Offset moves turnX in the opposite direction of train travel (towards branch) + const branchOnRight = xBranch > xBifurcate; + const branchDirection = branchOnRight ? 1 : -1; + const turnX = xBifurcate + branchDirection * branchOffset; + + // For 45° diagonal: dx = |dy| + // Right branch: diagonal goes right from turnX → diagX = turnX + dy + // Left branch: diagonal arrives at turnX from left → diagX = turnX - dy + const dy = Math.abs(ym - y); + const diagX = turnX + branchDirection * dy; + // TODO: When bend === '45degree', k1 (branch_distance_factor) effectively absorbs one + // extra station-spacing worth of horizontal room — without it, diagX would overshoot + // xBranch (the first branch station) for a right-branch, which is nonsensical. + // Consequently, k1 values smaller than one station spacing are meaningless in this mode. + // Enforcing a proper lower bound on k1 for 45° bends is non-trivial under the current + // architecture (k1 feeds into the adjacency-matrix weights before x-positions are known), + // so this constraint is left as a known limitation for now. + + // For right-branch: diagonal STARTS at turnX → H turnX, L diagX + // For left-branch: diagonal ENDS at turnX → H diagX, L turnX + const diagH = branchOnRight ? turnX : diagX; // horizontal target (start y-level) + const diagL = branchOnRight ? diagX : turnX; // diagonal target (end y-level) + if (type === 'main') { - if (direction === 'l') { - if (ym > y) { - console.log(path); - // main line, left direction, center to upper - if (bend === 'rightangle') return `M ${x - e1},${y} H ${xm} V ${ym}`; - // center to upper/rightangle, lower to center/diagonal - else return `M ${x},${y} H ${x + e2} L ${xb - e2},${ym} H ${xm}`; - } else { - // wrong marker - // main line, left direction, upper to center - if (bend === 'rightangle') return `M ${x},${y} V ${ym} H ${xm}`; - // upper to center/rightangle, center to lower/diagonal - else return `M ${x - e1},${y} H ${xb + e2} L ${xm - e2},${ym} H ${xm}`; - } + if (bend === 'rightangle') { + return `M ${x - startCap},${y} H ${turnX} V ${ym} H ${xm + endCap}`; + } + if (bend === '45degree') { + return `M ${x - startCap},${y} H ${diagH} L ${diagL},${ym} H ${xm + endCap}`; + } + // diagonal — smooth free-form slope (used by coline) + if (ym > y) { + return `M ${x - startCap},${y} H ${x + e2} L ${xBranch - e2},${ym} H ${xm + endCap}`; } else { - if (ym > y) { - // wrong marker - // main line, right direction, upper to center - if (bend === 'rightangle') return `M ${x},${y} H ${xm} V ${ym}`; - // upper to center/rightangle, center to lower/diagonal - else return `M ${x},${y} H ${x + e2} L ${xb - e2},${ym} H ${xm + e1}`; - } else { - // main line, right direction, center to upper - if (bend === 'rightangle') return `M ${x},${y} V ${ym} H ${xm + e1}`; - // center to upper/rightangle, lower to center/diagonal - else return `M ${x},${y} H ${xb + e2} L ${xm - e2},${ym} H ${xm}`; - } + return `M ${x - startCap},${y} H ${xBranch + e2} L ${xm - e2},${ym} H ${xm + endCap}`; } } else { - // type === 'pass' - if (direction === 'l') { - if (ym > y) { - // pass line, left direction, center to upper - if (bend === 'rightangle') return `M ${x - e1},${y} H ${xm} V ${ym}`; - // center to upper/rightangle, lower to center/diagonal - else return `M ${x},${y} H ${x + e2} L ${xb - e2},${ym} H ${xm + e1}`; - } else { - // pass line, left direction, upper to center - if (bend === 'rightangle') return `M ${x},${y} V ${ym} H ${xm + e1}`; - // upper to center/rightangle, center to lower/diagonal - else return `M ${x - e1},${y} H ${xb + e2} L ${xm - e2},${ym} H ${xm}`; - } + // type === 'pass' — direction-independent, e1 is always applied + if (bend === 'rightangle') { + return ym > y + ? `M ${x - e1},${y} H ${turnX} V ${ym} H ${xm}` + : `M ${x},${y} H ${turnX} V ${ym} H ${xm + e1}`; + } + if (bend === '45degree') { + // diagH/diagL depend only on branchOnRight, not on ym direction; + // ym only determines which end carries the e1 terminal cap. + return ym > y + ? `M ${x - e1},${y} H ${diagH} L ${diagL},${ym} H ${xm}` + : `M ${x},${y} H ${diagH} L ${diagL},${ym} H ${xm + e1}`; + } + // diagonal — smooth free-form slope (used by coline) + if (ym > y) { + return `M ${x},${y} H ${x + e2} L ${xBranch - e2},${ym} H ${xm + e1}`; } else { - if (ym > y) { - // pass line, right direction, upper to center - if (bend === 'rightangle') return `M ${x - e1},${y} H ${xm} V ${ym}`; - // upper to center/rightangle, center to lower/diagonal - return `M ${x},${y} H ${x + e2} L ${xb - e2},${ym} H ${xm + e1}`; - } else { - // pass line, right direction, center to upper - if (bend === 'rightangle') return `M ${x},${y} V ${ym} H ${xm + e1}`; - // center to upper/rightangle, lower to center/diagonal - return `M ${x - e1},${y} H ${xb + e2} L ${xm - e2},${ym} H ${xm}`; - } + return `M ${x - e1},${y} H ${xBranch + e2} L ${xm - e2},${ym} H ${xm}`; } } } @@ -464,9 +535,10 @@ const ServicesElements = (props: { servicesLevel: Services[]; lineXs: number[] } }; export const DirectionElements = () => { - const { direction, svgWidth, coline } = useRootSelector(store => store.param); + const { direction, svgWidth, coline, info_panel_type } = useRootSelector(store => store.param); // arrow will be black stroke with white fill in coline const isColine = !!Object.keys(coline).length; + const isSh2024 = info_panel_type === PanelTypeShmetro.sh2024; return useMemo( () => ( @@ -474,15 +546,15 @@ export const DirectionElements = () => { 列车前进方向 ), - [direction, coline, svgWidth.railmap] + [direction, coline, svgWidth.railmap, info_panel_type] ); }; diff --git a/src/util/param-updater-utils.ts b/src/util/param-updater-utils.ts index 1bbc19ef9..97ed2a636 100644 --- a/src/util/param-updater-utils.ts +++ b/src/util/param-updater-utils.ts @@ -263,6 +263,17 @@ export const updateParam = (param: { [x: string]: any }) => { v5_18_addStationNameSpacingAndSvgWidthPlatform(param); v5_21_addPsdLabel(param); + // Migrate branchSpacingPct → branch_info + param.branch_info = { + spacing_pct: param.branchSpacingPct ?? 33, + distance_factor: 1, + first_station_offset: 0, + bend_type: 'rightangle', + align_endpoints: false, + ...param.branch_info, + }; + delete param.branchSpacingPct; + sanitiseParam(param); return param; };