feat(D03): 예정노선 그리기 — 시점 · 종점 여럿 · 들어올 때 자동 받기 · [긋기] 양끝 · 부분 수정 · 기울기 % · ° 미리보기 · 방향 바꾸기 · 선 고르기 · 지우기 · 연도 (PLAN 579-3 · 580-1~3 · 요구 440 · 441)

- 579-3: D02 시점 · 종점 목록 받음 · 점 그림 · 맞춤 · 등고선 범위 · 「범위 밖」 판정에 시점 · 종점 · 들어올 때 받은 범위가 못 덮으면 바로 받기(진행 막대 · 직전 실패면 안 함)
- 선 이름 = 갈래 없는 선 「본선」(둘 이상 「본선 N」) · 갈래 「갈래 N」 — TS lineNames ↔ D04 line_names 짝 · 거울 시험
- 580-1: [긋기] 하나 — 시점 · 종점(끝낼 때 뒤집어 No.0 = 반대쪽) · 선 꼬리 · 선 머리 · 선 위(갈래)에서 시작 · 머리가 다른 선 꼬리에 닿으면 한 선 · 꺾임점이면 갈래 · 꺾임점 끌기 · 더하기 · 지우기(마크는 구간 비율)
- 580-2: 미리보기 글 = 거리 + 가상 종단 기울기 % · ° · 기울기 고정 칸 옆 ° 환산 · 고른 선 [방향 바꾸기]
- 580-3: 지도에서 선 · 조각 고름(좌측 줄 밝힘) · Delete · [선 지움] · 선 통째 연도 · 긋는 해(긋는 중 바꾸면 다음 점부터) · [연도 구분] 뒤 연도 고름 바로 · 마크 끌기
- 새 파일: D03_PlanAnalysis_UI_Edit.ts(고르기 · 고치기) · D03_PlanAnalysis_UI_DrawPaint.ts(그림 · 집기 — UI_Draw 700 줄 넘어 나눔)
- UI_Map = 겹 꼴에 release · pointerup · 끌기 중 포인터 잡기 · [?] 안내 줄(그리기 본체 draw() 는 sub2 몫이라 손 안 댐)
- 시험: 530-3 새 꼴 함수 · 서버 derive 대조 · 선 이름 거울 / 553-4 그림 자리 옮김

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-29 14:45:10 +09:00
co-authored by Claude Opus 5.5
parent 23aacda55e
commit 1ddb5cb1b2
11 changed files with 1406 additions and 380 deletions
+3 -3
View File
@@ -19,11 +19,11 @@ export interface BasePoint {
y: number;
}
/** D02 정본 — 전체 시점 · 종점(작업 좌표계 m + 경위도). */
/** D02 정본 — 시점 목록 · 종점 목록(작업 좌표계 m + 경위도 · 579-1). */
export interface PlanBaseView {
epsg: number | null;
start: BasePoint | null;
end: BasePoint | null;
starts: BasePoint[];
ends: BasePoint[];
}
export interface PlanJob {
+246 -312
View File
@@ -1,12 +1,17 @@
/* =============================================================================
* D03_PlanAnalysis_UI_Draw.ts
* D03 손 긋기 겹 · 그리기 칸(PLAN 562-3 · 요구서 389 3장 8~10) — 시점에서 점을 찍어 직선으로 이음.
* D03 손 긋기 겹 · 그리기 칸(PLAN 562-3 · 580 · 요구서 389 3장 8~10 · 440 3~7) — 점을 찍어 직선으로 이음.
*
* 본선 = D02 시점에서 · D02 종점 둘레(SNAP_PX)에 오면 종점에 붙고 그 점에서 끝.
* 미리보기 = 마지막 점 → 마우스 길이(m) · 원지반 기울기(%, + 오르막) — 제약 칸 넷:
* 자유 · 길이 고정 · 기울기 고정(마우스 방향 첫 자리) · 둘 다(양각기 원 위 후보 중 마우스에 가까운 것) ·
* 제약 기울기 = 오르내림 같은 크기(562-1 짝이 |기울기|로 셈) · 미리보기 값은 + 오르막 / − 내리막.
* 갈래 = 기존 선 꺾임점(선 위면 꺾임점 끼움)을 눌러 시작 · 구역 마크 = 선 위 누름(오른쪽 = 지움).
* [긋기] 하나 — 첫 누름 자리로 시작: 시점 = 새 본선 · 종점 = 새 본선(끝낼 때 뒤집어 종점이 선 끝 ·
* 측점 No.0 = 반대쪽) · 선 꼬리 = 이어 긋기 · 갈래 없는 선 머리 = 뒤집어 이어 긋고 끝낼 때 되돌림 ·
* 선 위 · 꺾임점 = 갈래. 시점 · 종점 · 다른 선 둘레(SNAP_PX)에 오면 거기 붙고 그 점에서 끝 —
* 머리가 다른 선 꼬리에 닿으면 한 선으로 잇고 · 꺾임점에 닿으면 그 점의 갈래.
* 미리보기 = 마지막 점 → 마우스 길이(m) · 가상 종단 기울기(원지반 % · ° · + 오르막) — 제약 칸 넷:
* 자유 · 길이 고정 · 기울기 고정(기울기 고정 · 길이 자유 · 마우스 방향 첫 자리) · 둘 다(양각기 원 위 후보 중
* 마우스에 가까운 것) · 제약 기울기 = 오르내림 같은 크기(562-1 짝이 |기울기|로 셈).
* 긋는 해 = 새 조각 처음 해 · 긋는 중 해를 바꾸면 다음 점부터 그 해(지금 끝 자리에 연도 구분 마크).
* 연도 구분 = 선 위 누름(마크 · 뒤 조각 고름 → 페이지가 연도 고름을 엶) · 오른쪽 = 지움.
* 고르기 · 고치기(쉴 때) = `D03_PlanAnalysis_UI_Edit`.
* 키 = Enter 끝 · Esc 이번 긋기 취소 · Backspace 마지막 점 빼기(설정 창 단축키 표 D03 줄).
* 셈 = 562-1 짝(`logic/D03_PlanAnalysis_Draw`) · 꼴 다루기 = `logic/D03_PlanAnalysis_Plan` ·
* 값을 바꾸면 `commit(다음 꼴)` 한 번 = 되돌리기 한 걸음(스택 · 캐시 = 페이지).
@@ -16,50 +21,49 @@ import { createButton, showToast } from "@ui/ui_template_elements";
import { createInputField } from "@ui/ui_template_input";
import { createSegment } from "@ui/ui_template_segment";
import { createText } from "@ui/ui_template_text";
import { drawStationTicks } from "../B04_PreProcess/B04_PreProcess_UI_MapOverlays";
import {
compassCandidates,
compassPoint,
distanceAlong,
fixedLengthPoint,
fixedSlopePoint,
groundSlopePct,
lineLength,
pointAtDistance,
segmentLength,
stationChainages,
} from "./logic/D03_PlanAnalysis_Draw";
import {
addBranch,
appendVertex,
dropLastLine,
attachHead,
dropLastVertex,
insertVertex,
reachedEnd,
newLine,
normalizePieces,
removeLine,
removeMark,
reverseLine,
samePoint,
setPieceYear,
splitPiece,
type PlanNetwork,
type Vertex,
} from "./logic/D03_PlanAnalysis_Plan";
import {
planColors,
PLAN_STATION_INTERVAL_M,
type PlanMapLayer,
type ScreenMap,
} from "./D03_PlanAnalysis_UI_Map";
fmt,
lineAt,
paintNetwork,
paintPreview,
screenGap,
slopeDeg,
SNAP_PX,
} from "./D03_PlanAnalysis_UI_DrawPaint";
import type { PlanMapLayer, ScreenMap } from "./D03_PlanAnalysis_UI_Map";
/** 종점 · 꺾임점에 붙는 반경(px). */
const SNAP_PX = 12;
/** 선 위 누름으로 볼 반경(px). */
const LINE_HIT_PX = 8;
/** 구간 길이 라벨을 보일 화면 길이(px). */
const LABEL_MIN_PX = 70;
/** 제약 칸 처음 값 — 길이 = 측점 간격(지식DB 설계제원_총괄) · 기울기 = 간선 20 km/h 일반지형 상한(종단선형). */
export const DRAW_DEFAULT_LENGTH_M = 20;
export const DRAW_DEFAULT_SLOPE_PCT = 9;
export type DrawConstraint = "free" | "length" | "slope" | "both";
export type DrawMode = "idle" | "draw" | "branch" | "mark";
export type DrawMode = "idle" | "draw" | "mark";
export interface DrawPreview {
point: Vertex;
@@ -69,15 +73,21 @@ export interface DrawPreview {
note: string | null;
blocked: boolean;
snapped: boolean;
/** 다른 선 위(꺾임점 아님)에 붙음 — 점을 찍을 때 그 선에 꺾임점을 끼움. */
onLine: { line: number; segment: number } | null;
}
export interface PlanDrawParams {
base: () => { start: Vertex; end: Vertex };
base: () => { starts: Vertex[]; ends: Vertex[] };
network: () => PlanNetwork;
heightAt: () => (x: number, y: number) => number;
commit: (next: PlanNetwork) => void;
/** 긋는 해 — 새 조각 처음 해 · 긋는 중 바꾸면 다음 점부터. */
year: () => number;
/** 모드 · 그리는 선이 바뀜 — 페이지가 단추 · 안내를 다시. */
onMode: () => void;
/** 연도 구분 마크를 놓음 — 뒤 조각(id) 고름 · 연도 고름. */
onMark: (piece: string) => void;
}
export interface PlanDraw {
@@ -91,21 +101,18 @@ export interface PlanDraw {
key: (event: KeyboardEvent) => boolean;
/** 되돌리기 뒤 — 그리던 선이 사라졌으면 그리기를 멈춤. */
sync: () => void;
/** 검증 창구 — 지금 미리보기 · 제약. */
/** 검증 창구 — 지금 미리보기 · 제약 · 그리는 선(차례 · 끝에서 긋는 중). */
preview: () => DrawPreview | null;
stroke: () => { line: number; flip: boolean } | null;
constraint: () => { kind: DrawConstraint; lengthM: number; slopePct: number };
setConstraint: (kind: DrawConstraint, lengthM?: number, slopePct?: number) => void;
}
const fmt = (value: number): string => value.toFixed(1);
const signed = (value: number): string => `${value >= 0 ? "+" : ""}${value.toFixed(1)}`;
const near = (a: [number, number], b: [number, number], px: number): boolean =>
Math.hypot(a[0] - b[0], a[1] - b[1]) <= px;
export function createPlanDraw(params: PlanDrawParams): PlanDraw {
let mode: DrawMode = "idle";
let editable = false;
let active: number | null = null; // 그리는 선 차례
let active: number | null = null; // 그리는 선 차례(첫 누름 전 = null)
let flip = false; // 끝에서 긋는 중 — 끝낼 때 뒤집음
let strokeStart: PlanNetwork | null = null; // Esc 로 돌아갈 꼴
let preview: DrawPreview | null = null;
let candidates: Vertex[] = [];
@@ -114,27 +121,21 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
let slopePct = DRAW_DEFAULT_SLOPE_PCT;
// ── 좌측 칸 ─────────────────────────────────────────────
const mainButton = createButton({
label: "본선 긋기",
const drawButton = createButton({
label: "긋기",
variant: "filled",
size: 28,
onClick: () => setMode(mode === "draw" && active === 0 ? "idle" : "draw"),
});
const branchButton = createButton({
label: "갈래",
variant: "ghost",
size: 28,
onClick: () => setMode(mode === "branch" ? "idle" : "branch"),
onClick: () => setMode(mode === "draw" ? "idle" : "draw"),
});
const markButton = createButton({
label: "구역 마크",
label: "연도 구분",
variant: "ghost",
size: 28,
onClick: () => setMode(mode === "mark" ? "idle" : "mark"),
});
const modeRow = document.createElement("div");
modeRow.className = "d03-plan__row";
modeRow.append(mainButton, branchButton, markButton);
modeRow.append(drawButton, markButton);
const kindSegment = createSegment({
options: [
{ value: "free", label: "자유" },
@@ -173,14 +174,22 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
size: 24,
onInput: (value) => {
const next = Number(value);
if (value.trim() !== "" && Number.isFinite(next)) slopePct = next;
slopeField.setError(value.trim() !== "" && Number.isFinite(next) ? undefined : "숫자");
const ok = value.trim() !== "" && Number.isFinite(next);
if (ok) slopePct = next;
slopeField.setError(ok ? undefined : "숫자");
degText();
},
});
slopeField.input.dataset.role = "draw-slope";
const slopeDegText = createText("", "muted");
slopeDegText.dataset.role = "draw-slope-deg";
const degText = (): void => {
slopeDegText.textContent = `≈ ${fmt(slopeDeg(Math.abs(slopePct)))}°`;
};
degText();
const fieldRow = document.createElement("div");
fieldRow.className = "d03-plan__row";
fieldRow.append(lengthField.root, slopeField.root);
fieldRow.append(lengthField.root, slopeField.root, slopeDegText);
const hint = createText("", "muted");
hint.classList.add("d03-plan__hint");
const panel = document.createElement("div");
@@ -194,26 +203,21 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
function panelState(): void {
const network = params.network();
const done = reachedEnd(network, params.base().end);
mainButton.querySelector(".ui-btn__label")!.textContent =
mode === "draw" && active === 0 ? "긋기 끝" : done ? "본선 끝남" : "본선 긋기";
mainButton.disabled = !editable || (done && !(mode === "draw" && active === 0));
branchButton.disabled = !editable || lineLength(network.lines[0]?.vertices ?? []) <= 0;
markButton.disabled = branchButton.disabled;
branchButton.setAttribute("aria-pressed", String(mode === "branch"));
drawButton.querySelector(".ui-btn__label")!.textContent = mode === "draw" ? "긋기 끝" : "긋기";
drawButton.disabled = !editable;
markButton.disabled = !editable || !network.lines.some((line) => lineLength(line.vertices) > 0);
drawButton.setAttribute("aria-pressed", String(mode === "draw"));
markButton.setAttribute("aria-pressed", String(mode === "mark"));
panel.dataset.mode = mode;
hint.textContent = !editable
? "보기만 — 산림청 계정에서 긋습니다."
: mode === "draw"
? "누름 = 점 · Enter 끝 · Esc 취소 · Backspace 마지막 점 빼기"
: mode === "branch"
? "갈래를 시작할 선 위 점을 누르세요."
: mode === "mark"
? "선 위를 눌러 구역을 나눕니다 · 오른쪽 누름 = 마크 지움"
: done
? "본선이 종점에 닿았습니다 — 갈래 · 구역 마크 · 연도를 정하세요."
: "[본선 긋기]로 시점에서 점을 찍어 갑니다.";
? active === null
? "시점 · 종점 · 선 끝 · 선 위를 눌러 시작합니다."
: "누름 = 점 · Enter 끝 · Esc 취소 · Backspace 마지막 점 빼기"
: mode === "mark"
? "선 위를 눌러 연도를 나눕니다 · 오른쪽 누름 = 마크 지움"
: "[긋기]로 시점 · 종점 · 선에서 긋습니다 · 선을 누르면 고릅니다.";
}
fieldsState();
@@ -223,20 +227,46 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
return vertices[vertices.length - 1] ?? null;
};
/** 마우스 자리 → 찍을 점 · 값 — 종점 붙기가 제약보다 먼저(본선만). */
/** 붙을 자리 — 앞에서 = 종점 · 끝에서 = 시점 · 다른 선 꺾임점 · 다른 선 위(그리는 선 빼고). */
function snapAt(
px: number,
py: number,
map: ScreenMap,
): { point: Vertex; note: string; onLine: DrawPreview["onLine"] } | null {
const { starts, ends } = params.base();
const network = params.network();
const offers = (flip ? starts : ends).map((point) => ({
point,
note: flip ? "시점에 붙음" : "종점에 붙음",
}));
network.lines.forEach((line, i) => {
if (i !== active)
for (const point of line.vertices) offers.push({ point, note: "선 꺾임점에 붙음" });
});
const best = offers
.map((offer) => ({ ...offer, gap: screenGap(map, offer.point, px, py) }))
.filter((offer) => offer.gap <= SNAP_PX)
.sort((a, b) => a.gap - b.gap)[0];
if (best) return { point: best.point, note: best.note, onLine: null };
const hit = lineAt(network, px, py, map, active);
return hit
? { point: hit.point, note: "선 위에 붙음", onLine: { line: hit.line, segment: hit.segment } }
: null;
}
/** 마우스 자리 → 찍을 점 · 값 — 붙기가 제약보다 먼저. */
function previewAt(px: number, py: number, map: ScreenMap): DrawPreview | null {
if (active === null) return null;
const from = lastOf(active);
if (!from) return null;
const z = params.heightAt();
const cursor = map.toMetric(px, py);
const end = params.base().end;
candidates = kind === "both" ? compassCandidates(from, lengthM, slopePct, z) : [];
const snap = snapAt(px, py, map);
let point: Vertex | null = cursor;
let note: string | null = null;
let snapped = false;
if (active === 0 && near(map.toScreen(end), [px, py], SNAP_PX)) {
[point, note, snapped] = [end, "종점에 붙음", true];
if (snap) {
[point, note] = [snap.point, snap.note];
} else if (kind === "length") {
point = fixedLengthPoint(from, cursor, lengthM);
} else if (kind === "slope") {
@@ -254,56 +284,39 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
slopePct: groundSlopePct(from, at, z),
note,
blocked: !point || length <= 0,
snapped,
snapped: !!snap,
onLine: snap?.onLine ?? null,
};
}
/** 화면 px 둘레 선 위 자리 — 가장 가까운 선 · 누가 · 꺾임점(가까우면) · 구간. */
function lineAt(
px: number,
py: number,
map: ScreenMap,
): { line: number; atM: number; point: Vertex; vertex: number | null; segment: number } | null {
const cursor = map.toMetric(px, py);
let best: ReturnType<typeof lineAt> = null;
let bestPx = LINE_HIT_PX;
params.network().lines.forEach((line, index) => {
if (line.vertices.length < 2) return;
// 붙는 반경 안 꺾임점 중 가장 가까운 것
let vertex = -1;
let vertexPx = SNAP_PX;
line.vertices.forEach((v, i) => {
const [vx, vy] = map.toScreen(v);
const d = Math.hypot(vx - px, vy - py);
if (d <= vertexPx) [vertex, vertexPx] = [i, d];
});
const atM =
vertex >= 0
? lineLength(line.vertices.slice(0, vertex + 1))
: distanceAlong(line.vertices, cursor);
const point = vertex >= 0 ? line.vertices[vertex] : pointAtDistance(line.vertices, atM);
const [sx, sy] = map.toScreen(point);
const gap = vertex >= 0 ? 0 : Math.hypot(sx - px, sy - py);
if (gap > bestPx || (best && gap === bestPx)) return;
// 누가가 든 구간(꺾임점 끼울 자리)
let segment = 0;
for (let walked = 0; segment < line.vertices.length - 2; segment += 1) {
walked += segmentLength(line.vertices[segment], line.vertices[segment + 1]);
if (walked >= atM) break;
}
best = { line: index, atM, point, vertex: vertex >= 0 ? vertex : null, segment };
bestPx = gap;
});
return best;
}
// ── 모드 ────────────────────────────────────────────────
/** 이번 긋기 마침 — 점 하나뿐인 선 버림 · 끝에서 그은 선은 뒤집고 머리 잇기 · 꼬리가 다른 선 머리면 잇기. */
function stop(): void {
// 점 하나뿐인 갈래는 버림(본선 시점은 남김)
const network = params.network();
if (active !== null && active > 0 && (network.lines[active]?.vertices.length ?? 0) < 2)
params.commit(dropLastLine(network));
let network = params.network();
const line = active;
const entry = line === null ? null : network.lines[line];
if (line !== null && entry) {
if (entry.vertices.length < 2) {
const dropped = removeLine(network, line);
if (typeof dropped !== "string") network = dropped;
} else if (flip) {
const turned = reverseLine(network, line);
if (typeof turned !== "string") network = attachHead(turned, line);
} else {
const tail = entry.vertices[entry.vertices.length - 1];
const next = network.lines.findIndex(
(other, i) =>
i !== line &&
!other.branch &&
other.vertices.length > 1 &&
samePoint(other.vertices[0], tail),
);
if (next >= 0) network = attachHead(network, next);
}
if (network !== params.network()) params.commit(network);
}
active = null;
flip = false;
strokeStart = null;
preview = null;
candidates = [];
@@ -312,20 +325,93 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
function setMode(next: DrawMode): void {
if (mode === "draw") stop();
mode = editable ? next : "idle";
if (mode === "draw") {
const network = params.network();
if (reachedEnd(network, params.base().end)) {
showToast("본선이 이미 종점에 닿았습니다 — 갈래는 [갈래]로 긋습니다.", "info");
mode = "idle";
} else {
active = 0;
strokeStart = network;
}
}
panelState();
params.onMode();
}
/** [긋기] 첫 누름 — 선 끝 이어 긋기 · 종점(뒤집어 긋기) · 시점 · 선 위 갈래. 참 = 시작함. */
function begin(px: number, py: number, map: ScreenMap): boolean {
const network = params.network();
const { starts, ends } = params.base();
const gap = (point: Vertex): number => screenGap(map, point, px, py);
const nearest = <T extends { gap: number }>(items: T[]): T | undefined =>
items.filter((item) => item.gap <= SNAP_PX).sort((a, b) => a.gap - b.gap)[0];
const onBase = (list: Vertex[], point: Vertex): boolean =>
list.some((p) => samePoint(p, point));
strokeStart = network;
// 1) 선 끝 — 꼬리(종점에 닿은 꼬리 빼고) · 갈래 없는 선 머리(시점에 놓인 머리 빼고)
const tips: Array<{ line: number; head: boolean; gap: number }> = [];
network.lines.forEach((line, i) => {
const tail = line.vertices[line.vertices.length - 1];
const head = line.vertices[0];
if (tail && !onBase(ends, tail)) tips.push({ line: i, head: false, gap: gap(tail) });
if (line.vertices.length > 1 && !line.branch && !onBase(starts, head))
tips.push({ line: i, head: true, gap: gap(head) });
});
const tip = nearest(tips);
if (tip) {
if (tip.head) {
const turned = reverseLine(network, tip.line);
if (typeof turned === "string") {
showToast(turned, "info");
return false;
}
params.commit(turned);
}
[active, flip] = [tip.line, tip.head];
return true;
}
// 2) 시점 · 종점
const base = nearest([
...starts.map((point) => ({ point, fromEnd: false, gap: gap(point) })),
...ends.map((point) => ({ point, fromEnd: true, gap: gap(point) })),
]);
if (base) {
const { point, fromEnd } = base;
const owner = fromEnd
? -1
: network.lines.findIndex((line) => line.vertices.some((v) => samePoint(v, point)));
if (owner >= 0) {
// 이미 선이 놓인 시점 = 그 점의 갈래
const vertex = network.lines[owner].vertices.findIndex((v) => samePoint(v, point));
params.commit(addBranch(network, owner, vertex));
} else params.commit(newLine(network, point));
[active, flip] = [params.network().lines.length - 1, fromEnd];
return true;
}
// 3) 선 위 · 꺾임점 = 갈래
const hit = lineAt(network, px, py, map);
if (hit) {
let next = network;
let vertex = hit.vertex;
if (vertex === null) {
next = insertVertex(next, hit.line, hit.segment, hit.point);
vertex = hit.segment + 1;
}
params.commit(addBranch(next, hit.line, vertex));
[active, flip] = [params.network().lines.length - 1, false];
return true;
}
strokeStart = null;
showToast("시점 · 종점 · 선 끝 · 선 위를 눌러 시작합니다.", "info");
return false;
}
/** 긋는 해가 끝 조각 해와 다르면 `fromM`(이번 점 앞 길이)에서 나눠 새 조각 = 긋는 해. */
function yearSplit(network: PlanNetwork, line: number, fromM: number): PlanNetwork {
const year = params.year();
if (!(fromM > 0)) return network;
let next = normalizePieces(network, year);
const id = next.lines[line].id;
const tail = next.pieces.filter((piece) => piece.line === id).at(-1);
if (!tail || tail.year === year) return next;
next = splitPiece(next, id, fromM);
const index = next.pieces.findIndex(
(piece) => piece.line === id && Math.abs(piece.from_m - fromM) < 1e-3,
);
return index >= 0 ? setPieceYear(next, index, year) : next;
}
function addPoint(): boolean {
if (active === null || !preview) return false;
if (preview.blocked) {
@@ -334,9 +420,14 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
}
const line = active;
const ended = preview.snapped;
params.commit(appendVertex(params.network(), line, preview.point));
let next = params.network();
const before = Math.round(lineLength(next.lines[line].vertices) * 1000) / 1000;
if (preview.onLine)
next = insertVertex(next, preview.onLine.line, preview.onLine.segment, preview.point);
next = yearSplit(appendVertex(next, line, preview.point), line, before);
params.commit(next);
if (ended) {
showToast("본선이 종점에 닿았습니다.", "success");
showToast(`${preview.note ?? "붙음"} — 긋기 끝`, "success");
setMode("idle");
}
return true;
@@ -344,95 +435,44 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
const layer: PlanMapLayer = {
draw(context, map) {
const colors = planColors();
const { start, end } = params.base();
const network = params.network();
context.lineCap = "round";
context.lineJoin = "round";
network.lines.forEach((line, index) => {
if (line.vertices.length < 2) return;
context.strokeStyle = index === active ? colors.active : colors.line;
context.lineWidth = index === 0 ? 3 : 2.5;
context.beginPath();
line.vertices.forEach((v, i) => {
const [sx, sy] = map.toScreen(v);
if (i) context.lineTo(sx, sy);
else context.moveTo(sx, sy);
});
context.stroke();
if (map.stations)
drawStationTicks(
paintNetwork(context, map, network, params.base(), active, params.heightAt(), () => {
const from = active === null ? null : lastOf(active);
if (from && preview)
paintPreview(
context,
line.vertices.map(([x, y]) => ({ x, y })),
{
intervalM: PLAN_STATION_INTERVAL_M,
pxPerMeter: map.pxPerMeter,
toScreen: (x, y) => map.toScreen([x, y]),
uprightRad: map.uprightRad,
chainages: stationChainages(lineLength(line.vertices), PLAN_STATION_INTERVAL_M),
},
map,
from,
preview,
kind === "both" ? { lengthM, candidates } : null,
);
if (map.slopes) segmentLabels(context, map, line.vertices);
});
// 꺾임점
context.fillStyle = "#ffffff";
for (const line of network.lines)
for (const v of line.vertices.slice(1)) {
const [sx, sy] = map.toScreen(v);
context.beginPath();
context.arc(sx, sy, 3, 0, Math.PI * 2);
context.fill();
}
drawPieces(context, map, network);
drawPreview(context, map, colors);
// 시점(초록) · 종점(빨강)
for (const [point, fill] of [
[start, "#16a34a"],
[end, "#dc2626"],
] as const) {
const [sx, sy] = map.toScreen(point);
context.beginPath();
context.arc(sx, sy, 7, 0, Math.PI * 2);
context.fillStyle = fill;
context.fill();
context.lineWidth = 2;
context.strokeStyle = "#ffffff";
context.stroke();
}
},
press(px, py, map, button) {
if (!editable) return false;
if (mode === "draw" && button === 0) {
if (active === null) {
const started = begin(px, py, map);
panelState();
params.onMode();
return started;
}
preview = previewAt(px, py, map);
return addPoint();
}
if (mode === "branch" && button === 0) {
const hit = lineAt(px, py, map);
if (!hit) return false;
let network = params.network();
let vertex = hit.vertex;
if (vertex === null) {
network = insertVertex(network, hit.line, hit.segment, hit.point);
vertex = hit.segment + 1;
}
strokeStart = params.network();
params.commit(addBranch(network, hit.line, vertex));
mode = "draw";
active = params.network().lines.length - 1;
panelState();
params.onMode();
return true;
}
if (mode === "mark") {
const hit = lineAt(px, py, map);
if (!hit) return false;
const network = params.network();
const hit = lineAt(network, px, py, map);
if (!hit) return false;
const id = network.lines[hit.line].id;
const next =
button === 2
? removeMark(network, id, hit.atM, SNAP_PX / map.pxPerMeter)
: splitPiece(network, id, hit.atM);
if (next !== network) params.commit(next);
if (next === network) return true;
const added = next.pieces.find((piece) => !network.pieces.some((p) => p.id === piece.id));
params.commit(next);
if (added) params.onMark(added.id);
return true;
}
return false;
@@ -445,120 +485,6 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
},
};
function segmentLabels(
context: CanvasRenderingContext2D,
map: ScreenMap,
vertices: Vertex[],
): void {
const z = params.heightAt();
for (let i = 1; i < vertices.length; i += 1) {
const [a, b] = [map.toScreen(vertices[i - 1]), map.toScreen(vertices[i])];
if (Math.hypot(b[0] - a[0], b[1] - a[1]) < LABEL_MIN_PX) continue;
const slope = groundSlopePct(vertices[i - 1], vertices[i], z);
const text = `${fmt(segmentLength(vertices[i - 1], vertices[i]))} m · ${slope === null ? "—" : `${signed(slope)} %`}`;
label(context, map, (a[0] + b[0]) / 2, (a[1] + b[1]) / 2 - 12, text, "11px");
}
}
function drawPieces(
context: CanvasRenderingContext2D,
map: ScreenMap,
network: PlanNetwork,
): void {
const colors = planColors();
network.pieces.forEach((piece) => {
const vertices = network.lines.find((line) => line.id === piece.line)?.vertices;
if (!vertices || vertices.length < 2) return;
if (piece.from_m > 0) {
const [sx, sy] = map.toScreen(pointAtDistance(vertices, piece.from_m));
context.beginPath();
context.moveTo(sx, sy - 8);
context.lineTo(sx + 6, sy);
context.lineTo(sx, sy + 8);
context.lineTo(sx - 6, sy);
context.closePath();
context.fillStyle = colors.active;
context.fill();
context.lineWidth = 1.5;
context.strokeStyle = "#ffffff";
context.stroke();
}
const [mx, my] = map.toScreen(pointAtDistance(vertices, (piece.from_m + piece.to_m) / 2));
label(context, map, mx, my + 16, `${piece.year}년`, "bold 12px");
});
}
function drawPreview(
context: CanvasRenderingContext2D,
map: ScreenMap,
colors: ReturnType<typeof planColors>,
): void {
if (active === null || !preview) return;
const from = lastOf(active);
if (!from) return;
const [ax, ay] = map.toScreen(from);
const [bx, by] = map.toScreen(preview.point);
if (kind === "both") {
context.strokeStyle = colors.active;
context.lineWidth = 1;
context.setLineDash([3, 4]);
context.beginPath();
context.arc(ax, ay, lengthM * map.pxPerMeter, 0, Math.PI * 2);
context.stroke();
context.fillStyle = colors.active;
for (const c of candidates) {
const [cx, cy] = map.toScreen(c);
context.beginPath();
context.arc(cx, cy, 3, 0, Math.PI * 2);
context.fill();
}
}
context.strokeStyle = preview.blocked ? colors.warn : colors.active;
context.lineWidth = 2;
context.setLineDash([6, 4]);
context.beginPath();
context.moveTo(ax, ay);
context.lineTo(bx, by);
context.stroke();
context.setLineDash([]);
context.beginPath();
context.arc(bx, by, 5, 0, Math.PI * 2);
context.stroke();
const value =
`${fmt(preview.lengthM)} m · ` +
(preview.slopePct === null ? "지형 밖" : `${signed(preview.slopePct)} %`);
label(
context,
map,
bx + 14,
by - 14,
preview.note ? `${value} · ${preview.note}` : value,
"12px",
"left",
);
}
function label(
context: CanvasRenderingContext2D,
map: ScreenMap,
x: number,
y: number,
text: string,
font: string,
align: CanvasTextAlign = "center",
): void {
map.upright(context, x, y, () => {
context.font = `${font} system-ui, sans-serif`;
context.textAlign = align;
context.textBaseline = "middle";
context.lineWidth = 3;
context.strokeStyle = planColors().halo;
context.strokeText(text, x, y);
context.fillStyle = "#ffffff";
context.fillText(text, x, y);
});
}
panelState();
return {
@@ -579,19 +505,22 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
if (mode !== "idle" && event.key === "Escape") {
if (mode === "draw" && strokeStart && strokeStart !== params.network())
params.commit(strokeStart);
active = null; // 되돌린 꼴이라 stop 의 갈래 버림은 안 함
active = null; // 되돌린 꼴이라 stop 의 버림 · 뒤집기는 안 함
setMode("idle");
return true;
}
if (mode === "draw" && active !== null && event.key === "Backspace") {
const network = params.network();
const step =
active > 0 && network.lines[active].vertices.length < 2
? dropLastLine(network)
network.lines[active].vertices.length < 2
? removeLine(network, active)
: dropLastVertex(network, active);
if (typeof step === "string") showToast(step, "info");
else if (step !== network) params.commit(step);
if (active >= params.network().lines.length) setMode("idle");
if (active >= params.network().lines.length) {
active = null;
setMode("idle");
}
return true;
}
return false;
@@ -599,17 +528,22 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
sync() {
if (active !== null && active >= params.network().lines.length) {
active = null;
flip = false;
mode = "idle";
}
panelState();
},
preview: () => preview,
stroke: () => (active === null ? null : { line: active, flip }),
constraint: () => ({ kind, lengthM, slopePct }),
setConstraint(next, length, slope) {
kind = next;
kindSegment.set(next);
if (length !== undefined) [lengthM, lengthField.input.value] = [length, String(length)];
if (slope !== undefined) [slopePct, slopeField.input.value] = [slope, String(slope)];
if (slope !== undefined) {
[slopePct, slopeField.input.value] = [slope, String(slope)];
degText();
}
fieldsState();
},
};
@@ -0,0 +1,258 @@
/* =============================================================================
* D03_PlanAnalysis_UI_DrawPaint.ts
* D03 손 긋기 겹 그림 · 집기(PLAN 562-3 · 580) — `D03_PlanAnalysis_UI_Draw`(긋기) ·
* `D03_PlanAnalysis_UI_Edit`(고르기 · 고치기)가 같이 씀. 그림 = 선 · 측점 · 구간 글 · 꺾임점 · 마크 · 연도 ·
* 시점 · 종점 · 미리보기(거리 · 가상 종단 기울기 % · °) · 집기 = 화면 px 둘레 선 위 자리.
* ========================================================================== */
import { drawStationTicks } from "../B04_PreProcess/B04_PreProcess_UI_MapOverlays";
import {
distanceAlong,
groundSlopePct,
lineLength,
pointAtDistance,
segmentLength,
stationChainages,
type HeightAt,
} from "./logic/D03_PlanAnalysis_Draw";
import type { PlanNetwork, Vertex } from "./logic/D03_PlanAnalysis_Plan";
import { planColors, PLAN_STATION_INTERVAL_M, type ScreenMap } from "./D03_PlanAnalysis_UI_Map";
import type { DrawPreview } from "./D03_PlanAnalysis_UI_Draw";
/** 시점 · 종점 · 꺾임점에 붙는 반경(px). */
export const SNAP_PX = 12;
/** 선 위 누름으로 볼 반경(px). */
export const LINE_HIT_PX = 8;
/** 구간 길이 라벨을 보일 화면 길이(px). */
const LABEL_MIN_PX = 70;
export const fmt = (value: number): string => value.toFixed(1);
const signed = (value: number): string => `${value >= 0 ? "+" : ""}${value.toFixed(1)}`;
/** 기울기 % → 각(°) — 가상 종단 기울기를 두 단위로. */
export const slopeDeg = (pct: number): number => (Math.atan(pct / 100) * 180) / Math.PI;
const slopeText = (pct: number): string => `${signed(pct)} % (${signed(slopeDeg(pct))}°)`;
/** 화면 px 거리(작업 좌표계 점 ↔ 마우스). */
export const screenGap = (map: ScreenMap, point: Vertex, px: number, py: number): number => {
const [sx, sy] = map.toScreen(point);
return Math.hypot(sx - px, sy - py);
};
/** 화면 px 둘레 선 위 자리 — 가장 가까운 선 · 누가 · 꺾임점(가까우면) · 구간 · `skip` 선 빼고. */
export function lineAt(
network: PlanNetwork,
px: number,
py: number,
map: ScreenMap,
skip: number | null = null,
): { line: number; atM: number; point: Vertex; vertex: number | null; segment: number } | null {
const cursor = map.toMetric(px, py);
let best: ReturnType<typeof lineAt> = null;
let bestPx = LINE_HIT_PX;
network.lines.forEach((line, index) => {
if (line.vertices.length < 2 || index === skip) return;
// 붙는 반경 안 꺾임점 중 가장 가까운 것
let vertex = -1;
let vertexPx = SNAP_PX;
line.vertices.forEach((v, i) => {
const [vx, vy] = map.toScreen(v);
const d = Math.hypot(vx - px, vy - py);
if (d <= vertexPx) [vertex, vertexPx] = [i, d];
});
const atM =
vertex >= 0
? lineLength(line.vertices.slice(0, vertex + 1))
: distanceAlong(line.vertices, cursor);
const point = vertex >= 0 ? line.vertices[vertex] : pointAtDistance(line.vertices, atM);
const [sx, sy] = map.toScreen(point);
const gap = vertex >= 0 ? 0 : Math.hypot(sx - px, sy - py);
if (gap > bestPx || (best && gap === bestPx)) return;
// 누가가 든 구간(꺾임점 끼울 자리)
let segment = 0;
for (let walked = 0; segment < line.vertices.length - 2; segment += 1) {
walked += segmentLength(line.vertices[segment], line.vertices[segment + 1]);
if (walked >= atM) break;
}
best = { line: index, atM, point, vertex: vertex >= 0 ? vertex : null, segment };
bestPx = gap;
});
return best;
}
/** 그은 선 · 측점 · 구간 글 · 꺾임점 · 마크 · 연도 · (extra = 미리보기) · 시점(초록) · 종점(빨강).
* `active` = 그리는 선(굵은 보라). */
export function paintNetwork(
context: CanvasRenderingContext2D,
map: ScreenMap,
network: PlanNetwork,
base: { starts: Vertex[]; ends: Vertex[] },
active: number | null,
z: HeightAt,
extra: () => void,
): void {
const { starts, ends } = base;
const colors = planColors();
context.lineCap = "round";
context.lineJoin = "round";
network.lines.forEach((line, index) => {
if (line.vertices.length < 2) return;
context.strokeStyle = index === active ? colors.active : colors.line;
context.lineWidth = line.branch ? 2.5 : 3;
context.beginPath();
line.vertices.forEach((v, i) => {
const [sx, sy] = map.toScreen(v);
if (i) context.lineTo(sx, sy);
else context.moveTo(sx, sy);
});
context.stroke();
if (map.stations)
drawStationTicks(
context,
line.vertices.map(([x, y]) => ({ x, y })),
{
intervalM: PLAN_STATION_INTERVAL_M,
pxPerMeter: map.pxPerMeter,
toScreen: (x, y) => map.toScreen([x, y]),
uprightRad: map.uprightRad,
chainages: stationChainages(lineLength(line.vertices), PLAN_STATION_INTERVAL_M),
},
);
if (map.slopes) segmentLabels(context, map, line.vertices, z);
});
// 꺾임점
context.fillStyle = "#ffffff";
for (const line of network.lines)
for (const v of line.vertices.slice(1)) {
const [sx, sy] = map.toScreen(v);
context.beginPath();
context.arc(sx, sy, 3, 0, Math.PI * 2);
context.fill();
}
drawPieces(context, map, network);
extra();
// 시점(초록) · 종점(빨강)
for (const [points, fill] of [
[starts, "#16a34a"],
[ends, "#dc2626"],
] as const)
for (const point of points) {
const [sx, sy] = map.toScreen(point);
context.beginPath();
context.arc(sx, sy, 7, 0, Math.PI * 2);
context.fillStyle = fill;
context.fill();
context.lineWidth = 2;
context.strokeStyle = "#ffffff";
context.stroke();
}
}
function segmentLabels(
context: CanvasRenderingContext2D,
map: ScreenMap,
vertices: Vertex[],
z: HeightAt,
): void {
for (let i = 1; i < vertices.length; i += 1) {
const [a, b] = [map.toScreen(vertices[i - 1]), map.toScreen(vertices[i])];
if (Math.hypot(b[0] - a[0], b[1] - a[1]) < LABEL_MIN_PX) continue;
const slope = groundSlopePct(vertices[i - 1], vertices[i], z);
const text = `${fmt(segmentLength(vertices[i - 1], vertices[i]))} m · ${slope === null ? "—" : `${signed(slope)} %`}`;
planLabel(context, map, (a[0] + b[0]) / 2, (a[1] + b[1]) / 2 - 12, text, "11px");
}
}
function drawPieces(context: CanvasRenderingContext2D, map: ScreenMap, network: PlanNetwork): void {
const colors = planColors();
network.pieces.forEach((piece) => {
const vertices = network.lines.find((line) => line.id === piece.line)?.vertices;
if (!vertices || vertices.length < 2) return;
if (piece.from_m > 0) {
const [sx, sy] = map.toScreen(pointAtDistance(vertices, piece.from_m));
context.beginPath();
context.moveTo(sx, sy - 8);
context.lineTo(sx + 6, sy);
context.lineTo(sx, sy + 8);
context.lineTo(sx - 6, sy);
context.closePath();
context.fillStyle = colors.active;
context.fill();
context.lineWidth = 1.5;
context.strokeStyle = "#ffffff";
context.stroke();
}
const [mx, my] = map.toScreen(pointAtDistance(vertices, (piece.from_m + piece.to_m) / 2));
planLabel(context, map, mx, my + 16, `${piece.year}년`, "bold 12px");
});
}
/** 미리보기 — 점선 · 둘 다(양각기) 원 · 후보 · 글(거리 · 가상 종단 기울기 % · °). */
export function paintPreview(
context: CanvasRenderingContext2D,
map: ScreenMap,
from: Vertex,
preview: DrawPreview,
compass: { lengthM: number; candidates: Vertex[] } | null,
): void {
const colors = planColors();
const [ax, ay] = map.toScreen(from);
const [bx, by] = map.toScreen(preview.point);
if (compass) {
context.strokeStyle = colors.active;
context.lineWidth = 1;
context.setLineDash([3, 4]);
context.beginPath();
context.arc(ax, ay, compass.lengthM * map.pxPerMeter, 0, Math.PI * 2);
context.stroke();
context.fillStyle = colors.active;
for (const c of compass.candidates) {
const [cx, cy] = map.toScreen(c);
context.beginPath();
context.arc(cx, cy, 3, 0, Math.PI * 2);
context.fill();
}
}
context.strokeStyle = preview.blocked ? colors.warn : colors.active;
context.lineWidth = 2;
context.setLineDash([6, 4]);
context.beginPath();
context.moveTo(ax, ay);
context.lineTo(bx, by);
context.stroke();
context.setLineDash([]);
context.beginPath();
context.arc(bx, by, 5, 0, Math.PI * 2);
context.stroke();
const value =
`${fmt(preview.lengthM)} m · ` +
(preview.slopePct === null ? "지형 밖" : slopeText(preview.slopePct));
planLabel(
context,
map,
bx + 14,
by - 14,
preview.note ? `${value} · ${preview.note}` : value,
"12px",
"left",
);
}
export function planLabel(
context: CanvasRenderingContext2D,
map: ScreenMap,
x: number,
y: number,
text: string,
font: string,
align: CanvasTextAlign = "center",
): void {
map.upright(context, x, y, () => {
context.font = `${font} system-ui, sans-serif`;
context.textAlign = align;
context.textBaseline = "middle";
context.lineWidth = 3;
context.strokeStyle = planColors().halo;
context.strokeText(text, x, y);
context.fillStyle = "#ffffff";
context.fillText(text, x, y);
});
}
@@ -0,0 +1,321 @@
/* =============================================================================
* D03_PlanAnalysis_UI_Edit.ts
* D03 고르기 · 고치기 겹(PLAN 580 · 요구서 440 3 · 6 · 7) — 쉴 때(긋기 · 연도 구분 · 재기 아님)만.
*
* 선 누름 = 그 선 · 조각 고름(굵게 · 좌측 「구역 · 연도」 그 줄 밝힘) · 빈 곳 = 고름 풂.
* 고른 선 위 누름 = 꺾임점 더함(그대로 끌면 옮김) · 꺾임점 끌기 = 옮김(붙은 갈래 첫 점 같이) ·
* 마크 끌기 = 연도 구분 옮김 · 오른쪽 누름 = 꺾임점 · 마크 지움 · Delete = 고른 선 지움.
* 잠금 = 시점 · 종점에 놓인 점(1단계 몫) · 갈래 첫 점(붙은 선 점을 끌면 같이 감).
* 끌기 중 = 화면만(`show`) · 놓을 때 `commit` 한 번 = 되돌리기 한 걸음.
* 좌측 「고른 선」 칸 = 이름 · 길이 · [방향 바꾸기](측점 No.0 쪽 바꿈) · [선 지움].
* ========================================================================== */
import { createButton, showToast } from "@ui/ui_template_elements";
import { createText } from "@ui/ui_template_text";
import {
distanceAlong,
lineLength,
pointAtDistance,
sliceLine,
} from "./logic/D03_PlanAnalysis_Draw";
import {
insertVertex,
lineNames,
moveMark,
moveVertex,
removeLine,
removeMark,
removeVertex,
reverseLine,
samePoint,
type PlanNetwork,
type Vertex,
} from "./logic/D03_PlanAnalysis_Plan";
import { lineAt, screenGap, SNAP_PX } from "./D03_PlanAnalysis_UI_DrawPaint";
import { planColors, type PlanMapLayer, type ScreenMap } from "./D03_PlanAnalysis_UI_Map";
export interface PlanEditParams {
network: () => PlanNetwork;
base: () => { starts: Vertex[]; ends: Vertex[] };
commit: (next: PlanNetwork) => void;
/** 끌기 중 — 화면만 바꿈(되돌리기 · 캐시 없음). */
show: (next: PlanNetwork) => void;
/** 고르기 받는 때(쉴 때 · 재기 아님). */
enabled: () => boolean;
/** 고치기 됨(산림청). */
editable: () => boolean;
/** 고름이 바뀜 — 페이지가 좌측 줄 밝힘. */
onSelect: () => void;
}
export interface PlanSelection {
line: string;
piece: string | null;
}
export interface PlanEdit {
layer: PlanMapLayer;
/** 좌측 「고른 선」 칸. */
panel: HTMLElement;
selected: () => PlanSelection | null;
select: (line: string | null, piece?: string | null) => void;
/** 키 — 받았으면 참(Delete · Esc). */
key: (event: KeyboardEvent) => boolean;
/** 되돌리기 · 저장 뒤 — 고른 선이 사라졌으면 풂 · 칸 다시. */
sync: () => void;
}
type Drag =
| { kind: "vertex"; line: number; vertex: number; before: PlanNetwork; last: PlanNetwork | null }
| { kind: "mark"; index: number; before: PlanNetwork; last: PlanNetwork | null }
| { kind: "locked"; note: string; told: boolean };
export function createPlanEdit(params: PlanEditParams): PlanEdit {
let selected: PlanSelection | null = null;
let drag: Drag | null = null;
const info = createText("", "muted");
const flipButton = createButton({
label: "방향 바꾸기",
variant: "ghost",
size: 24,
onClick: () => apply((network, index) => reverseLine(network, index)),
});
flipButton.title = "측점 No.0 쪽(시점 쪽)을 반대 끝으로 바꿉니다.";
const dropButton = createButton({
label: "선 지움",
variant: "ghost",
size: 24,
onClick: () => apply((network, index) => removeLine(network, index)),
});
const row = document.createElement("div");
row.className = "d03-plan__row";
row.append(flipButton, dropButton);
const panel = document.createElement("div");
panel.className = "d03-plan__pick";
panel.dataset.role = "plan-pick";
panel.append(info, row);
const indexOf = (network: PlanNetwork): number =>
selected ? network.lines.findIndex((line) => line.id === selected!.line) : -1;
/** 고른 선에 꼴 바꿈 하나 — 까닭 글이면 알림. */
function apply(step: (network: PlanNetwork, index: number) => PlanNetwork | string): void {
const network = params.network();
const index = indexOf(network);
if (index < 0 || !params.editable()) return;
const next = step(network, index);
if (typeof next === "string") return void showToast(next, "info");
params.commit(next);
sync();
}
function panelState(): void {
const network = params.network();
const index = indexOf(network);
panel.hidden = index < 0;
if (index < 0) return;
const line = network.lines[index];
info.textContent = `고른 선 — ${lineNames(network.lines)[index]} · ${lineLength(line.vertices).toFixed(1)} m`;
flipButton.hidden = dropButton.hidden = !params.editable();
flipButton.disabled = !!line.branch;
}
function select(line: string | null, piece: string | null = null): void {
selected = line ? { line, piece } : null;
panelState();
params.onSelect();
}
function sync(): void {
if (selected && indexOf(params.network()) < 0) selected = null;
if (selected?.piece && !params.network().pieces.some((p) => p.id === selected!.piece))
selected = { ...selected, piece: null };
panelState();
}
const pieceAt = (network: PlanNetwork, line: string, atM: number): string | null =>
network.pieces.find((p) => p.line === line && atM >= p.from_m - 1e-6 && atM <= p.to_m + 1e-6)
?.id ?? null;
/** 화면 px 둘레 마크(조각 시작) — 조각 차례. */
function markAt(network: PlanNetwork, px: number, py: number, map: ScreenMap): number | null {
let best: number | null = null;
let bestPx = SNAP_PX;
network.pieces.forEach((piece, index) => {
const vertices = network.lines.find((line) => line.id === piece.line)?.vertices;
if (!vertices || !(piece.from_m > 0)) return;
const gap = screenGap(map, pointAtDistance(vertices, piece.from_m), px, py);
if (gap <= bestPx) [best, bestPx] = [index, gap];
});
return best;
}
/** 화면 px 둘레 꺾임점 — 갈래 첫 점은 붙은 선 점이 대신 집힘. */
function vertexAt(
network: PlanNetwork,
px: number,
py: number,
map: ScreenMap,
): { line: number; vertex: number } | null {
let best: { line: number; vertex: number } | null = null;
let bestPx = SNAP_PX;
network.lines.forEach((line, i) =>
line.vertices.forEach((point, v) => {
if (v === 0 && line.branch) return;
const gap = screenGap(map, point, px, py);
if (gap <= bestPx) [best, bestPx] = [{ line: i, vertex: v }, gap];
}),
);
return best;
}
/** 잠금 까닭 — 시점 · 종점에 놓인 점. */
function lockOf(network: PlanNetwork, line: number, vertex: number): string | null {
const point = network.lines[line].vertices[vertex];
const { starts, ends } = params.base();
return [...starts, ...ends].some((p) => samePoint(p, point))
? "시점 · 종점 자리 점은 1단계(기준 지정)에서 옮깁니다."
: null;
}
const layer: PlanMapLayer = {
draw(context, map) {
const network = params.network();
const index = indexOf(network);
if (index < 0) return;
const colors = planColors();
const line = network.lines[index];
const path = (points: Vertex[]): void => {
context.beginPath();
points.forEach((point, i) => {
const [sx, sy] = map.toScreen(point);
if (i) context.lineTo(sx, sy);
else context.moveTo(sx, sy);
});
context.stroke();
};
context.lineCap = "round";
context.lineJoin = "round";
context.strokeStyle = colors.active;
context.globalAlpha = 0.45;
context.lineWidth = 9;
path(line.vertices);
const piece = network.pieces.find((p) => p.id === selected?.piece);
if (piece) {
context.globalAlpha = 0.85;
context.lineWidth = 6;
path(sliceLine(line.vertices, piece.from_m, piece.to_m));
}
context.globalAlpha = 1;
// 꺾임점 손잡이
context.lineWidth = 2;
context.fillStyle = "#ffffff";
for (const point of line.vertices) {
const [sx, sy] = map.toScreen(point);
context.fillRect(sx - 4, sy - 4, 8, 8);
context.strokeRect(sx - 4, sy - 4, 8, 8);
}
},
press(px, py, map, button) {
if (!params.enabled()) return false;
const network = params.network();
const editable = params.editable();
const mark = markAt(network, px, py, map);
const vertex = vertexAt(network, px, py, map);
if (button === 2) {
if (!editable) return false;
if (mark !== null) {
const piece = network.pieces[mark];
params.commit(removeMark(network, piece.line, piece.from_m, 1e-3));
sync();
return true;
}
if (!vertex) return false;
const next = removeVertex(network, vertex.line, vertex.vertex);
if (typeof next === "string") showToast(next, "info");
else params.commit(next);
sync();
return true;
}
if (mark !== null) {
const piece = network.pieces[mark];
select(piece.line, piece.id);
if (editable) drag = { kind: "mark", index: mark, before: network, last: null };
return true;
}
if (vertex) {
const id = network.lines[vertex.line].id;
const atM = lineLength(network.lines[vertex.line].vertices.slice(0, vertex.vertex + 1));
select(id, pieceAt(network, id, atM));
const lock = lockOf(network, vertex.line, vertex.vertex);
if (editable)
drag = lock
? { kind: "locked", note: lock, told: false }
: { kind: "vertex", ...vertex, before: network, last: null };
return true;
}
const hit = lineAt(network, px, py, map);
if (hit) {
const id = network.lines[hit.line].id;
if (editable && selected?.line === id) {
params.commit(insertVertex(network, hit.line, hit.segment, hit.point));
const before = params.network();
drag = { kind: "vertex", line: hit.line, vertex: hit.segment + 1, before, last: null };
}
select(id, pieceAt(params.network(), id, hit.atM));
return true;
}
if (!selected) return false;
select(null);
return true;
},
hover(px, py, map) {
if (!drag) return;
if (drag.kind === "locked") {
if (!drag.told) showToast(drag.note, "info");
drag.told = true;
return;
}
const cursor = map.toMetric(px, py);
if (drag.kind === "vertex") {
drag.last = moveVertex(drag.before, drag.line, drag.vertex, cursor);
} else {
const piece = drag.before.pieces[drag.index];
const vertices = drag.before.lines.find((line) => line.id === piece.line)!.vertices;
drag.last = moveMark(drag.before, drag.index, distanceAlong(vertices, cursor));
}
params.show(drag.last);
},
release() {
const done = drag;
drag = null;
if (!done || done.kind === "locked" || !done.last) return;
params.show(done.before);
params.commit(done.last);
sync();
},
};
panelState();
return {
layer,
panel,
selected: () => selected,
select,
key(event) {
if (!selected || !params.enabled()) return false;
if (event.key === "Delete" && params.editable()) {
apply((network, index) => removeLine(network, index));
return true;
}
if (event.key === "Escape") {
select(null);
return true;
}
return false;
},
sync,
};
}
+16 -3
View File
@@ -64,11 +64,12 @@ export interface ScreenMap {
upright: (context: CanvasRenderingContext2D, x: number, y: number, paint: () => void) => void;
}
/** 평면 위 겹 — 그리기 · 누름(참 = 받음 · 0 왼쪽 · 2 오른쪽) · 마우스 곁 · 나감. */
/** 평면 위 겹 — 그리기 · 누름(참 = 받음 · 0 왼쪽 · 2 오른쪽) · 마우스 곁(왼쪽 끌기 중도) · 놓음 · 나감. */
export interface PlanMapLayer {
draw: (context: CanvasRenderingContext2D, map: ScreenMap) => void;
press?: (px: number, py: number, map: ScreenMap, button: 0 | 2) => boolean;
hover?: (px: number, py: number, map: ScreenMap) => void;
release?: () => void;
leave?: () => void;
}
@@ -152,11 +153,15 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
label: "평면 도구",
hint: {
lines: [
"[긋기] 뒤 시점 · 종점 · 선 끝 · 선 위 누름 = 시작",
"누름 = 점 찍기",
"Enter = 긋기 끝",
"Esc = 긋기 취소",
"Backspace = 마지막 점 빼기",
"오른쪽 누름 = 마크 지움",
"선 누름 = 고름 · 고른 선 위 누름 = 꺾임점 더함",
"꺾임점 · 마크 끌기 = 옮김",
"오른쪽 누름 = 꺾임점 · 마크 지움",
"Delete = 고른 선 지움",
"가운데 끌기 = 옮기기",
"휠 = 확대 · 축소",
"Ctrl+휠 = 돌리기",
@@ -270,7 +275,15 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
if (!meta || event.button !== 0) return;
const [px, py] = at(event);
const map = screenMap();
if (layers.some((layer) => layer.press?.(px, py, map, 0))) draw();
if (layers.some((layer) => layer.press?.(px, py, map, 0))) {
canvas.setPointerCapture(event.pointerId); // 끌기가 캔버스 밖으로 나가도 놓음을 받음
draw();
}
});
canvas.addEventListener("pointerup", (event) => {
if (event.button !== 0) return;
for (const layer of layers) layer.release?.();
draw();
});
canvas.addEventListener("pointermove", (event) => {
if (!meta || event.buttons & 4) return; // 가운데 단추 옮기는 중
+120 -32
View File
@@ -1,10 +1,12 @@
/* =============================================================================
* D03_PlanAnalysis_UI_Page.ts
* D 예정노선 2단계 노선분석(요구서 389 3장 6~13 · PLAN 562-3 · 563-1) — B05~B09 틀(`mountGroupStep`).
* D 예정노선 2단계 노선분석(요구서 389 3장 6~13 · 440 · PLAN 562-3 · 563-1 · 579-3 · 580) — B05~B09 틀(`mountGroupStep`).
*
* 메인 = 평면(바탕 겹 켜고 끔 — 위성 · 등고선 · 세류 · 지적 · 도로 · 급경사 + 그은 선 · 측점 · 구역 마크) + 도구 여섯.
* 좌 = 지형(범위를 카카오 지도에 먼저 보임 → [이 범위 받기] · [범위 밖 더 받기]는 선이 받은 범위 밖일 때만) ·
* 그리기(본선 · 갈래 · 구역 마크 · 제약 칸) · 구역 · 연도(조각마다 연도) · [저장] [초기화].
* 메인 = 평면(바탕 겹 켜고 끔 — 위성 · 등고선 · 세류 · 지적 · 도로 · 급경사 + 그은 선 · 측점 · 연도 구분) + 도구 여섯.
* 좌 = 지형(들어올 때 받은 범위가 시점 · 종점을 못 덮으면 바로 받기 · [범위 밖 더 받기]는 시점 · 종점 · 선이
* 받은 범위 밖일 때만) · 그리기([긋기] · [연도 구분] · 제약 칸 · 긋는 해 · 고른 선) ·
* 구역 · 연도(선 통째 해 · 조각마다 해 · 고른 줄 밝힘) · [저장] [초기화].
* 시점 · 종점 = D02 목록(여럿) · 고르기 · 고치기 = `D03_PlanAnalysis_UI_Edit`.
*
* 데이터 3층(CLAUDE.md 5장) — 긋기 · 마크 · 연도 = 캐시(`d03-plan` {version: 2, network}) · [저장] =
* PUT plan/network(서버가 길이 · 측점 · 조각 · 연도 행을 다시 셈 · 응답으로 바꿈) · [초기화] = 빈 선 PUT.
@@ -46,6 +48,7 @@ import {
} from "./D03_PlanAnalysis_Api";
import { openPlan3d } from "./D03_PlanAnalysis_UI_3D";
import { createPlanDraw } from "./D03_PlanAnalysis_UI_Draw";
import { createPlanEdit } from "./D03_PlanAnalysis_UI_Edit";
import { createPlanLayers } from "./D03_PlanAnalysis_UI_Layers";
import { createPlanMap, PLAN_STATION_INTERVAL_M } from "./D03_PlanAnalysis_UI_Map";
import { createPlanMeasure } from "./D03_PlanAnalysis_UI_Measure";
@@ -53,9 +56,10 @@ import { lineLength, stationChainages } from "./logic/D03_PlanAnalysis_Draw";
import {
emptyNetwork,
fromServer,
lineName,
lineNames,
marksOf,
normalizePieces,
setLineYear,
setPieceYear,
type PlanNetwork,
type Vertex,
@@ -82,11 +86,12 @@ const station = (m: number): string => stationLabel(m, PLAN_STATION_INTERVAL_M,
export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
const projectId = currentProjectId();
let editable = false;
let start: Vertex = [0, 0];
let end: Vertex = [0, 0];
let starts: Vertex[] = [];
let ends: Vertex[] = [];
let epsg = 0;
let yearStart = new Date().getFullYear();
let network: PlanNetwork = emptyNetwork(start);
let drawYear = yearStart; // 긋는 해 — 새 조각 처음 해
let network: PlanNetwork = emptyNetwork();
let savedJson = JSON.stringify(network); // 못 읽은 채 떠남 = 고침 없음
let undoStack: PlanNetwork[] = [];
let redoStack: PlanNetwork[] = [];
@@ -99,14 +104,31 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
const z = (x: number, y: number): number => (surface ? gridHeightAt(surface.grid, x, y) : NaN);
const draw = createPlanDraw({
base: () => ({ start, end }),
base: () => ({ starts, ends }),
network: () => network,
heightAt: () => z,
commit,
year: () => drawYear,
onMode: () => {
if (draw.mode() !== "idle") setMeasure(false);
refresh();
},
onMark: (piece) => {
edit.select(network.pieces.find((p) => p.id === piece)?.line ?? null, piece);
openYear(piece);
},
});
const edit = createPlanEdit({
network: () => network,
base: () => ({ starts, ends }),
commit,
show: (next) => {
network = normalizePieces(next, drawYear);
map.redraw();
},
enabled: () => draw.mode() === "idle" && !measuring,
editable: () => editable,
onSelect: () => refresh(),
});
const measure = createPlanMeasure(() => z);
const map = createPlanMap({
@@ -118,7 +140,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
const backdrop = createPlanLayers({
projectId,
map,
points: () => [start, end, ...allVertices()],
points: () => [...starts, ...ends, ...allVertices()],
});
map.root.append(backdrop.bar, backdrop.legend);
const main = document.createElement("div");
@@ -143,8 +165,16 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
});
const terrainCard = createPanelCard("지형", false);
terrainCard.append(terrainText, bar.root, extentButton, moreButton);
const drawYearPick = createSelectField({
label: "긋는 해",
options: [],
size: 24,
ariaLabel: "긋는 해",
onChange: (value) => (drawYear = Number(value)),
});
drawYearPick.select.dataset.role = "draw-year";
const drawCard = createPanelCard("그리기", false);
drawCard.append(draw.panel);
drawCard.append(draw.panel, drawYearPick.root, edit.panel);
const pieceList = document.createElement("div");
pieceList.className = "d03-plan__pieces";
const pieceCard = createPanelCard("구역 · 연도", false);
@@ -183,7 +213,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
function commit(next: PlanNetwork): void {
undoStack = [...undoStack.slice(1 - UNDO_MAX), network];
redoStack = [];
network = normalizePieces(next, yearStart);
network = normalizePieces(next, drawYear);
keepDraft();
refresh();
}
@@ -211,17 +241,25 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
measuring = on;
measure.clear();
if (on) draw.setMode("idle");
map.setLayers([...backdrop.layers, draw.layer, ...(measuring ? [measure.layer] : [])]);
map.setLayers([
...backdrop.layers,
draw.layer,
edit.layer,
...(measuring ? [measure.layer] : []),
]);
refresh();
}
/** 꺾임점이 받은 범위 밖인가(받은 범위 없음 = 밖 아님 — 그땐 [지형 범위 보기]). */
/** 시점 · 종점 · 꺾임점이 받은 범위 밖인가(받은 범위 없음 = 밖 아님 — 그땐 [지형 범위 보기]). */
const outside = (): boolean =>
!!have &&
allVertices().some(([x, y]) => x < have![0] || x > have![2] || y < have![1] || y > have![3]);
[...starts, ...ends, ...allVertices()].some(
([x, y]) => x < have![0] || x > have![2] || y < have![1] || y > have![3],
);
function refresh(): void {
draw.sync();
edit.sync();
map.setTools({
undo: editable && undoStack.length > 0,
redo: editable && redoStack.length > 0,
@@ -238,7 +276,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
: !surface
? "받은 지형 없음 — 범위를 보고 받으세요(미리보기 길이 · 기울기는 지형을 받은 뒤)."
: outside()
? "그은 선이 받은 지형 밖으로 나갔습니다 — 더 받아야 기울기를 셉니다."
? "시점 · 종점 또는 그은 선이 받은 지형 밖입니다 — 더 받아야 기울기를 셉니다."
: "받은 지형 안";
if (failed && editable && !busy) extentButton.hidden = false;
extentButton.querySelector(".ui-btn__label")!.textContent = failed
@@ -248,23 +286,59 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
saveButton.disabled = busy || !dirty();
}
function renderPieces(): void {
/** 고를 해 — 사업연도 시작부터 열 해 + 조각에 있는 해. */
function yearOptions(): Array<{ value: string; text: string }> {
const years = new Set(Array.from({ length: YEAR_SPAN }, (_, i) => yearStart + i));
network.pieces.forEach((piece) => years.add(piece.year));
const options = [...years].sort().map((year) => ({ value: String(year), text: `${year}년` }));
return [...years].sort().map((year) => ({ value: String(year), text: `${year}년` }));
}
/** 연도 구분 마크를 놓은 뒤 — 뒤 조각 해 고름을 엶. */
function openYear(piece: string): void {
// 지도 누름의 기본 동작(초점 걷음)이 끝난 뒤
setTimeout(() => {
const pick = pieceList.querySelector<HTMLSelectElement>(`[data-piece="${piece}"] select`);
if (!pick) return;
pick.scrollIntoView({ block: "nearest" });
pick.focus();
try {
pick.showPicker();
} catch {
// 사용자 누름 밖이면 못 엶 — 초점만
}
});
}
function renderPieces(): void {
const options = yearOptions();
const names = lineNames(network.lines);
const picked = edit.selected();
const rows: HTMLElement[] = [];
network.lines.forEach((line, index) => {
const pieces = network.pieces.filter((piece) => piece.line === line.id);
if (!pieces.length) return;
const head = createText(
`${lineName(index)} · ${lineLength(line.vertices).toFixed(1)} m`,
"muted",
const years = new Set(pieces.map((piece) => piece.year));
const whole = createSelectField({
options: years.size > 1 ? [{ value: "", text: "여러 해" }, ...options] : options,
value: years.size > 1 ? "" : String(pieces[0].year),
size: 24,
ariaLabel: "선 통째 연도",
disabled: !editable,
onChange: (value) => value && commit(setLineYear(network, line.id, Number(value))),
});
const head = document.createElement("div");
head.className = "d03-plan__piece d03-plan__piece-head";
head.classList.toggle("is-selected", picked?.line === line.id && !picked.piece);
head.dataset.line = line.id;
head.append(
createText(`${names[index]} · ${lineLength(line.vertices).toFixed(1)} m`, "muted"),
whole.root,
);
head.classList.add("d03-plan__piece-head");
rows.push(head);
for (const piece of pieces) {
const row = document.createElement("div");
row.className = "d03-plan__piece";
row.classList.toggle("is-selected", picked?.piece === piece.id);
row.dataset.piece = piece.id;
const span = createText(
`${station(piece.from_m)} ~ ${station(piece.to_m)} (${(piece.to_m - piece.from_m).toFixed(1)} m)`,
@@ -425,7 +499,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
network =
server && server.lines.length
? fromServer(server.lines, server.marks, server.pieces, yearStart)
: emptyNetwork(start);
: emptyNetwork();
if (server?.epsg) epsg = server.epsg;
savedJson = JSON.stringify(network);
}
@@ -484,9 +558,10 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
function open3d(): void {
if (!surface) return;
const names = lineNames(network.lines);
const lines = network.lines
.map((line, index) => ({
name: lineName(index),
name: names[index],
line: line.vertices,
stations: stationChainages(lineLength(line.vertices), PLAN_STATION_INTERVAL_M),
}))
@@ -497,9 +572,14 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
// 검증 창구 — 꼴 · 미리보기 · 제약 · 화면 자리 · 재기 · 회전 · 저장 시간(ORCA 판정)
(window as unknown as { __d03Plan?: unknown }).__d03Plan = {
network: () => network,
base: () => ({ start, end }),
base: () => ({ starts, ends }),
lineNames: () => lineNames(network.lines),
screenOf: map.screenOf,
preview: draw.preview,
stroke: draw.stroke,
selected: edit.selected,
select: edit.select,
drawYear: () => drawYear,
constraint: draw.constraint,
setConstraint: draw.setConstraint,
mode: draw.mode,
@@ -525,36 +605,44 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
? (redo(), true)
: measuring && event.key === "Escape"
? (setMeasure(false), true)
: !ctrl && draw.key(event);
: !ctrl && (draw.key(event) || edit.key(event));
if (handled) {
event.preventDefault();
map.redraw();
}
};
window.addEventListener("keydown", onKey);
map.setLayers([...backdrop.layers, draw.layer]);
map.setLayers([...backdrop.layers, draw.layer, edit.layer]);
try {
editable = !!(await fetchSessionUser())?.is_forest_service;
draw.setEditable(editable);
const view = await fetchPlanNetwork(projectId);
const base = view.base;
if (!base.start || !base.end || !base.epsg) {
if (!base.starts.length || !base.ends.length || !base.epsg) {
terrainText.textContent = "시점 · 종점이 비었습니다 — 1단계(기준 지정)에서 정하세요.";
return;
}
start = [base.start.x, base.start.y];
end = [base.end.x, base.end.y];
starts = base.starts.map((p): Vertex => [p.x, p.y]);
ends = base.ends.map((p): Vertex => [p.x, p.y]);
epsg = base.epsg;
takeServer(view);
drawYear = yearStart;
drawYearPick.setOptions(yearOptions(), String(drawYear));
const draft = readState<Draft>("d03-plan", projectId);
if (draft?.version === DRAFT_VERSION && draft.network?.lines?.length) network = draft.network;
map.update({ fit: [start, end, ...allVertices()] });
map.update({ fit: [...starts, ...ends, ...allVertices()] });
refresh();
await loadSurface();
refresh();
await backdrop.load();
if ((await fetchPlanProgress(projectId)).terrain.running) await follow();
const job = (await fetchPlanProgress(projectId)).terrain;
if (job.running) await follow();
else if (editable && !job.error) {
// 들어올 때 받은 범위가 시점 · 종점을 못 덮으면 바로 받기(요구 441 1 · 실패한 뒤엔 [다시 받기])
const extent = await fetchTerrainExtent(projectId, null, []);
if (!extent.covered) await download(extent.margin_m, []);
}
} catch (error) {
terrainText.textContent = `불러오지 못했습니다 — ${errorText(error)}`;
}
@@ -124,3 +124,9 @@
justify-content: flex-end;
margin-bottom: 6px;
}
/* 580-3 고른 선 · 조각 줄 밝힘 */
.d03-plan__piece.is-selected {
background: var(--color-accent-soft);
border-radius: var(--radius-md);
}
+288 -18
View File
@@ -1,15 +1,17 @@
/* =============================================================================
* D03_PlanAnalysis_Plan.ts
* D 예정노선 2단계 손 긋기 꼴(PLAN 562-3) — 그은 선 · 갈래 · 조각(구역 × 연도) 다루기.
* D 예정노선 2단계 손 긋기 꼴(PLAN 562-3 · 580) — 그은 선 · 갈래 · 조각(구역 × 연도) 다루기 ·
* 선 뒤집기 · 잇기 · 꺾임점 옮김 · 지움 · 선 지움 · 선 통째 연도 · 마크 옮김.
* DOM · fetch · sessionStorage 없음 · 길이 셈 = 562-1 짝(`D03_PlanAnalysis_Draw`).
* [저장] 은 서버가 선 · 조각으로 연도 행을 다시 셈(562-2) — 여기 값을 받아 적지 않음.
* ========================================================================== */
import { lineLength } from "./D03_PlanAnalysis_Draw";
import { lineLength, segmentLength } from "./D03_PlanAnalysis_Draw";
export type Vertex = [number, number];
/** 그은 선 하나 — 첫 선 = 본선(D02 시점에서) · 둘째부터 = 갈래(`branch` = 붙은 선 · 꺾임점 차례). */
/** 그은 선 하나 — `branch` 없음 = 본선(시점 · 종점에서) · 있음 = 갈래(붙은 선 · 꺾임점 차례) ·
* 꼭짓점 차례 = 측점 차례(첫 점 = No.0). */
export interface PlanLine {
id: string;
vertices: Vertex[];
@@ -32,8 +34,11 @@ export interface PlanNetwork {
/** 마크가 끝 · 다른 마크와 이보다 가까우면 안 놓음(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 {
@@ -41,9 +46,28 @@ function nextPieceId(pieces: ReadonlyArray<PlanPiece>): string {
return `p${Math.max(0, ...used) + 1}`;
}
/** 빈 선 — 본선 시점만. */
export function emptyNetwork(start: Vertex): PlanNetwork {
return { lines: [{ id: "main", vertices: [start], branch: null }], pieces: [] };
/** 빈 꼴 — 선 없음(긋기 = 시점 · 종점 · 선에서 시작). */
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] };
}
/** 선마다 조각이 0 ~ 길이를 빈틈없이 덮게 맞춤 — 경계 = 조각 시작(0 · 끝 근처 빼고) · 연도 = 그 자리 조각 ·
@@ -186,14 +210,12 @@ export function dropLastLine(network: PlanNetwork): PlanNetwork {
export function addBranch(network: PlanNetwork, line: number, vertex: number): PlanNetwork {
const parent = network.lines[line];
const from = parent.vertices[vertex];
const used = new Set(network.lines.map((entry) => entry.id));
let n = network.lines.length;
while (used.has(`branch-${n}`)) n += 1;
const id = nextLineId(network.lines, "branch");
return {
...network,
lines: [
...network.lines,
{ id: `branch-${n}`, vertices: [[from[0], from[1]]], branch: { line: parent.id, vertex } },
{ id, vertices: [[from[0], from[1]]], branch: { line: parent.id, vertex } },
],
};
}
@@ -223,14 +245,262 @@ export function marksOf(network: PlanNetwork): Array<{ line: string; at_m: numbe
.map((piece) => ({ line: piece.line, at_m: piece.from_m }));
}
/** 본선이 종점에 닿았나(1 mm). */
export function reachedEnd(network: PlanNetwork, end: Vertex): boolean {
const main = network.lines[0]?.vertices ?? [];
const last = main[main.length - 1];
return !!last && Math.hypot(last[0] - end[0], last[1] - end[1]) < 1e-3;
/** 선 이름 — 갈래 없는 선 = 본선(둘 이상이면 본선 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}` : "본선",
);
}
/** 선 이름 — 본선 · 갈래 1 · 갈래 2 …(선 차례). */
export function lineName(index: number): string {
return index === 0 ? "본선" : `갈래 ${index}`;
/** 선 차례 — 갈래는 부모 뒤(서버 셈이 앞에서부터 부모를 찾음) · 이미 맞으면 그대로. */
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,
),
};
}
+21 -5
View File
@@ -41,8 +41,24 @@ async def list_year_routes(project_id: str) -> list[dict[str, Any]]:
return rows
def line_names(lines: list[dict[str, Any]]) -> list[str]:
"""선 이름 — 갈래 없는 선 = 본선(둘 이상이면 본선 1 · 2 …) · 갈래 = 갈래 1 · 2 …(선 차례).
⚠ 짝 = TS `D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.ts` `lineNames` — 거울 시험 530-3."""
roots = sum(1 for line in lines if not line.get("branch"))
out, root, branch = [], 0, 0
for line in lines:
if line.get("branch"):
branch += 1
out.append(f"갈래 {branch}")
else:
root += 1
out.append(f"본선 {root}" if roots > 1 else "본선")
return out
async def list_pieces(project_id: str) -> list[dict[str, Any]]:
"""조각 행(연도 · 선 차례 · 선 위 거리 차례) + `line_name`(본선 · 갈래 N = 정본 선 차례)."""
"""조각 행(연도 · 선 차례 · 선 위 거리 차례) + `line_name`(`line_names` = 정본 선 차례)."""
async with (
get_db_pool().acquire() as connection,
connection.cursor(aiomysql.DictCursor) as cursor,
@@ -61,13 +77,13 @@ async def list_pieces(project_id: str) -> list[dict[str, Any]]:
if network:
raw = network["network_json"]
body = json.loads(raw) if isinstance(raw, str | bytes) else raw
lines = [line["id"] for line in (body or {}).get("lines", [])]
order = {line: i for i, line in enumerate(lines)}
lines = (body or {}).get("lines", [])
order = {line["id"]: i for i, line in enumerate(lines)}
names = dict(zip(order, line_names(lines)))
for row in rows:
raw = row["route_json"]
row["route_json"] = json.loads(raw) if isinstance(raw, str | bytes) else raw
index = order.get(row["line_id"], len(order))
row["line_name"] = "본선" if index == 0 else f"갈래 {index}"
row["line_name"] = names.get(row["line_id"], "갈래")
rows.sort(key=lambda r: (r["year"], order.get(r["line_id"], len(order)), r["from_m"]))
return rows
+125 -6
View File
@@ -1,7 +1,9 @@
"""D03 손 긋기 꼴 다루기(PLAN 562-3 · 563-1) — TS `logic/D03_PlanAnalysis_Plan.ts`.
"""D03 손 긋기 꼴 다루기(PLAN 562-3 · 563-1 · 580) — TS `logic/D03_PlanAnalysis_Plan.ts`.
조각이 선을 빈틈없이 덮음 · 마크 놓기 · 지우기 · 갈래 차례 밀기 · 갈래 붙은 점 못 뺌 · 서버 꼴 읽기 ·
화면이 보내는 꼴(선 · 마크 · 조각)을 서버 셈(`D03_PlanAnalysis_Network.derive`)이 그대로 받음.
조각이 선을 빈틈없이 덮음 · 마크 놓기 · 지우기 · 갈래 차례 밀기 · 갈래 붙은 점 못 뺌 ·
서버 꼴 읽기 · 화면 꼴(선 · 마크 · 조각)을 서버 셈(`derive`)이 그대로 받음 ·
580 = 종점에서 그은 선 뒤집어 잇기 · 갈래로 붙음 · 꺾임점 옮김 · 지움(마크 비율) ·
선 지움 · 방향 바꿈 · 선 통째 연도 · 마크 옮김 · 선 이름 거울(D04 `line_names`).
./venv/Scripts/python.exe -m pytest resources/tester/test_530_3_plan_logic.py -q
"""
@@ -19,6 +21,7 @@ if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from D03_PlanAnalysis import D03_PlanAnalysis_Network as server # noqa: E402
from D04_PlanConfirm.D04_PlanConfirm_Repository import line_names # noqa: E402
TS_FILE = ROOT / "D03_PlanAnalysis" / "logic" / "D03_PlanAnalysis_Plan.ts"
TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
@@ -27,7 +30,8 @@ _RUNNER = """
import { writeFileSync } from "node:fs";
import * as plan from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.js";
const Y = 2026;
let n = plan.emptyNetwork([0, 0]);
const empty = plan.emptyNetwork();
let n = plan.newLine(empty, [0, 0]);
n = plan.appendVertex(n, 0, [100, 0]);
n = plan.appendVertex(n, 0, [100, 100]);
n = plan.normalizePieces(n, Y);
@@ -52,10 +56,57 @@ const read = plan.fromServer(
[{ id: "p9", line: "main", from_m: 0, to_m: 50, year: 2028 }],
Y,
);
// 580 — 본선(0,0)→(100,0) 반쯤 · 종점(200,0)에서 거꾸로 그은 선이 본선 꼬리에 닿음 → 한 선
let j = plan.normalizePieces(plan.appendVertex(plan.newLine(empty, [0, 0]), 0, [100, 0]), Y);
j = plan.newLine(j, [200, 0]);
j = plan.appendVertex(j, 1, [150, 50]);
j = plan.appendVertex(j, 1, [100, 0]);
j = plan.normalizePieces(j, 2027);
const joined = plan.normalizePieces(plan.attachHead(plan.reverseLine(j, 1), 1), Y);
const sameYear = plan.normalizePieces(
plan.attachHead(plan.reverseLine(plan.setLineYear(j, j.lines[1].id, Y), 1), 1),
Y,
);
// 종점에서 그은 선이 본선 꺾임점에 닿음 → 그 점의 갈래(선 차례 = 부모 뒤)
let b = plan.normalizePieces(
plan.appendVertex(plan.appendVertex(plan.newLine(empty, [0, 0]), 0, [100, 0]), 0, [200, 0]),
Y,
);
b = plan.newLine(b, [100, 80]);
b = plan.appendVertex(b, 1, [100, 0]);
const asBranch = plan.attachHead(plan.reverseLine(b, 1), 1);
// 꺾임점 옮김 · 지움 — 마크 비율 · 붙은 갈래 첫 점
let m = plan.normalizePieces(
plan.appendVertex(plan.appendVertex(plan.newLine(empty, [0, 0]), 0, [100, 0]), 0, [100, 100]),
Y,
);
m = plan.normalizePieces(plan.splitPiece(m, "main", 150), Y);
m = plan.normalizePieces(plan.appendVertex(plan.addBranch(m, 0, 1), 1, [160, 0]), Y);
const movedV = plan.normalizePieces(plan.moveVertex(m, 0, 1, [100, -100]), Y);
let r = plan.normalizePieces(
plan.appendVertex(plan.appendVertex(plan.newLine(empty, [0, 0]), 0, [100, 0]), 0, [100, 100]),
Y,
);
r = plan.normalizePieces(plan.splitPiece(r, "main", 150), Y);
const removedV = plan.normalizePieces(plan.removeVertex(r, 0, 1), Y);
const removeBlocked = plan.removeVertex(m, 0, 1);
const lineBlocked = plan.removeLine(m, 0);
const lineGone = plan.normalizePieces(plan.removeLine(m, 1), Y);
const reversed = plan.normalizePieces(plan.reverseLine(m, 0), Y);
const branchFlip = plan.reverseLine(m, 1);
const lineYear = plan.setLineYear(m, "main", 2029);
const markMoved = plan.normalizePieces(plan.moveMark(m, 1, 120), Y);
const markClamp = plan.normalizePieces(plan.moveMark(m, 1, 500), Y);
const names = [
plan.lineNames([{ branch: null }]),
plan.lineNames([{ branch: null }, { branch: null }, { branch: { line: "main", vertex: 1 } }]),
plan.lineNames([{ branch: null }, { branch: { line: "main", vertex: 1 } }]),
];
writeFileSync(process.argv[2], JSON.stringify({
one, split, tooClose, branched, inserted, blocked, dropped, guard, removed, grown, read,
marks: plan.marksOf(branched),
reached: plan.reachedEnd(n, [100, 100]),
empty, joined, sameYear, asBranch, m, movedV, removedV, removeBlocked, lineBlocked, lineGone,
reversed, branchFlip, lineYear, markMoved, markClamp, names,
}));
"""
@@ -111,7 +162,7 @@ def test_pieces_cover_and_split(tmp_path: Path) -> None:
assert got["tooClose"] == got["split"] # 마크 1 m 안 = 안 놓음
assert _spans(got["removed"]) == [(0, 200, 2026)] # 마크 지움 = 앞 조각 연도
assert _spans(got["grown"]) == [(0, 50, 2026), (50, 250, 2027)] # 선이 늘면 끝 조각이 늚
assert got["reached"] is True
assert got["empty"] == {"lines": [], "pieces": []} # 빈 꼴 = 선 없음(시점 · 종점에서 시작)
def test_branch_order_and_guard(tmp_path: Path) -> None:
@@ -144,3 +195,71 @@ def test_server_accepts_screen_body(tmp_path: Path) -> None:
("main", 50.0, 200.0, 2027),
("branch-1", 0.0, 60.0, 2026),
]
def test_reversed_stroke_joins_or_branches(tmp_path: Path) -> None:
got = _run_ts(tmp_path)
joined = got["joined"]
# 종점에서 그은 선 = 뒤집혀 본선 꼬리에 이어 한 선 · No.0 = 시점 쪽 · 다른 해면 이음 자리 마크
assert len(joined["lines"]) == 1
assert joined["lines"][0]["vertices"] == [[0, 0], [100, 0], [150, 50], [200, 0]]
total = 100 + 2 * (50**2 * 2) ** 0.5
spans = _spans(joined)
assert spans[0] == (0, 100, 2026) and spans[1][0] == 100 and spans[1][2] == 2027
assert abs(spans[1][1] - total) < 1e-3
assert len(got["sameYear"]["pieces"]) == 1 # 같은 해면 이음 자리 마크 없음
branch = got["asBranch"]["lines"][1]
assert branch["branch"] == {"line": "main", "vertex": 1}
assert branch["vertices"] == [[100, 0], [100, 80]]
body = {**got["asBranch"], "marks": [], "pieces": [], "epsg": 5186}
server.derive(body, 2026) # 서버가 그대로 받음(갈래 첫 점 = 부모 점)
body = {**joined, "marks": [], "epsg": 5186}
body["marks"] = [
{"line": p["line"], "at_m": p["from_m"]} for p in joined["pieces"] if p["from_m"] > 0
]
assert len(server.derive(body, 2026)["pieces"]) == 2
def test_vertex_move_remove_keeps_marks(tmp_path: Path) -> None:
got = _run_ts(tmp_path)
# 본선 (0,0)-(100,0)-(100,100) · 마크 150 m(둘째 구간 반) · 갈래 = 꺾임점 1 에서 (160,0)
moved = got["movedV"]
assert moved["lines"][0]["vertices"][1] == [100, -100]
assert moved["lines"][1]["vertices"][0] == [100, -100] # 붙은 갈래 첫 점 같이
first = (100**2 + 100**2) ** 0.5
mark = [p for p in moved["pieces"] if p["line"] == "main"][1]["from_m"]
assert abs(mark - (first + 100)) < 1e-3 # 둘째 구간(길이 200) 반
removed = got["removedV"]
assert removed["lines"][0]["vertices"] == [[0, 0], [100, 100]]
share = 0.75 * (2 * 100**2) ** 0.5 # 합친 구간(200 m) 안 150 m = 3/4
assert abs(_spans(removed)[1][0] - share) < 1e-3
assert "갈래가 붙은 점" in got["removeBlocked"]
assert "갈래가 붙은 선" in got["lineBlocked"]
assert [line["id"] for line in got["lineGone"]["lines"]] == ["main"]
assert all(p["line"] == "main" for p in got["lineGone"]["pieces"])
assert "갈래는 방향을" in got["branchFlip"]
def test_reverse_year_mark_move(tmp_path: Path) -> None:
got = _run_ts(tmp_path)
rev = got["reversed"]
assert rev["lines"][0]["vertices"] == [[100, 100], [100, 0], [0, 0]]
assert rev["lines"][1]["branch"] == {"line": "main", "vertex": 1} # 3 − 1 − 1
assert _spans(rev) == [(0, 50, 2026), (50, 200, 2026)]
assert {p["year"] for p in got["lineYear"]["pieces"] if p["line"] == "main"} == {2029}
assert _spans(got["markMoved"]) == [(0, 120, 2026), (120, 200, 2026)]
assert _spans(got["markClamp"]) == [(0, 199, 2026), (199, 200, 2026)] # 끝에서 1 m 남김
body = {**rev, "marks": [{"line": "main", "at_m": 50}], "epsg": 5186}
server.derive(body, 2026)
def test_line_names_mirror(tmp_path: Path) -> None:
got = _run_ts(tmp_path)
cases = [
[{"branch": None}],
[{"branch": None}, {"branch": None}, {"branch": {"line": "main", "vertex": 1}}],
[{"branch": None}, {"branch": {"line": "main", "vertex": 1}}],
]
assert got["names"] == [line_names(c) for c in cases]
assert got["names"][1] == ["본선 1", "본선 2", "갈래 1"] # 시점 둘 = 둘째도 본선
assert got["names"][2] == ["본선", "갈래 1"] # 옛 꼴 이름 그대로
+2 -1
View File
@@ -47,7 +47,8 @@ def test_d03_parts():
assert '"rotate-ccw", "rotate-cw"' in tools
for b05_only in ("footprint", "station-snap", "contour-minor"):
assert f'"{b05_only}"' not in tools, b05_only
assert "map.stations" in draw and "map.slopes" in draw
paint = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts") # 580 — 그림 나눔
assert "map.stations" in paint and "map.slopes" in paint
# 569-2 — 바탕 겹 단추(왼쪽 위 아이콘 줄) · 처음 켬 셋 · 취향 기억
assert "createToolbar({" in layers and "createIconButton({" in layers
assert '"aislo:pref:d03.layers"' in layers and "writeByKey(PREF_KEY" in layers