/* ============================================================================= * common_util/common_util_route_follow.ts * 노선이 바뀔 때 관 · 구조물이 땅 위 자리(좌표)를 따라감 — 누가거리 다시 매김(344-2). * * 자리 정본 = 좌표(x · y) · 누가거리 = 그 자리를 지금 노선에서 읽은 값(정수 m · 234-1). * 짝: * - `projectPipePoints` ↔ 파이썬 `common_util_drainage_pipes.project_pipe_points`(xy 투영 + 정수 m · * 구간 끝 같은 만큼 밂 · 누가거리 순). * - `followStructures` ↔ 파이썬 `common_util_route_follow.follow_structures`(+ 0 ~ 연장 자름 · * 노선과 거리 · `STRUCTURE_ROUTE_DRIFT_MAX_M` 넘으면 「노선에서 멀어짐」 · 버리지 않음). * - `projectOnPolyline` = shapely `LineString.project`(가장 가까운 첫 선분) · `distance`. * - `placeOnLine` 좌표 낡음 판정 ↔ 파이썬 `fill_structure_coordinates`(좌표가 그 누가거리를 낳으면 살림 · * 아니면 누가거리의 노선 위 자리 · 410) · 거울 시험 = resources/tester/test_410_follow_xy.py. * - `fillStructureCoordinates` = 저장 때 좌표 맞춤 그 자체(옛 좌표 지킴 · 0.1 mm · 서버 Node `facility.fill_xy` · * 파이썬 짝 = Node 못 돌릴 때) · 노선 위 자리 `pointAtChainage` 는 `placeOnLine` 과 한 벌(511-B B-5). * - `placeOnLine` 의 좌표 없는 관 · `rebasePipeDraft` 는 TS 만 — 좌표 없는 세션 관 초안(정본 PUT 꼴)을 따라가기 * 전 자리로 · 노선을 저장한 [저장]이 서버가 따라 옮긴 관 위에 초안을 얹음(349-0u). * - `rebaseStructureDraft` 도 TS 만 — 구조물 초안을 서버가 따라 옮긴 정본 위에(459 · 관 짝). * - `followPlusRows` = 추가 측점 따라감 한 벌 — 서버도(Node 입구 `facility.plus_follow` · 472-4 ①) · * `followPlusGround` · `followPlusStations` = 노선 초안 동안(저장분 · 앞 걸음 좌표 들고 감 · 493-3). * - 끌기 여러 걸음 화면 자리 = 서버 [저장] 한 번 자리(관 · 구조물 · 추가 측점 · 493-3) — 거울 시험 = * resources/tester/test_493_3_follow_screen_save.py(짝 = `B05_Profile_Route_Save.followed_pipes` · * `B05_Profile_Structures_Router.followed_structures` · `B05_Profile_Plus_Stations.followed_plus_rows`). * - `routeOutside` ↔ 파이썬 `route_outside`(459 · 노선 밖 = 측점 계획 · 알약에서 뺌 · 좌표 그대로) — 따라감이 * `outside: true` 를 붙임(파생값 · 저장 때 뺌 · 서버는 측점 세울 때 다시 셈). * 거울 시험 = resources/tester/test_344_2_route_pairs.py. * ========================================================================== */ import { gridStations, SAME_STATION_M, snapExistingStation, snapStructureStation, } from "./common_util_structure_station"; /** 「노선에서 멀어짐」 거리(m) — 파이썬 `config_system.STRUCTURE_ROUTE_DRIFT_MAX_M` 짝(거울 시험이 대조). */ export const STRUCTURE_ROUTE_DRIFT_MAX_M = 20; /** 노선 밖 판정(m) — 투영이 시점 앞 · 종점 뒤로 이만큼 넘음(459 · 파이썬 `ROUTE_OUTSIDE_M` · 458 주인 측점 상한과 같은 값). */ export const ROUTE_OUTSIDE_M = 0.51; export interface XY { x: number; y: number; } /** 노선 위 자리 — 누가거리 · 노선과의 거리. 노선이 없으면(점 둘 미만 · 길이 0) null. */ export function projectOnPolyline( line: ReadonlyArray, point: XY, ): { chainage_m: number; distance_m: number; length_m: number } | null { let walked = 0; let best: { chainage_m: number; distance_m: number } | null = null; for (let index = 1; index < line.length; index += 1) { const a = line[index - 1]; const b = line[index]; const dx = b.x - a.x; const dy = b.y - a.y; const span = Math.hypot(dx, dy); if (span > 0) { const t = Math.min(1, Math.max(0, ((point.x - a.x) * dx + (point.y - a.y) * dy) / span ** 2)); const distance = Math.hypot(a.x + dx * t - point.x, a.y + dy * t - point.y); if (best === null || distance < best.distance_m) best = { chainage_m: walked + span * t, distance_m: distance }; } walked += span; } return best && walked > 0 ? { ...best, length_m: walked } : null; } /** 노선 밖인가(459) — 가장 가까운 자리가 시점 · 종점이고 그 끝 선분 연장으로 `tolM` 넘게 나감(옆으로 먼 것은 아님). * 파이썬 `route_outside` 짝. */ export function routeOutside( line: ReadonlyArray, point: XY, tolM: number = ROUTE_OUTSIDE_M, ): boolean { const hit = projectOnPolyline(line, point); if (!hit || (hit.chainage_m > 0 && hit.chainage_m < hit.length_m)) return false; const [a, b] = hit.chainage_m <= 0 ? [line[0], line[1]] : [line[line.length - 1], line[line.length - 2]]; const span = Math.hypot(b.x - a.x, b.y - a.y); if (!(span > 0)) return false; return -((point.x - a.x) * (b.x - a.x) + (point.y - a.y) * (b.y - a.y)) / span > tolM; } /** 노선 밖 표시를 다시 붙인 사본 — 좌표가 있고 노선 밖이면 `outside: true` · 아니면 칸 뺌(파생값 · 정본에 안 씀). */ function markOutside( item: T, line: ReadonlyArray, ): T { const { outside: _was, ...rest } = item as T & { outside?: boolean }; return item.x != null && item.y != null && routeOutside(line, { x: item.x, y: item.y }) ? ({ ...rest, outside: true } as unknown as T) : (rest as T); } /** 좌표가 그 누가거리를 낳는가 — `line` 에서 따라감(같은 선 = 제자리)이 누가거리를 안 옮김(410 · 파이썬 * `fill_structure_coordinates` 같은 규칙). 아니면 좌표가 낡음(마커 · 방향키 · 폼으로 누가거리만 옮김). */ const holdsStation = ( item: FollowingStructure, line: ReadonlyArray, intervalM: number | null, ): boolean => { const [moved] = followStructures([item], line, Infinity, intervalM); return ( moved.drift_m === null || Math.abs(moved.item.chainage_m! - item.chainage_m!) <= SAME_STATION_M ); }; /** 관 · 구조물에 자리를 붙인 사본 — 좌표가 그 누가거리를 낳으면 그대로(같은 객체 · 410) · 좌표 없음(세션 관 * 초안 = 정본 PUT 꼴 · 좌표 뺌)이면 같은 id · 같은 누가거리인 `saved`(정본 · 앞 따라감 · 좌표 있음)의 좌표(안 * 옮긴 것 = 서버 따라감과 같은 자리) · 그 밖(옮김 · 좌표 낡음)은 그 누가거리의 `line` 위 자리(연장 밖은 끝 선분 * 위로 자름 = 옮긴 자리가 새 기준). 누가거리가 없으면 그대로. `intervalM` = site 맞춤 간격(`followStructures`). */ export function placeOnLine( items: ReadonlyArray, line: ReadonlyArray, saved: ReadonlyArray = [], intervalM: number | null = null, ): S[] { const byId = new Map(saved.map((item) => [item.id, item])); return items.map((item) => { if (item.chainage_m == null) return item; const placed = item.x != null && item.y != null; if (placed && holdsStation(item, line, intervalM)) return item; const was = !placed && item.id ? byId.get(item.id) : undefined; if ( was?.x != null && was.y != null && was.chainage_m != null && Math.abs(was.chainage_m - item.chainage_m) <= SAME_STATION_M ) return { ...item, x: was.x, y: was.y }; const at = pointAtChainage(line, item.chainage_m); return at ? { ...item, ...at } : item; }); } /** 누가거리의 노선 위 자리 — shapely `LineString.interpolate` 꼴(선분 길이 √ · 넘으면 끝점 · 음수 = 끝에서) + * 0.1 mm(파이썬 `round(·, 4)` · F1 저장 좌표 맞춤과 같은 값 · 511-B B-5). 노선 없음(점 둘 미만 · 길이 0) = null. */ export function pointAtChainage(line: ReadonlyArray, chainageM: number): XY | null { const spans = line.slice(1).map((b, index) => { const a = line[index]; return Math.sqrt((b.x - a.x) ** 2 + (b.y - a.y) ** 2); }); const length = spans.reduce((sum, span) => sum + span, 0); if (!(length > 0)) return null; const target = chainageM < 0 ? Math.max(0, length + chainageM) : chainageM; // ponytail: toFixed = 딱 반(홀수/32 m)에서 큰 쪽 · 파이썬 round 는 짝수 쪽 — 실측 좌표엔 안 생김 const round4 = (value: number) => Number(value.toFixed(4)); let walked = 0; for (let index = 0; index < spans.length; index += 1) { if (walked + spans[index] > target) { const a = line[index]; const b = line[index + 1]; const t = Math.max(0, (target - walked) / spans[index]); return { x: round4((b.x - a.x) * t + a.x), y: round4((b.y - a.y) * t + a.y) }; } walked += spans[index]; } const end = line[line.length - 1]; return { x: round4(end.x), y: round4(end.y) }; } type Positioned = FollowingStructure & { structure_id?: string | null }; /** 저장 때 구조물 좌표 맞춤(F1 · 511-B B-5 · 파이썬 `fill_structure_coordinates` 같은 규칙 · 서버 Node * `facility.fill_xy`) — 누가거리 없음 · `onlyMissing` 에 좌표 있음 = 그대로 · `previous`(저장 전 정본 · * structure_id)와 자리 셋(기준 · 시작 · 끝)이 같고 옛 좌표 있음 = 옛 좌표 · 좌표가 그 누가거리를 낳음 = 그대로 · * 그 밖 = 노선 위 자리(`pointAtChainage`). 돌려줌 = 항목마다 새 좌표(안 바뀜 null) · 채운 수. */ export function fillStructureCoordinates( items: ReadonlyArray, line: ReadonlyArray, previous: Readonly> | null = null, onlyMissing = false, intervalM: number | null = null, ): { xy: Array; filled: number } { const total = line .slice(1) .reduce((sum, b, index) => sum + Math.hypot(b.x - line[index].x, b.y - line[index].y), 0); if (line.length < 2 || !(total > 0)) return { xy: items.map(() => null), filled: 0 }; const same = (a: Positioned, b: Positioned) => (a.chainage_m ?? null) === (b.chainage_m ?? null) && (a.start_m ?? null) === (b.start_m ?? null) && (a.end_m ?? null) === (b.end_m ?? null); let filled = 0; const xy = items.map((item): XY | null => { if (item.chainage_m == null) return null; const placed = item.x != null && item.y != null; if (onlyMissing && placed) return null; const old = previous?.[item.structure_id ?? ""]; if (old && same(old, item) && old.x != null) return { x: old.x, y: old.y! }; if (placed && holdsStation(item, line, intervalM)) return null; const at = pointAtChainage(line, item.chainage_m); if (at) filled += 1; return at; }); return { xy, filled }; } /** 노선을 저장한 [저장] — 세션 관 초안(목록 한 벌)을 서버가 따라 옮긴 관(`after`) 위에 얹은 목록(누가거리 순). * 초안 관이 노선 저장 전 정본(`before`) 자리 그대로면(따라감 전 값) · 따라가기만 했으면(땅 위 자리 = 정본 x · y · * 누가거리만 화면 계산 · 428) 서버 자리로(구간 끝 · 좌표도) · 사용자가 옮긴 · 새로 놓은 관은 그대로 · 서버가 새로 * 놓은 관(옛 정본에 없던 id)은 붙임 · 짝은 id. */ export function rebasePipeDraft

( draft: ReadonlyArray

, before: ReadonlyArray, after: ReadonlyArray

, ): P[] { const old = new Map(before.map((pipe) => [pipe.id, pipe])); const moved = new Map(after.map((pipe) => [pipe.id, pipe])); const mine = new Set(draft.map((pipe) => pipe.id)); const kept = draft.map((pipe) => { const was = pipe.id ? old.get(pipe.id) : undefined; const now = pipe.id ? moved.get(pipe.id) : undefined; const followed = pipe.x != null && pipe.y != null && was?.x != null && was.y != null && Math.hypot(pipe.x - was.x, pipe.y - was.y) <= FOLLOW_SAME_XY_M; if (!was || !now || (!followed && Math.abs(pipe.chainage_m - was.chainage_m) > SAME_STATION_M)) return pipe; const shift = now.chainage_m - pipe.chainage_m; return { ...pipe, chainage_m: now.chainage_m, ...(pipe.start_m != null ? { start_m: pipe.start_m + shift } : {}), ...(pipe.end_m != null ? { end_m: pipe.end_m + shift } : {}), ...(now.x != null && now.y != null ? { x: now.x, y: now.y } : {}), }; }); const added = after.filter((pipe) => pipe.id && !old.has(pipe.id) && !mine.has(pipe.id)); return [...kept, ...added].sort((a, b) => a.chainage_m - b.chainage_m); } type StructureLike = FollowingStructure & { structure_id?: string | null }; /** 자리 셋(기준 · 구간 끝)이 같은가 — `shift` 만큼 민 자리로 견줌(구간 길이 = 시설 치수). */ const samePositions = (a: FollowingStructure, b: FollowingStructure, shift = 0): boolean => (["chainage_m", "start_m", "end_m"] as const).every( (key) => (a[key] == null) === (b[key] == null) && Math.abs((a[key] ?? 0) + shift - (b[key] ?? 0)) <= SAME_STATION_M, ); /** 노선을 저장한 [저장] — 세션 구조물 초안을 서버가 따라 옮긴 정본(`after`) 위에 얹은 목록(459 · 관 * `rebasePipeDraft` 짝). 따라가기만 한 것(자리 = 노선 저장 전 정본 `before` 그대로 · 또는 땅 위 자리가 같고 * 기준 누가거리가 서버 따라감과 같음)은 서버 자리(기준 · 구간 끝 · 좌표) · 사용자가 옮긴 것(마커 · 폼 = * 누가거리만 옮겨 좌표 낡음 포함) · 새로 놓은 것은 초안 그대로 · 옵션 · 메모는 늘 초안 · 짝은 `structure_id`. */ export function rebaseStructureDraft( draft: ReadonlyArray, before: ReadonlyArray, after: ReadonlyArray, ): S[] { const old = new Map(before.map((item) => [item.structure_id, item])); const moved = new Map(after.map((item) => [item.structure_id, item])); return draft.map((item) => { const was = item.structure_id ? old.get(item.structure_id) : undefined; const now = item.structure_id ? moved.get(item.structure_id) : undefined; if (!was || !now) return item; const followed = item.x != null && item.y != null && was.x != null && was.y != null && Math.hypot(item.x - was.x, item.y - was.y) <= FOLLOW_SAME_XY_M && samePositions(was, item, (item.chainage_m ?? 0) - (was.chainage_m ?? 0)) && Math.abs((item.chainage_m ?? 0) - (now.chainage_m ?? 0)) <= SAME_STATION_M; if (!followed && !samePositions(item, was)) return item; return { ...item, chainage_m: now.chainage_m ?? item.chainage_m, start_m: now.start_m ?? null, end_m: now.end_m ?? null, x: now.x ?? item.x, y: now.y ?? item.y, }; }); } /** 땅 위 자리(x · y)가 있는 추가 측점이 새 선(`to`)을 따라감 — 정수 m · 누가거리 순 · 같은 m 하나(앞엣것) · * 좌표 그대로. 서버 정본(`plus_stations.json`)도 이 셈(Node 입구 `facility.plus_follow` · 472-4 ①). */ export function followPlusRows( rows: ReadonlyArray, to: ReadonlyArray, ): R[] { const seen = new Set(); return followStructures(rows, to) .map(({ item }) => item) .sort((a, b) => a.chainage_m! - b.chainage_m!) .filter((item) => !seen.has(item.chainage_m!) && seen.add(item.chainage_m!)); } /** 추가 측점이 노선을 따라감(459) — 땅 위 자리를 새 선(`to`)에 투영한 정수 m 줄(좌표 그대로 · 같은 m 하나 · * 오름차순). 땅 위 자리 = 같은 누가거리인 `ground`(저장분 · 앞 걸음 따라감)의 좌표 · 없으면 옛 선(`from`) 위 * 누가거리 자리. 걸음마다 정수 m 에서 다시 놓으면 끌기 걸음 수만큼 밀림(493-3 · 서버 = 정본 좌표에서 한 번). */ export function followPlusGround( list: ReadonlyArray, from: ReadonlyArray, to: ReadonlyArray, ground: ReadonlyArray = [], ): FollowingStructure[] { const held = new Map( ground .filter((row) => row.chainage_m != null && row.x != null && row.y != null) .map((row) => [row.chainage_m!, row]), ); const placed = list.map((chainage_m) => { const was = held.get(chainage_m); return was ? { chainage_m, x: was.x, y: was.y } : placeOnLine([{ chainage_m } as FollowingStructure], from)[0]; }); return followPlusRows(placed, to); } /** `followPlusGround` 의 누가거리만. */ export const followPlusStations = ( list: ReadonlyArray, from: ReadonlyArray, to: ReadonlyArray, ground: ReadonlyArray = [], ): number[] => followPlusGround(list, from, to, ground).map((item) => item.chainage_m!); /** 따라가기만 한 관 = 정본과 같은 땅 위 자리(m) — 서버 좌표가 mm 3자리라 그 반올림 폭. */ const FOLLOW_SAME_XY_M = 0.001; export interface FollowingPoint { chainage_m: number; x?: number | null; y?: number | null; start_m?: number | null; end_m?: number | null; } /** 저장 좌표를 새 노선에 투영해 누가거리를 다시 매긴 사본(누가거리 순) — 관 지점. */ export function projectPipePoints

( points: ReadonlyArray

, line: ReadonlyArray, ): P[] { const length = line.reduce( (sum, point, index) => index ? sum + Math.hypot(point.x - line[index - 1].x, point.y - line[index - 1].y) : 0, 0, ); if (!(length > 0)) return [...points]; // 파이썬도 노선이 없으면 그대로(차례도) return points .map((point) => { if (point.x == null || point.y == null) return { ...point }; const hit = projectOnPolyline(line, { x: point.x, y: point.y })!; const moved = snapStructureStation(hit.chainage_m, [hit.length_m]); const shift = moved - point.chainage_m; return markOutside( { ...point, chainage_m: moved, ...(point.start_m != null ? { start_m: point.start_m + shift } : {}), ...(point.end_m != null ? { end_m: point.end_m + shift } : {}), }, line, ); }) .sort((a, b) => a.chainage_m - b.chainage_m); } export interface FollowingStructure { placement?: string | null; chainage_m?: number | null; start_m?: number | null; end_m?: number | null; x?: number | null; y?: number | null; } /** 구조물이 좌표를 따라감 — 사본 · 노선과 거리(좌표 없으면 null) · 멀어짐(차례는 그대로) · * `intervalM` 주면 site 꼴(사토장 · 토취장) = 새 노선 일반 측점 중 가장 가까운 곳(380-3 · 파이썬은 서버 간격). */ export function followStructures( items: ReadonlyArray, line: ReadonlyArray, maxDriftM: number = STRUCTURE_ROUTE_DRIFT_MAX_M, intervalM: number | null = null, ): Array<{ item: S; drift_m: number | null; far: boolean }> { return items.map((item) => { const hit = item.x != null && item.y != null ? projectOnPolyline(line, { x: item.x, y: item.y }) : null; if (hit === null || item.chainage_m == null) return { item: { ...item }, drift_m: null, far: false }; const target = intervalM && item.placement === "site" ? snapExistingStation(hit.chainage_m, gridStations(hit.length_m, intervalM)) : snapStructureStation(hit.chainage_m, [hit.length_m]); const shift = target - item.chainage_m; const clamp = (value: number): number => Math.min(Math.max(value + shift, 0), hit.length_m); return { item: markOutside( { ...item, chainage_m: clamp(item.chainage_m), ...(item.start_m != null ? { start_m: clamp(item.start_m) } : {}), ...(item.end_m != null ? { end_m: clamp(item.end_m) } : {}), }, line, ), drift_m: hit.distance_m, far: hit.distance_m > maxDriftM, }; }); } /** 묶음 follow 입력 꼴 — 목록이면 `placeOnLine`(앞 노선 꼭짓점 `[x, y]`) · 아니면(null 등) 그대로. */ export const placedOnLine = ( list: unknown, line: ReadonlyArray<[number, number]>, saved: FollowingStructure[], intervalM: number | null = null, ): unknown => Array.isArray(list) ? placeOnLine( list as FollowingStructure[], line.map(([x, y]) => ({ x, y })), saved, intervalM, ) : list;