Files
eomsangdonandClaude Opus 5.5 cc909d107e feat(D03): 연도 구역 마커 끌어 놓기 · 선 밖으로 끌어내 지움 · 긋는 해 걷음 · 갈래 시점 지정 (PLAN 585-3 · 요구 445 A6 · 446)
- 도구줄 「연도 마커」(공용 지도 도구줄 year-mark · 깃발 아이콘)를 선 위로 끌어 놓음 = 그 자리 구간을 둘로 — 구역만 나눔 · 해 그대로
- 끄는 동안 마름모 미리보기 · 선 끝 · 다른 마커 1 m 안 · 선 밖 = 경고 색 · 못 놓음
- 놓인 마커 끌기 = 경계 옮김 · 선 밖으로 끌어냄 · 오른쪽 누름 · Delete = 지움(앞 구간과 합침) · 같은 해 양쪽 = 속 빈 마름모
- 좌측 [연도 구분] · 「긋는 해」 걷음 · 새 선 = 사업연도 시작 해 · 새 갈래 = 붙은 자리 구간 해
- 속성 판 [갈래 시점 지정] = 다른 선에 닿은 끝을 시점(No.0)으로 갈래 · 꼬리에 닿은 머리 = 한 선 · 본선 = [방향 바꾸기]
- 시험: 585-2 시험에 나눔 · 합침 · 갈래 해 · 갈래 시점 덧 · 530-3 갈래 해 · 553-4 속성 판 · 568-1 도구줄 목록 맞춤

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 18:51:04 +09:00

617 lines
24 KiB
TypeScript

/* =============================================================================
* D03_PlanAnalysis_Plan.ts
* D 예정노선 2단계 손 긋기 꼴(PLAN 562-3 · 580) — 그은 선 · 갈래 · 조각(구역 × 연도) 다루기 ·
* 선 뒤집기 · 잇기 · 꺾임점 옮김 · 지움 · 선 지움 · 선 통째 연도 · 마크 옮김 · 여러 조각 연도 · 끌어 고르기 판정(585-2) ·
* 갈래 시점 지정 · 갈래 처음 해 = 붙은 자리 구간 해(585-3).
* DOM · fetch · sessionStorage 없음 · 길이 셈 = 562-1 짝(`D03_PlanAnalysis_Draw`).
* [저장] 은 서버가 선 · 조각으로 연도 행을 다시 셈(562-2) — 여기 값을 받아 적지 않음.
* ========================================================================== */
import { lineLength, segmentLength, sliceLine } from "./D03_PlanAnalysis_Draw";
export type Vertex = [number, number];
/** 그은 선 하나 — `branch` 없음 = 본선(시점 · 종점에서) · 있음 = 갈래(붙은 선 · 꺾임점 차례) ·
* 꼭짓점 차례 = 측점 차례(첫 점 = No.0). */
export interface PlanLine {
id: string;
vertices: Vertex[];
branch: { line: string; vertex: number } | null;
}
/** 조각 = 선(id) 위 [from_m, to_m] 구역 · 연도(구역 마크 = 조각 경계 · id = 업체 지정이 붙는 열쇠라 나눠도 앞 조각이 지님). */
export interface PlanPiece {
id: string;
line: string;
from_m: number;
to_m: number;
year: number;
}
export interface PlanNetwork {
lines: PlanLine[];
pieces: PlanPiece[];
}
/** 마크가 끝 · 다른 마크와 이보다 가까우면 안 놓음(m). */
export const MARK_GAP_M = 1;
/** 같은 점(m) — 서버 1 mm 와 같음. */
const SAME_M = 1e-3;
const round3 = (value: number): number => Math.round(value * 1000) / 1000;
export const samePoint = (a: Vertex, b: Vertex): boolean => segmentLength(a, b) <= SAME_M;
/** 새 조각 id — `p<n>`(있는 것 중 가장 큰 수 + 1). */
function nextPieceId(pieces: ReadonlyArray<PlanPiece>): string {
const used = pieces.map((piece) => Number(/^p(\d+)$/.exec(piece.id)?.[1] ?? 0));
return `p${Math.max(0, ...used) + 1}`;
}
/** 빈 꼴 — 선 없음(긋기 = 시점 · 종점 · 선에서 시작). */
export function emptyNetwork(): PlanNetwork {
return { lines: [], pieces: [] };
}
/** 새 선 id — 처음 본선 = `main` · 그 밖 `<prefix>-<n>`. */
function nextLineId(lines: ReadonlyArray<PlanLine>, prefix: "line" | "branch"): string {
const used = new Set(lines.map((entry) => entry.id));
if (prefix === "line" && !used.has("main")) return "main";
let n = lines.length;
while (used.has(`${prefix}-${n}`)) n += 1;
return `${prefix}-${n}`;
}
/** 새 본선 — `point` 한 점(끝에 꺾임점을 더해 감). */
export function newLine(network: PlanNetwork, point: Vertex): PlanNetwork {
const line: PlanLine = {
id: nextLineId(network.lines, "line"),
vertices: [[point[0], point[1]]],
branch: null,
};
return { ...network, lines: [...network.lines, line] };
}
/** 새 갈래 처음 해(585-3) = 붙은 자리 구간 해 · 붙은 자리 못 찾음 = null. */
function branchYear(network: PlanNetwork, line: PlanLine): number | null {
const parent = network.lines.find((other) => other.id === line.branch?.line);
if (!parent || !line.branch) return null;
const atM = lineLength(parent.vertices.slice(0, line.branch.vertex + 1));
return (
network.pieces.find(
(piece) =>
piece.line === parent.id && atM >= piece.from_m - SAME_M && atM <= piece.to_m + SAME_M,
)?.year ?? null
);
}
/** 선마다 조각이 0 ~ 길이를 빈틈없이 덮게 맞춤 — 경계 = 조각 시작(0 · 끝 근처 빼고) · 연도 = 그 자리 조각 ·
* 조각 없는 선 = 한 조각(`year`). 길이 0 선은 조각 없음. */
export function normalizePieces(network: PlanNetwork, year: number): PlanNetwork {
const pieces: PlanPiece[] = [];
for (const line of network.lines) {
const length = round3(lineLength(line.vertices));
if (!(length > 0)) continue;
const own = network.pieces
.filter((piece) => piece.line === line.id)
.sort((a, b) => a.from_m - b.from_m);
const starts = own
.filter((piece) => piece.from_m > MARK_GAP_M / 2 && piece.from_m < length - MARK_GAP_M / 2)
.map((piece) => ({ id: piece.id, from: round3(piece.from_m), year: piece.year }));
const head = own.find((piece) => piece.from_m <= MARK_GAP_M / 2) ?? {
id: "",
year: own.length ? year : (branchYear(network, line) ?? year),
};
const cuts = [{ id: head.id, from: 0, year: head.year }, ...starts];
cuts.forEach((cut, i) =>
pieces.push({
// id 빈 칸 · 연도 없음(서버 마크만 있던 구역) = 새 id · 처음 연도
id: cut.id || nextPieceId([...network.pieces, ...pieces]),
line: line.id,
from_m: cut.from,
to_m: i + 1 < cuts.length ? cuts[i + 1].from : length,
year: Number.isFinite(cut.year) ? cut.year : year,
}),
);
}
return { ...network, pieces };
}
/** 선 위 `atM` 에 마크 — 그 자리 조각을 둘로(같은 연도) · 끝 · 경계와 가까우면 그대로. */
export function splitPiece(network: PlanNetwork, line: string, atM: number): PlanNetwork {
const index = network.pieces.findIndex(
(piece) => piece.line === line && atM > piece.from_m && atM < piece.to_m,
);
const piece = network.pieces[index];
if (!piece || atM - piece.from_m < MARK_GAP_M || piece.to_m - atM < MARK_GAP_M) return network;
const at = round3(atM);
const pieces = [...network.pieces];
pieces.splice(
index,
1,
{ ...piece, to_m: at },
{ ...piece, id: nextPieceId(pieces), from_m: at },
);
return { ...network, pieces };
}
/** `atM` 둘레 `toleranceM` 안 마크를 지움 — 두 조각을 합침(앞 조각 연도). 없으면 그대로. */
export function removeMark(
network: PlanNetwork,
line: string,
atM: number,
toleranceM: number,
): PlanNetwork {
const index = network.pieces.findIndex(
(piece) =>
piece.line === line && piece.from_m > 0 && Math.abs(piece.from_m - atM) <= toleranceM,
);
if (index < 1 || network.pieces[index - 1].line !== line) return network;
const pieces = [...network.pieces];
const before = pieces[index - 1];
pieces.splice(index - 1, 2, { ...before, to_m: pieces[index].to_m });
return { ...network, pieces };
}
/** 조각 연도 바꿈. */
export function setPieceYear(network: PlanNetwork, index: number, year: number): PlanNetwork {
return {
...network,
pieces: network.pieces.map((piece, i) => (i === index ? { ...piece, year } : piece)),
};
}
/** `line` 째 선의 `segment` 번째 구간 안에 꺾임점을 끼움 — 그 선에 붙은 갈래 차례를 한 칸 밈.
* 선 위 점이라 누가 · 조각은 그대로. */
export function insertVertex(
network: PlanNetwork,
line: number,
segment: number,
point: Vertex,
): PlanNetwork {
const id = network.lines[line].id;
return {
...network,
lines: network.lines.map((entry, i) => {
if (i === line) {
const vertices = [...entry.vertices];
vertices.splice(segment + 1, 0, point);
return { ...entry, vertices };
}
const branch = entry.branch;
return branch && branch.line === id && branch.vertex > segment
? { ...entry, branch: { ...branch, vertex: branch.vertex + 1 } }
: entry;
}),
};
}
/** 선 끝에 점을 더함. */
export function appendVertex(network: PlanNetwork, line: number, point: Vertex): PlanNetwork {
return {
...network,
lines: network.lines.map((entry, i) =>
i === line ? { ...entry, vertices: [...entry.vertices, point] } : entry,
),
};
}
/** 선 끝 점을 뺌 — 첫 점(본선 시점 · 갈래 붙은 점)은 못 뺌 · 갈래가 붙은 점도 못 뺌(까닭 글). */
export function dropLastVertex(network: PlanNetwork, line: number): PlanNetwork | string {
const entry = network.lines[line];
if (!entry || entry.vertices.length < 2) return "더 뺄 점이 없습니다.";
const last = entry.vertices.length - 1;
if (
network.lines.some((other) => other.branch?.line === entry.id && other.branch.vertex === last)
)
return "갈래가 붙은 점은 뺄 수 없습니다 — 갈래를 먼저 지우세요.";
return {
...network,
lines: network.lines.map((other, i) =>
i === line ? { ...other, vertices: other.vertices.slice(0, last) } : other,
),
};
}
/** 마지막 선(그리던 갈래)을 지움 — 그 선 조각도. 갈래가 붙어 있으면 그대로. */
export function dropLastLine(network: PlanNetwork): PlanNetwork {
const index = network.lines.length - 1;
const id = network.lines[index]?.id;
if (index < 1 || network.lines.some((line) => line.branch?.line === id)) return network;
return {
lines: network.lines.slice(0, index),
pieces: network.pieces.filter((piece) => piece.line !== id),
};
}
/** 새 갈래 — `line` 째 선의 `vertex` 꺾임점에서 시작. */
export function addBranch(network: PlanNetwork, line: number, vertex: number): PlanNetwork {
const parent = network.lines[line];
const from = parent.vertices[vertex];
const id = nextLineId(network.lines, "branch");
return {
...network,
lines: [
...network.lines,
{ id, vertices: [[from[0], from[1]]], branch: { line: parent.id, vertex } },
],
};
}
/** 서버 꼴(선 · 마크 · 조각 · 조각 없는 구역 있음) → 화면 꼴(조각이 선을 빈틈없이 덮음). */
export function fromServer(
lines: PlanLine[],
marks: ReadonlyArray<{ line: string; at_m: number }>,
pieces: ReadonlyArray<PlanPiece>,
year: number,
): PlanNetwork {
const cuts: PlanPiece[] = marks
.filter(
(mark) =>
!pieces.some(
(piece) => piece.line === mark.line && Math.abs(piece.from_m - mark.at_m) < 0.01,
),
)
.map((mark) => ({ id: "", line: mark.line, from_m: mark.at_m, to_m: mark.at_m, year: NaN }));
return normalizePieces({ lines, pieces: [...pieces, ...cuts] }, year);
}
/** 서버 꼴 마크 — 조각 경계(선마다 0 빼고 시작 자리). */
export function marksOf(network: PlanNetwork): Array<{ line: string; at_m: number }> {
return network.pieces
.filter((piece) => piece.from_m > 0)
.map((piece) => ({ line: piece.line, at_m: piece.from_m }));
}
/** 선 이름 — 갈래 없는 선 = 본선(둘 이상이면 본선 1 · 2 …) · 갈래 = 갈래 1 · 2 …(선 차례).
* ⚠ 짝 = `D04_PlanConfirm_Repository.line_names`(조각 · 내보내기 이름) — 고치면 같이(거울 시험 530-3). */
export function lineNames(lines: ReadonlyArray<Pick<PlanLine, "branch">>): string[] {
const roots = lines.filter((line) => !line.branch).length;
let root = 0;
let branch = 0;
return lines.map((line) =>
line.branch ? `갈래 ${++branch}` : roots > 1 ? `본선 ${++root}` : "본선",
);
}
/** 선 차례 — 갈래는 부모 뒤(서버 셈이 앞에서부터 부모를 찾음) · 이미 맞으면 그대로. */
export function orderLines(lines: ReadonlyArray<PlanLine>): PlanLine[] {
const done = new Set<string>();
const out: PlanLine[] = [];
let rest = [...lines];
while (rest.length) {
const left: PlanLine[] = [];
for (const line of rest) {
if (!line.branch || done.has(line.branch.line)) {
out.push(line);
done.add(line.id);
} else left.push(line);
}
if (left.length === rest.length) return [...out, ...left];
rest = left;
}
return out;
}
/** `id` 선과 그 아래 갈래 모두. */
function family(network: PlanNetwork, id: string): Set<string> {
const out = new Set([id]);
for (let grew = true; grew;) {
grew = false;
for (const line of network.lines)
if (line.branch && out.has(line.branch.line) && !out.has(line.id)) {
out.add(line.id);
grew = true;
}
}
return out;
}
/** 꺾임점마다 선 위 거리. */
function chainages(vertices: ReadonlyArray<Vertex>): number[] {
const out = [0];
for (let i = 1; i < vertices.length; i += 1)
out.push(out[i - 1] + segmentLength(vertices[i - 1], vertices[i]));
return out;
}
/** 선 모양이 바뀜 — 그 선 마크(조각 시작)를 `carry(옛 거리)` 로 옮겨 실음 · 끝은 `normalizePieces` 가 맞춤. */
function carryMarks(
pieces: ReadonlyArray<PlanPiece>,
line: string,
carry: (m: number) => number,
): PlanPiece[] {
return pieces.map((piece) =>
piece.line === line && piece.from_m > 0
? { ...piece, from_m: round3(carry(piece.from_m)) }
: piece,
);
}
/** 옛 모양 위 거리 → 같은 구간 · 같은 비율의 새 모양 위 거리(꺾임점 수 같음). */
function sameShare(
before: ReadonlyArray<Vertex>,
after: ReadonlyArray<Vertex>,
): (m: number) => number {
const [a, b] = [chainages(before), chainages(after)];
return (m) => {
const found = a.findIndex((s, k) => k > 0 && m <= s);
const i = found < 0 ? a.length - 2 : found - 1;
const span = a[i + 1] - a[i];
return b[i] + (span > 0 ? (m - a[i]) / span : 0) * (b[i + 1] - b[i]);
};
}
/** 꺾임점 옮김 — 이 점에 붙은 갈래 첫 점도 같이 · 마크는 구간 안 비율 그대로. */
export function moveVertex(
network: PlanNetwork,
line: number,
vertex: number,
point: Vertex,
): PlanNetwork {
const moved = new Map<string, number>(); // 선 id → 옮길 꺾임점
const visit = (id: string, v: number): void => {
moved.set(id, v);
for (const other of network.lines)
if (other.branch?.line === id && other.branch.vertex === v) visit(other.id, 0);
};
visit(network.lines[line].id, vertex);
let pieces = network.pieces;
const lines = network.lines.map((entry) => {
const v = moved.get(entry.id);
if (v === undefined) return entry;
const vertices = entry.vertices.map((p, i): Vertex => (i === v ? [point[0], point[1]] : p));
pieces = carryMarks(pieces, entry.id, sameShare(entry.vertices, vertices));
return { ...entry, vertices };
});
return { lines, pieces };
}
/** 꺾임점 지움 — 갈래 첫 점 · 갈래가 붙은 점 · 점 둘뿐인 선은 못 지움(까닭 글) · 뒤 갈래 차례 −1 ·
* 마크는 합친 구간 안 비율. */
export function removeVertex(
network: PlanNetwork,
line: number,
vertex: number,
): PlanNetwork | string {
const entry = network.lines[line];
if (entry.vertices.length <= 2) return "점이 둘뿐인 선입니다 — 선을 지우세요.";
if (vertex === 0 && entry.branch) return "갈래 첫 점은 붙은 선 위라 지울 수 없습니다.";
if (network.lines.some((o) => o.branch?.line === entry.id && o.branch.vertex === vertex))
return "갈래가 붙은 점은 지울 수 없습니다 — 갈래를 먼저 지우세요.";
const vertices = entry.vertices.filter((_, i) => i !== vertex);
const [a, b] = [chainages(entry.vertices), chainages(vertices)];
const last = entry.vertices.length - 1;
const carry = (m: number): number => {
if (vertex === 0) return Math.max(0, m - a[1]);
if (vertex === last) return Math.min(m, b[b.length - 1]);
if (m <= a[vertex - 1]) return m;
if (m >= a[vertex + 1]) return m - a[vertex + 1] + b[vertex];
const share = (m - a[vertex - 1]) / (a[vertex + 1] - a[vertex - 1]);
return b[vertex - 1] + share * (b[vertex] - b[vertex - 1]);
};
return {
lines: network.lines.map((other, i) => {
if (i === line) return { ...other, vertices };
const branch = other.branch;
return branch?.line === entry.id && branch.vertex > vertex
? { ...other, branch: { ...branch, vertex: branch.vertex - 1 } }
: other;
}),
pieces: carryMarks(network.pieces, entry.id, carry),
};
}
/** 선 방향 바꿈(측점 No.0 쪽 바꿈) — 조각 · 붙은 갈래 차례 따라감 · 갈래는 못 바꿈(까닭 글). */
export function reverseLine(network: PlanNetwork, line: number): PlanNetwork | string {
const entry = network.lines[line];
if (entry.branch) return "갈래는 방향을 바꿀 수 없습니다 — 첫 점이 붙은 선 위입니다.";
const n = entry.vertices.length;
const length = round3(lineLength(entry.vertices));
return {
lines: network.lines.map((other, i) => {
if (i === line) return { ...other, vertices: [...other.vertices].reverse() };
const branch = other.branch;
return branch?.line === entry.id
? { ...other, branch: { ...branch, vertex: n - 1 - branch.vertex } }
: other;
}),
pieces: network.pieces.map((piece) =>
piece.line === entry.id
? { ...piece, from_m: round3(length - piece.to_m), to_m: round3(length - piece.from_m) }
: piece,
),
};
}
/** 선 지움 — 그 선 조각도 · 갈래가 붙어 있으면 못 지움(까닭 글). */
export function removeLine(network: PlanNetwork, line: number): PlanNetwork | string {
const id = network.lines[line]?.id;
if (network.lines.some((other) => other.branch?.line === id))
return "갈래가 붙은 선은 지울 수 없습니다 — 갈래를 먼저 지우세요.";
return {
lines: network.lines.filter((_, i) => i !== line),
pieces: network.pieces.filter((piece) => piece.line !== id),
};
}
/** `a` 선 꼬리에 `b` 선(머리 = a 꼬리)을 이어 한 선 — 조각 id 그대로 · 이음 자리 두 조각이 같은 해면 하나로. */
function joinLines(network: PlanNetwork, a: number, b: number): PlanNetwork {
const [head, tail] = [network.lines[a], network.lines[b]];
const offset = round3(lineLength(head.vertices));
const shift = head.vertices.length - 1;
const last = network.pieces
.filter((piece) => piece.line === head.id)
.sort((x, y) => y.from_m - x.from_m)[0];
const pieces = network.pieces
.map((piece) =>
piece.line === tail.id
? {
...piece,
line: head.id,
from_m: round3(piece.from_m + offset),
to_m: round3(piece.to_m + offset),
}
: piece,
)
.filter(
(piece) =>
!(
piece.line === head.id &&
Math.abs(piece.from_m - offset) < SAME_M &&
last?.year === piece.year
),
);
const lines = network.lines
.map((line, i) => {
if (i === a) return { ...line, vertices: [...line.vertices, ...tail.vertices.slice(1)] };
const branch = line.branch;
return branch?.line === tail.id
? { ...line, branch: { line: head.id, vertex: branch.vertex + shift } }
: line;
})
.filter((_, i) => i !== b);
return { lines: orderLines(lines), pieces };
}
/** 갈래 없는 선 머리가 다른 선 꼬리에 닿으면 이어 붙이고 · 꺾임점에 닿으면 그 점의 갈래로(제 갈래 빼고). */
export function attachHead(network: PlanNetwork, line: number): PlanNetwork {
const entry = network.lines[line];
if (!entry || entry.branch || entry.vertices.length < 2) return network;
const head = entry.vertices[0];
const own = family(network, entry.id);
const others = network.lines
.map((other, index) => ({ other, index }))
.filter(({ other }) => !own.has(other.id) && other.vertices.length > 0);
const tail = others.find(({ other }) =>
samePoint(other.vertices[other.vertices.length - 1], head),
);
if (tail) return joinLines(network, tail.index, line);
for (const { other } of others) {
const vertex = other.vertices.findIndex((v) => samePoint(v, head));
if (vertex < 0) continue;
const lines = network.lines.map((entry, i) =>
i === line ? { ...entry, branch: { line: other.id, vertex } } : entry,
);
return { ...network, lines: orderLines(lines) };
}
return network;
}
/** 선 통째 연도 — 그 선 조각 모두. */
export function setLineYear(network: PlanNetwork, line: string, year: number): PlanNetwork {
return {
...network,
pieces: network.pieces.map((piece) => (piece.line === line ? { ...piece, year } : piece)),
};
}
/** 마크 옮김 — `index` 조각의 시작(= 앞 조각 끝)을 `atM` 으로 · 양쪽 조각 안에서 `MARK_GAP_M` 남김. */
export function moveMark(network: PlanNetwork, index: number, atM: number): PlanNetwork {
const piece = network.pieces[index];
if (!piece || !(piece.from_m > 0)) return network;
const before = network.pieces.find(
(p) => p !== piece && p.line === piece.line && Math.abs(p.to_m - piece.from_m) < SAME_M,
);
if (!before) return network;
const at = round3(Math.min(piece.to_m - MARK_GAP_M, Math.max(before.from_m + MARK_GAP_M, atM)));
return {
...network,
pieces: network.pieces.map((p) =>
p === piece ? { ...p, from_m: at } : p === before ? { ...p, to_m: at } : p,
),
};
}
/** 여러 조각 연도 바꿈(585-2 속성 판) — `ids` 조각 모두 `year`. */
export function setPiecesYear(
network: PlanNetwork,
ids: ReadonlyArray<string>,
year: number,
): PlanNetwork {
const pick = new Set(ids);
return {
...network,
pieces: network.pieces.map((piece) => (pick.has(piece.id) ? { ...piece, year } : piece)),
};
}
/** 끌어 고르기 상자(보이는 화면 px · 축에 바른 네모). */
export interface ViewBox {
x0: number;
y0: number;
x1: number;
y1: number;
}
/** 토막 a–b 가 네모와 만나는가(한 끝이 안 · 또는 네 변 중 하나와 교차). */
function segmentHitsBox(a: Vertex, b: Vertex, box: ViewBox): boolean {
const inside = ([x, y]: Vertex): boolean =>
x >= box.x0 && x <= box.x1 && y >= box.y0 && y <= box.y1;
if (inside(a) || inside(b)) return true;
const cross = (p: Vertex, q: Vertex, r: Vertex, s: Vertex): boolean => {
const d = (q[0] - p[0]) * (s[1] - r[1]) - (q[1] - p[1]) * (s[0] - r[0]);
if (d === 0) return false;
const t = ((r[0] - p[0]) * (s[1] - r[1]) - (r[1] - p[1]) * (s[0] - r[0])) / d;
const u = ((r[0] - p[0]) * (q[1] - p[1]) - (r[1] - p[1]) * (q[0] - p[0])) / d;
return t >= 0 && t <= 1 && u >= 0 && u <= 1;
};
const corners: Vertex[] = [
[box.x0, box.y0],
[box.x1, box.y0],
[box.x1, box.y1],
[box.x0, box.y1],
];
return corners.some((c, i) => cross(a, b, c, corners[(i + 1) % 4]));
}
/** 끌어 고르기(585-2 · 오토캐드 꼴) — 창(`crossing` 아님) = 조각 꺾은선 점이 모두 상자 안 ·
* 걸침 = 점 하나라도 안 또는 토막 하나라도 상자 변과 만남. `toView` = 작업 좌표계 점 → 보이는 px. */
export function piecesInBox(
network: PlanNetwork,
toView: (point: Vertex) => Vertex,
box: ViewBox,
crossing: boolean,
): string[] {
const norm: ViewBox = {
x0: Math.min(box.x0, box.x1),
y0: Math.min(box.y0, box.y1),
x1: Math.max(box.x0, box.x1),
y1: Math.max(box.y0, box.y1),
};
const inside = ([x, y]: Vertex): boolean =>
x >= norm.x0 && x <= norm.x1 && y >= norm.y0 && y <= norm.y1;
return network.pieces
.filter((piece) => {
const vertices = network.lines.find((line) => line.id === piece.line)?.vertices;
if (!vertices || vertices.length < 2) return false;
const path = sliceLine(vertices, piece.from_m, piece.to_m).map(toView);
if (!crossing) return path.every(inside);
return path.some((p, i) => (i ? segmentHitsBox(path[i - 1], p, norm) : inside(p)));
})
.map((piece) => piece.id);
}
/** 갈래 시점 지정(585-3) — 본선 한 끝이 다른 선 꺾임점에 닿았으면 그 점을 시점(No.0)으로 그 선의 갈래로 ·
* 머리가 다른 선 꼬리면 한 선으로 이음(연속 구간 = 측점 차례). 닿은 끝 없음 · 이미 갈래 = 까닭 글. */
export function branchFromEnd(network: PlanNetwork, line: number): PlanNetwork | string {
const entry = network.lines[line];
if (!entry || entry.vertices.length < 2) return "선을 고르세요.";
if (entry.branch) return "이미 갈래입니다 — 갈라지는 점이 시점입니다.";
const own = family(network, entry.id);
const touches = (point: Vertex): boolean =>
network.lines.some(
(other) => !own.has(other.id) && other.vertices.some((v) => samePoint(v, point)),
);
if (touches(entry.vertices[0])) {
const next = attachHead(network, line);
if (next !== network) return next;
}
if (!touches(entry.vertices[entry.vertices.length - 1]))
return "다른 선에 닿은 끝이 없습니다 — 끝을 다른 선 위에 놓고 지정하세요.";
const turned = reverseLine(network, line);
if (typeof turned === "string") return turned;
const index = turned.lines.findIndex((other) => other.id === entry.id);
const next = attachHead(turned, index);
return next === turned ? "다른 선에 닿은 끝이 없습니다." : next;
}