Files
Aislo/D03_PlanAnalysis/logic/D03_PlanAnalysis_Span.ts
T
eomsangdonandClaude Opus 5.5 2a67a7092d feat(774-1): 다시 긋기 = 중간 노드 고름으로 켬 · 다른 중간 노드에 이어야 끝
- 노드 속성 판 · 우클릭 = 중간 노드에 [다시 긋기](선 토막 고름의 [다시 긋기] 뺌) · B05 긋기 중 중간 노드 누름 = 그대로 켬
- 끝 = 같은 선 다른 중간 노드만(rejoinMiss) · 시작 노드 · 끝점 = 못 찍음 + 안내(계속 긋기) · 빈 곳 = 점 찍고 계속
- 고리: 시작 노드 · 이을 중간 노드 · 마우스 올린 중간 노드(paintRejoin · --draw-map-*) · 옛 구간 흐림(dim) 뺌
- 시험: test_771_2 에 끝 판정 · 중간 노드 대조 더함

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 20:31:21 +09:00

144 lines
6.8 KiB
TypeScript

/* =============================================================================
* D03_PlanAnalysis_Span.ts
* D 예정노선 다시 긋기(PLAN 599-3 · 요구 463-12) — 선 하나의 두 꺾임점 사이를 새로 그은 점들로 바꿈.
* 중간 노드에서 긋기(771-2 · 요구 613) = `rejoinSpan` — 닿은 노드가 앞이면 거꾸로 · 같은 노드 = 까닭 글.
* 끝 판정(774 · 요구 616) = `rejoinMiss` — 다른 중간 노드만 끝 · 시작 노드 · 끝점 = 계속 긋기.
* 끝 두 점(앞 노드 · 뒤 노드) 그대로 · 속 꺾임점에 갈래가 붙었으면 막음(까닭 글) · 뒤 갈래 차례 밈 ·
* 마크(조각 경계) = 바뀐 구간 안은 길이 비율 · 뒤는 길이 차만큼 · 조각 id 그대로(업체 열쇠).
* 노드 기록(599-2) — 그리기 칸 값 → 기록 · 옮긴 노드 어긋남(⚠) · 다시 그은 속 노드 = 그때 기록 · 뒤 노드 = 자유.
* DOM · fetch · sessionStorage 없음 · 길이 셈 = 562-1 짝(`D03_PlanAnalysis_Draw`).
* ========================================================================== */
import { groundSlopePct, lineLength, segmentLength, type HeightAt } from "./D03_PlanAnalysis_Draw";
import type { NodeKind, NodeRecord, PlanLine, PlanNetwork, Vertex } from "./D03_PlanAnalysis_Plan";
const round3 = (value: number): number => Math.round(value * 1000) / 1000;
/** 그리기 칸 방식 · 값 → 노드 기록(안 쓴 값 = null · 599-2). */
export function nodeRecord(kind: NodeKind, lengthM: number, slopePct: number): NodeRecord {
return {
kind,
length_m: kind === "length" || kind === "both" ? lengthM : null,
slope_pct: kind === "slope" || kind === "both" ? slopePct : null,
};
}
/** 노드 기록과 지금 앞 토막이 어긋남(옮긴 노드 ⚠ · 599-2) — 길이 1 cm · |기울기| 0.1 % 넘게 · 지형 밖 = 안 봄. */
export function nodeOff(line: PlanLine, vertex: number, z: HeightAt): boolean {
const record = line.nodes?.[vertex];
if (!record || vertex < 1) return false;
const [a, b] = [line.vertices[vertex - 1], line.vertices[vertex]];
if (record.length_m !== null && Math.abs(segmentLength(a, b) - record.length_m) > 0.01)
return true;
const slope = record.slope_pct === null ? null : groundSlopePct(a, b, z);
return slope !== null && Math.abs(Math.abs(slope) - Math.abs(record.slope_pct!)) > 0.1;
}
/** `line` 째 선 `from`~`to` 꺾임점 사이를 다시 그을 수 있나 — 못 하면 까닭 글. */
export function respanBlocked(
network: PlanNetwork,
line: number,
from: number,
to: number,
): string | null {
const entry = network.lines[line];
if (!entry || !(from >= 0 && to > from && to < entry.vertices.length))
return "다시 그을 구간을 고르세요.";
if (
network.lines.some(
(o) => o.branch?.line === entry.id && o.branch.vertex > from && o.branch.vertex < to,
)
)
return "구간 속 점에 갈래가 붙어 있습니다 — 갈래를 먼저 지우세요.";
return null;
}
/** `line` 째 선 `from`~`to` 꺾임점 사이 속 점을 `through` 로 바꿈(끝 두 점 그대로) — 막히면 까닭 글.
* `records` = 속 점 기록(`through` 와 같은 길이) · 뒤 노드 기록 = 자유 · 기록 없는 선 + 기록 없음 = 그대로 없음. */
export function replaceSpan(
network: PlanNetwork,
line: number,
from: number,
to: number,
through: ReadonlyArray<Vertex>,
records: ReadonlyArray<NodeRecord | null> = [],
): PlanNetwork | string {
const blocked = respanBlocked(network, line, from, to);
if (blocked) return blocked;
const entry = network.lines[line];
const old = entry.vertices;
const vertices: Vertex[] = [
...old.slice(0, from + 1),
...through.map(([x, y]): Vertex => [x, y]),
...old.slice(to),
];
const shift = through.length - (to - from - 1);
const at = (list: ReadonlyArray<Vertex>, i: number): number => lineLength(list.slice(0, i + 1));
const [startM, oldEndM, newEndM] = [at(old, from), at(old, to), at(vertices, to + shift)];
const carry = (m: number): number =>
m <= startM
? m
: m >= oldEndM
? m - oldEndM + newEndM
: startM + ((m - startM) / (oldEndM - startM)) * (newEndM - startM);
const branched = (other: PlanLine): PlanLine =>
other.branch?.line === entry.id && other.branch.vertex >= to
? { ...other, branch: { ...other.branch, vertex: other.branch.vertex + shift } }
: other;
return {
lines: network.lines.map((other, i) => {
if (i !== line) return branched(other);
if (!other.nodes && !records.some(Boolean)) return { ...other, vertices };
const nodes = other.nodes ?? old.map(() => null);
const inner = through.map((_, k) => records[k] ?? null);
const end = nodeRecord("free", 0, 0);
return {
...other,
vertices,
nodes: [...nodes.slice(0, from + 1), ...inner, end, ...nodes.slice(to + 1)],
};
}),
pieces: network.pieces.map((piece) =>
piece.line === entry.id
? { ...piece, from_m: round3(carry(piece.from_m)), to_m: round3(carry(piece.to_m)) }
: piece,
),
};
}
export const REJOIN_SAME =
"같은 노드에 닿아 반영하지 않았습니다 — 다른 노드에 닿으면 그 사이가 바뀝니다.";
/** 중간 노드에서 긋기(771-2) — `start` 노드에서 그은 `through` 가 `end` 노드에 닿음 = 두 노드 사이 갈음.
* 거꾸로 닿음(`end` < `start`) = 점 차례를 뒤집어 넣음(속 점 기록 = 선 차례 앞 토막 몫으로 밈) ·
* 같은 노드 = 까닭 글 · 그 밖 = `replaceSpan` 과 같음. */
export function rejoinSpan(
network: PlanNetwork,
line: number,
start: number,
end: number,
through: ReadonlyArray<Vertex>,
records: ReadonlyArray<NodeRecord | null> = [],
): PlanNetwork | string {
if (start === end) return REJOIN_SAME;
if (start < end) return replaceSpan(network, line, start, end, through, records);
const back = [...records].reverse();
return replaceSpan(network, line, end, start, [...through].reverse(), [
null,
...back.slice(0, -1),
]);
}
/** 다시 긋기 시작 · 끝 자리 = 중간 노드(774 · 요구 616) — 꼭짓점 `count` 개 선의 `vertex` 가 끝점 아님. */
export const isMiddleNode = (count: number, vertex: number): boolean =>
vertex > 0 && vertex < count - 1;
export const REJOIN_TIP = "끝점은 끝이 아닙니다 — 다른 중간 노드에 이어야 끝납니다.";
export const REJOIN_HOLD = "시작 노드는 끝이 아닙니다 — 다른 중간 노드에 이어야 끝납니다.";
/** 다시 긋기 끝 판정(774) — 닿은 `vertex` 가 다른 중간 노드 = null(끝) · 시작 노드 · 끝점 = 계속 긋기 까닭 글. */
export function rejoinMiss(count: number, start: number, vertex: number): string | null {
if (vertex === start) return REJOIN_HOLD;
return isMiddleNode(count, vertex) ? null : REJOIN_TIP;
}