Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1

This commit is contained in:
2026-11-03 20:41:02 +09:00
8 changed files with 200 additions and 101 deletions
+59 -63
View File
@@ -8,10 +8,9 @@
* 되돌림 · 선 위 · 꺾임점 = 갈래. 다른 지점 · 다른 선 둘레(SNAP_PX)에 오면 거기 붙고 그 점에서 끝 —
* 머리가 다른 선 꼬리에 닿으면 한 선으로 잇고 · 꺾임점에 닿으면 그 점의 갈래 · 본선 꼬리가 다른 구역에
* 닿아 끝나면 저절로 그 점의 갈래(No.0 = 닿은 점 · 599 · 요구서 463 3장 10).
* 다시 긋기(599-3 · `respan`) = 고른 구간 앞 노드부터 긋고 원래 뒤 노드에 붙으면 끝(속 구간 흐리게 ·
* Enter 로 끝내면 취소) — 끝나면 `replaceSpan` 한 번 = 되돌리기 한 걸음.
* 중간 노드에서 긋기(771-2 · 갈래 숨긴 페이지 = B05) = 다시 긋기의 뒤 노드 열림 — 같은 선 다른 노드에 닿으면
* 그 사이 갈음(`rejoinSpan` · 거꾸로 됨) · 같은 노드 · 안 닿고 끝냄 = 꼴 그대로 + 안내.
* 다시 긋기(774 · 요구 616 · `respan`) = 고른 중간 노드(속성 판 · 우클릭 · B05 긋기 중 누름)부터 긋고 같은 선
* 다른 중간 노드에 닿으면 그 사이 갈음(`rejoinSpan` · 거꾸로 됨 · 되돌리기 한 걸음) · 시작 노드 · 끝점 = 끝 아님
* (계속 긋기 · 안내) · Enter 로 안 닿고 끝냄 = 꼴 그대로 + 안내 · 시작 · 이을 노드 · 마우스 올린 노드 = 고리.
* Esc(771-1) = 이번 긋기 직전 꼴 · 지움 바로 뒤 긋기면 지움 전 꼴(`dropBase`) · 다시 긋기 = 꼴 그대로.
* 미리보기 = 마지막 점 → 마우스 길이(m) · 가상 종단 기울기(원지반 % · ° · + 오르막) — 제약 칸 넷:
* 자유 · 길이 고정 · 기울기 고정(기울기 고정 · 길이 자유 · 마우스 방향 첫 자리) · 길이·기울기 고정(양각기 원 위 후보 중
@@ -49,12 +48,14 @@ import {
type PlanNetwork,
type Vertex,
} from "./logic/D03_PlanAnalysis_Plan";
import { nodeRecord, rejoinSpan, replaceSpan, respanBlocked } from "./logic/D03_PlanAnalysis_Span";
import { joinTouching } from "./logic/D03_PlanAnalysis_OneLine";
import { isMiddleNode, nodeRecord, rejoinMiss, rejoinSpan } from "./logic/D03_PlanAnalysis_Span";
import {
fmt,
lineAt,
paintNetwork,
paintPreview,
paintRejoin,
screenGap,
SNAP_PX,
} from "./D03_PlanAnalysis_UI_DrawPaint";
@@ -69,10 +70,9 @@ import { featureOn, type PlanPage } from "./D03_PlanAnalysis_UI_Features";
import type { PlanMapLayer, ScreenMap } from "./D03_PlanAnalysis_UI_Map";
const START_HINT = "지점 · 선 끝 · 선 위를 눌러 시작합니다 — 빈 땅은 우클릭 「여기서 긋기 시작」.";
/** 중간 노드에서 긋다 다른 노드에 안 닿고 끝냄(771-2). */
const RESPAN_MISS = "다시 긋기는 뒤 노드에 붙어야 끝 — 취소했습니다.";
/** 다시 긋다 다른 중간 노드에 안 닿고 끝냄(771-2 · 774). */
const REJOIN_MISS =
"다른 노드에 닿지 않아 반영하지 않았습니다 — 다른 노드에 닿으면 그 사이가 바뀝니다.";
"다른 중간 노드에 닿지 않아 반영하지 않았습니다 — 다른 중간 노드에 이으면 그 사이가 바뀝니다.";
export type DrawMode = "idle" | "draw";
@@ -82,7 +82,7 @@ export function lineLimitBlock(
network: PlanNetwork,
): string | null {
if (kind === "line" || kind === "point")
return "예정노선은 한 선입니다 — 선 끝에서 이어 긋거나 노드에서 시작해 다른 노드에 닿게 긋습니다.";
return "예정노선은 한 선입니다 — 선 끝에서 이어 긋거나 중간 노드에서 시작해 다른 중간 노드에 잇습니다.";
if (kind === "empty" && network.lines.some((line) => line.vertices.length > 1))
return "예정노선은 한 선입니다 — 지운 뒤 새로 긋습니다.";
return null;
@@ -137,8 +137,8 @@ export interface PlanDraw {
stroke: () => { line: number; flip: boolean } | null;
constraint: () => { kind: DrawConstraint; lengthM: number; slopePct: number };
setConstraint: (kind: DrawConstraint, lengthM?: number, slopePct?: number) => void;
/** 다시 긋기 시작(599-3 · 598 판 [다시 긋기]) — `lineId` 선 `fromVertex`~`toVertex` · 참 = 시작. */
respan: (lineId: string, fromVertex: number, toVertex: number) => boolean;
/** 다시 긋기 시작(774 · 노드 판 [다시 긋기] · 우클릭) — `lineId` 선 중간 노드 `vertex` · 참 = 시작. */
respan: (lineId: string, vertex: number) => boolean;
}
export function createPlanDraw(params: PlanDrawParams): PlanDraw {
@@ -154,11 +154,10 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
let hoverAt: { px: number; py: number; map: ScreenMap; shift: boolean } | null = null; // Tab · Shift 뒤 미리보기 다시
let lengthM = DRAW_DEFAULT_LENGTH_M;
let slopePct = DRAW_DEFAULT_SLOPE_PCT;
// 다시 긋기 — 선 id · 앞 · 뒤 꺾임점(null = 중간 노드에서 긋기 · 같은 선 어느 노드든 · 771-2) · 새로 찍은 속 점 · 그 기록
// 다시 긋기 — 선 id · 시작 중간 노드 · 새로 찍은 속 점 · 그 기록(끝 = 같은 선 다른 중간 노드 · 774)
let span: {
line: string;
from: number;
to: number | null;
through: Vertex[];
records: NodeRecord[];
} | null = null;
@@ -190,7 +189,7 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
return vertices[vertices.length - 1] ?? null;
};
/** 붙을 자리 — 그리는 선 첫 점 아닌 지점 · 다른 선 꺾임점 · 다른 선 위(그리는 선 빼고). */
/** 붙을 자리 — 그리는 선 첫 점 아닌 지점 · 다른 선 꺾임점 · 다른 선 위(그리는 선 빼고) · 한 선 페이지 = 다른 토막 끝만(774-2). */
function snapAt(
px: number,
py: number,
@@ -202,33 +201,39 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
.filter((point) => !head || !samePoint(point, head))
.map((point) => ({ point, note: "지점에 붙음" }));
for (const point of params.snaps?.() ?? []) offers.push({ point, note: "이음점에 붙음" });
const one = !featureOn(params.page, "branch");
network.lines.forEach((line, i) => {
if (i !== active)
for (const point of line.vertices) offers.push({ point, note: "선 꺾임점에 붙음" });
line.vertices.forEach((point, k) => {
if (!one || k === 0 || k === line.vertices.length - 1)
offers.push({ point, note: one ? "끊긴 끝에 이음" : "선 꺾임점에 붙음" });
});
});
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);
const hit = one ? null : lineAt(network, px, py, map, active);
return hit
? { point: hit.point, note: "선 위에 붙음", onLine: { line: hit.line, segment: hit.segment } }
: null;
}
/** 중간 노드에서 긋기 붙을 자리(771-2) — 같은 선 노드 중 가장 가까운 것 · 시작 노드는 한 점 찍은 뒤만. */
/** 다시 긋기 닿은 노드(774) — 같은 선 노드 중 가장 가까운 것 · 다른 중간 노드 = 붙음 · 시작 · 끝점 = 막힘 글. */
function nodeSnap(
vertices: Vertex[],
px: number,
py: number,
map: ScreenMap,
): { point: Vertex; note: string; onLine: null } | null {
): { point: Vertex; note: string; onLine: null; miss: string | null } | null {
const best = vertices
.map((point, i) => ({ point, i, gap: screenGap(map, point, px, py) }))
.filter((item) => item.gap <= SNAP_PX && (item.i !== span!.from || span!.through.length > 0))
.filter((item) => item.gap <= SNAP_PX)
.sort((a, b) => a.gap - b.gap)[0];
return best ? { point: best.point, note: "노드에 닿음", onLine: null } : null;
if (!best) return null;
const miss = rejoinMiss(vertices.length, span!.from, best.i);
return { point: best.point, note: miss ?? "중간 노드에 이음 — 끝", onLine: null, miss };
}
/** 앞 구간 시작 — 정방향 부채꼴(625-2 B5) · 첫 구간 = null · 갈래 첫 구간 = 붙은 선 들어온 쪽. */
@@ -268,20 +273,14 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
const z = params.heightAt();
const cursor = map.toMetric(px, py);
fan = kind === "both" ? compassFan(from, lengthM, slopePct, z, prevOf(), cursor) : null;
// 다시 긋기 = 원래 뒤 노드에만 붙음 · 중간 노드에서 긋기 = 같은 선 노드(시작 노드는 한 점 찍은 뒤부터)
const end = span?.to === null ? undefined : spanVertices?.[span!.to];
const snap = end
? screenGap(map, end, px, py) <= SNAP_PX
? { point: end, note: "뒤 노드에 붙음", onLine: null }
: null
: spanVertices
? nodeSnap(spanVertices, px, py, map)
: snapAt(px, py, map);
// 다시 긋기 = 같은 선 다른 중간 노드에만 붙음(시작 · 끝점 = 못 찍음 · 안내)
const snap = spanVertices ? nodeSnap(spanVertices, px, py, map) : snapAt(px, py, map);
let point: Vertex | null = cursor;
let note: string | null = null;
let lengthOnly = false;
if (snap) {
[point, note] = [snap.point, snap.note];
if ("miss" in snap && snap.miss) point = null;
} else if (kind === "length") {
point = fixedLengthPoint(from, cursor, lengthM);
} else if (kind === "slope") {
@@ -309,7 +308,7 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
// ── 모드 ────────────────────────────────────────────────
/** 이번 긋기 마침 — 점 하나뿐인 선 버림 · 끝에서 그은 선은 뒤집고 머리 잇기 · 꼬리가 다른 선 머리면 잇기. */
function stop(): void {
if (span) showToast(span.to === null ? REJOIN_MISS : RESPAN_MISS, "info");
if (span) showToast(REJOIN_MISS, "info");
let network = params.network();
const line = active;
const entry = line === null ? null : network.lines[line];
@@ -337,6 +336,7 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
if (typeof joined !== "string" && joined !== turned) network = joined;
}
}
if (!featureOn(params.page, "branch")) network = joinTouching(network); // B05 = 늘 노선 하나(774-2)
if (network !== params.network()) params.commit(network);
}
active = null;
@@ -348,16 +348,15 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
span = null;
}
function respan(lineId: string, from: number, to: number): boolean {
function respan(lineId: string, from: number): boolean {
if (!editable) return false;
const index = params.network().lines.findIndex((line) => line.id === lineId);
const blocked = respanBlocked(params.network(), index, from, to);
if (blocked) {
showToast(blocked, "info");
const line = params.network().lines.find((entry) => entry.id === lineId);
if (!line || !isMiddleNode(line.vertices.length, from)) {
showToast("다시 긋기는 중간 노드를 골라 시작합니다.", "info");
return false;
}
setMode("draw");
span = { line: lineId, from, to, through: [], records: [] };
span = { line: lineId, from, through: [], records: [] };
restore = params.dropBase?.() ?? null;
panelState();
params.onMode();
@@ -413,12 +412,12 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
const network = params.network();
const target = aim(px, py, map);
const branches = featureOn(params.page, "branch");
// 한 선 페이지(B05) 중간 노드 = 그 노드에서 긋기 · 다른 노드에 닿으면 그 사이 갈음(771-2 · 닿기 전엔 꼴 그대로)
// 한 선 페이지(B05) 중간 노드 = 다시 긋기(771-2 · 774 · 닿기 전엔 꼴 그대로) · 선 위 = 아래 막힘 글
const vertex = target.kind === "line" ? target.hit.vertex : null;
if (!branches && target.kind === "line" && vertex !== null && vertex > 0) {
if (!branches && target.kind === "line" && vertex !== null) {
const line = network.lines[target.hit.line];
if (vertex < line.vertices.length - 1) {
span = { line: line.id, from: vertex, to: null, through: [], records: [] };
if (isMiddleNode(line.vertices.length, vertex)) {
span = { line: line.id, from: vertex, through: [], records: [] };
restore = params.dropBase?.() ?? null;
return true;
}
@@ -495,12 +494,8 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
return true;
}
const vertices = params.network().lines[spanLine()].vertices;
const end =
span.to ??
(samePoint(vertices[span.from], preview.point)
? span.from
: vertices.findIndex((v) => samePoint(v, preview!.point)));
const next = (span.to === null ? rejoinSpan : replaceSpan)(
const end = vertices.findIndex((v, i) => i !== span!.from && samePoint(v, preview!.point));
const next = rejoinSpan(
params.network(),
spanLine(),
span.from,
@@ -533,10 +528,6 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
let network = params.network();
let stroke = active;
const index = spanLine();
const dim =
span && span.to !== null && index >= 0
? { line: index, from: span.from, to: span.to }
: null;
if (span && index >= 0) {
// 다시 긋는 선 = 앞 노드 + 찍은 점(그리는 선 꼴)
const head = network.lines[index].vertices[span.from];
@@ -544,19 +535,24 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
network = { ...network, lines: [...network.lines, temp] };
stroke = network.lines.length - 1;
}
paintNetwork(
context,
map,
network,
stroke,
params.heightAt(),
() => {
const from = stroke === null ? null : network.lines[stroke]?.vertices.at(-1);
if (from && preview)
paintPreview(context, map, from, preview, fan ? { lengthM, fan } : null);
},
dim,
);
paintNetwork(context, map, network, stroke, params.heightAt(), () => {
const from = stroke === null ? null : network.lines[stroke]?.vertices.at(-1);
if (from && preview)
paintPreview(context, map, from, preview, fan ? { lengthM, fan } : null);
});
// 다시 긋기 고리(774) — 시작 · 이을 중간 노드 · 마우스 올린 중간 노드(쉴 때 · B05 긋기 첫 누름 전)
const plan = params.network();
const at =
hoverAt && editable && active === null ? lineAt(plan, hoverAt.px, hoverAt.py, map) : null;
const vertices = plan.lines[span ? index : (at?.line ?? -1)]?.vertices;
if (!vertices) return;
const v = at && (!span || at.line === index) ? at.vertex : null;
const open = span
? v !== null && !rejoinMiss(vertices.length, span.from, v)
: v !== null &&
isMiddleNode(vertices.length, v) &&
(mode === "idle" || !featureOn(params.page, "branch"));
paintRejoin(context, map, vertices, span?.from ?? null, open ? v : null);
},
press(px, py, map, button, shift) {
if (!editable) return false;
@@ -168,8 +168,7 @@ export function lineAt(
return best;
}
/** 그은 선 · 측점 · 구간 글 · 꺾임점 · 마크 · 연도 · (extra = 미리보기).
* `active` = 그리는 선(굵은 보라) · `dim` = 다시 긋는 구간(599-3 · 흐리게). */
/** 그은 선 · 측점 · 구간 글 · 꺾임점 · 마크 · 연도 · (extra = 미리보기) · `active` = 그리는 선(굵은 보라). */
export function paintNetwork(
context: CanvasRenderingContext2D,
map: ScreenMap,
@@ -177,7 +176,6 @@ export function paintNetwork(
active: number | null,
z: HeightAt,
extra: () => void,
dim: { line: number; from: number; to: number } | null = null,
): void {
const colors = planColors();
context.lineCap = "round";
@@ -186,22 +184,13 @@ export function paintNetwork(
if (line.vertices.length < 2) return;
const color = index === active ? colors.active : routeColor(index);
const width = 3;
const path = (vertices: ReadonlyArray<Vertex>): void => {
context.beginPath();
vertices.forEach((v, i) => {
const [sx, sy] = map.toScreen(v);
if (i) context.lineTo(sx, sy);
else context.moveTo(sx, sy);
});
strokeCased(context, color, width); // 밝은 바탕 대비 = 검은 테(600-1 · 463-24)
};
if (dim?.line === index) {
path(line.vertices.slice(0, dim.from + 1));
path(line.vertices.slice(dim.to));
context.globalAlpha = 0.3;
path(line.vertices.slice(dim.from, dim.to + 1));
context.globalAlpha = 1;
} else path(line.vertices);
context.beginPath();
line.vertices.forEach((v, i) => {
const [sx, sy] = map.toScreen(v);
if (i) context.lineTo(sx, sy);
else context.moveTo(sx, sy);
});
strokeCased(context, color, width); // 밝은 바탕 대비 = 검은 테(600-1 · 463-24)
if (map.stations)
drawStationTicks(
context,
@@ -245,6 +234,29 @@ export function paintNodes(
});
}
/** 다시 긋기 고리(774) — `start` 시작 노드 = 굵은 그리기 색 · 그 밖 중간 노드 = 이을 수 있음(얇은 재기 색) ·
* `hover` = 마우스 올린 중간 노드(그리기 색) · 끝점 = 고리 없음. */
export function paintRejoin(
context: CanvasRenderingContext2D,
map: Pick<ScreenMap, "toScreen">,
vertices: ReadonlyArray<Vertex>,
start: number | null,
hover: number | null,
): void {
const colors = planColors();
const ring = (i: number, pad: number, color: string, width: number): void => {
const [sx, sy] = map.toScreen(vertices[i]);
context.beginPath();
context.arc(sx, sy, NODE_DOT_PX + pad, 0, Math.PI * 2);
strokeCased(context, color, width);
};
if (start !== null)
for (let i = 1; i < vertices.length - 1; i += 1)
if (i !== start) ring(i, 4, colors.measure, 1.5);
if (start !== null) ring(start, 5, colors.active, 3);
if (hover !== null) ring(hover, 6, colors.active, 2.5);
}
/** 지점 핀 겹(585-1) — 꼭지 = 지점 · 둥근 머리 · 「지점 N」 · 화면 px 고정 크기 · 돌린 지도에서도 섬. */
export function pointLayer(points: () => Vertex[], names: () => string[] = () => []): PlanMapLayer {
return {
+1 -1
View File
@@ -227,7 +227,7 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
"Backspace = 마지막 점 빼기",
"Tab(긋는 중) = 자유지정 ↔ 길이·기울기 고정",
"Shift 누른 채(길이·기울기 고정) = 부채꼴 사이 기울기 자리 · 길이 그대로",
"[다시 긋기] = 앞 노드부터 긋고 뒤 노드에 붙으면 끝",
"[다시 긋기](중간 노드 고름) = 그 노드부터 긋고 다른 중간 노드에 이으면 끝",
"누름 = 노드 · 선 고름 · 다시 누름 = 선 → 구역 → 전체로 넓힘",
"Shift+누름 = 선 더함 · 뺌",
"빈 곳 오른쪽으로 끌기 = 창 안 선 · 왼쪽으로 = 걸친 선까지",
+8 -6
View File
@@ -2,8 +2,8 @@
* D03_PlanAnalysis_UI_Menu.ts
* D03 지도 우클릭 메뉴(PLAN 591-2 · 598-2 · 요구 463 16 · 22) — 부품 상자 `createMapContextMenu` 하나 · 누른 자리마다 항목.
* 긋는 중 = 긋기 끝 · 핀 = 그 지점에서 긋기 · 지우기 · 마커 = 마커 지움 ·
* 노드 = 노드 지움 · 지점으로 · 이어 긋기 / 갈래 시작 · 선 끝 = 잇기 · 여기에 잇기(638-3) ·
* 선 위 = 갈래 시작 · 여기서 구간 나눔 · 다시 긋기(599) ·
* 노드 = 노드 지움 · 지점으로 · 이어 긋기 / 갈래 시작 · 중간 노드 = 다시 긋기(774) · 선 끝 = 잇기 · 여기에 잇기(638-3) ·
* 선 위 = 갈래 시작 · 여기서 구간 나눔 ·
* 빈 땅 = 여기서 긋기 시작 · 핀 · 노드 밖 = 지점 놓기(노드 둘레면 그 노드에 붙음).
* 우클릭 즉시 지움 없음 — 지움은 메뉴 · 고르고 Delete. B05 우클릭(`bindNodeSplice`)과 코드 안 나눔.
* 겹 = 맨 끝(맨 위) · 오른쪽 누름만 받음.
@@ -11,6 +11,7 @@
import { createMapContextMenu, type MapContextMenuItem } from "@ui/ui_template_context_menu";
import { samePoint, type PlanNetwork, type Vertex } from "./logic/D03_PlanAnalysis_Plan";
import { isMiddleNode } from "./logic/D03_PlanAnalysis_Span";
import { lineLimitBlock, type PlanDraw } from "./D03_PlanAnalysis_UI_Draw";
import type { PlanEdit } from "./D03_PlanAnalysis_UI_Edit";
import { featureOn, type PlanPage } from "./D03_PlanAnalysis_UI_Features";
@@ -27,8 +28,8 @@ export interface PlanMenuParams {
edit: PlanEdit;
draw: PlanDraw;
editable: () => boolean;
/** [다시 긋기](599-3) — 구역 id · 앞 노드 · 뒤 노드. */
respan: (line: string, from: number, to: number) => boolean;
/** [다시 긋기](774) — 구역 id · 시작 중간 노드. */
respan: (line: string, vertex: number) => boolean;
}
export interface PlanMenu {
@@ -82,6 +83,9 @@ export function createPlanMenu(params: PlanMenuParams): PlanMenu {
const point = params.network().lines.find((l) => l.id === node.line)!.vertices[node.vertex];
const [sx, sy] = map.toScreen(point);
start(sx, sy);
const count = params.network().lines.find((l) => l.id === node.line)!.vertices.length;
if (isMiddleNode(count, node.vertex))
items.push(["다시 긋기", () => params.respan(node.line, node.vertex)]);
items.push("-", ["노드 지움", () => edit.dropNode(node)]);
if (featureOn(page, "join") && edit.isEnd(node))
items.push(
@@ -92,11 +96,9 @@ export function createPlanMenu(params: PlanMenuParams): PlanMenu {
if (marks && !marks.full() && !points().some((p) => samePoint(p, point)))
items.push(["지점으로", () => marks.place(sx, sy, map)]);
} else if (at?.segment) {
const seg = at.segment;
start(px, py);
if (featureOn(page, "years"))
items.push(["여기서 구간 나눔", () => edit.splitAt(px, py, map)]);
items.push(["다시 긋기", () => params.respan(seg.line, seg.index, seg.index + 1)]);
} else start(px, py);
if (marks && pin === null && !at?.node && !at?.mark && !marks.full())
items.push("-", ["지점 놓기", () => marks.place(px, py, map)]);
+9 -12
View File
@@ -3,9 +3,9 @@
* D03 고름 속성 판(PLAN 585-2 · 598 · 요구 463 13·16 · 11) — 오토캐드 특성 창 꼴 · 판 하나가 고름 꼴마다 몸을 바꿈.
*
* 판 = 지도 위 떠 있는 판(공용 `createFloatPanel` · 머리 끌어 옮김 · 오른쪽 위) · 고름 있을 때만 · ✕ = 풂.
* 노드 — 경위도 · 지반고 · 측점 · 앞 선 길이 · 기울기 · [노드 지움] [지점으로](그 자리에 지점 · 이미 있으면 숨김) ·
* [잇기](선 끝 · 638-3).
* 선(토막) — 측점 · 길이 · 지반 기울기 · 세트 · [다시 긋기](하나 · 599) [선 지움](가운데 = 구역 둘).
* 노드 — 경위도 · 지반고 · 측점 · 앞 선 길이 · 기울기 · [다시 긋기](중간 노드 · 774) [노드 지움]
* [지점으로](그 자리에 지점 · 이미 있으면 숨김) · [잇기](선 끝 · 638-3).
* 선(토막) — 측점 · 길이 · 지반 기울기 · 세트 · [선 지움](가운데 = 구역 둘).
* 구역 — 길이 · 노드 수 · 세트(구역 모든 구간) · 구간 줄 · [구역 지움].
* 전체 — 길이 · 구역 수 · 해마다 길이 · [전체 지움](되돌리기 됨).
* 세트 구간(왼쪽 해 · 세트 · 구간 줄) — 세트(모두 같으면 그 세트 · 다르면 「여러 세트」) · 구간 줄 · 마커 — 앞뒤 세트 · [마커 지움].
@@ -34,7 +34,7 @@ import {
segmentPieces,
segmentRange,
} from "./logic/D03_PlanAnalysis_Select";
import { nodeOff, nodeRecord } from "./logic/D03_PlanAnalysis_Span";
import { isMiddleNode, nodeOff, nodeRecord } from "./logic/D03_PlanAnalysis_Span";
import {
createDrawKindFields,
DRAW_DEFAULT_LENGTH_M,
@@ -71,8 +71,8 @@ export interface PlanPropsParams {
addPoint?: (point: Vertex) => void;
/** 노드 판 방식 · 값 바꿈 — 노드 · 그 자리 지점 한 걸음. */
commitPoints: (points: Vertex[], next: PlanNetwork) => void;
/** [다시 긋기](599-3) — 구역 id · 앞 노드 · 뒤 노드. */
respan: (line: string, from: number, to: number) => boolean;
/** [다시 긋기](774) — 구역 id · 시작 중간 노드. */
respan: (line: string, vertex: number) => boolean;
}
export interface PlanProps {
@@ -238,6 +238,9 @@ export function createPlanProps(params: PlanPropsParams): PlanProps {
title: `${nameOf(line)} · 노드 ${vertex + 1}`,
parts,
actions: [
...(isMiddleNode(vertices.length, vertex)
? [button("다시 긋기", "props-respan", () => params.respan(line, vertex))]
: []),
button("노드 지움", "props-drop-node", () => edit.dropNode(picked.node!)),
toPoint,
...(featureOn(page, "join") && edit.isEnd(picked.node!)
@@ -265,12 +268,6 @@ export function createPlanProps(params: PlanPropsParams): PlanProps {
const parts: HTMLElement[] = [createKv(rows)];
if (yearsShown(network)) parts.push(setField(inSegments, ranges));
const actions = [button("선 지움", "props-drop-segment", edit.dropSelected)];
if (single)
actions.unshift(
button("다시 긋기", "props-respan", () =>
params.respan(single.line, single.index, single.index + 1),
),
);
return {
title: single
? `${nameOf(single.line)} · 선 ${single.index + 1}`
@@ -0,0 +1,37 @@
/* =============================================================================
* D03_PlanAnalysis_OneLine.ts
* 한 선 페이지(B05 · 기능 표 「branch」 숨김) 예정노선 = 늘 노선 하나(PLAN 774-2 · 요구 618).
* `joinTouching` = 끝이 맞닿은 토막을 한 선으로 이음(꼬리 ↔ 머리 · 꼬리 ↔ 꼬리 · 머리 ↔ 머리 = 뒤집어 맞춤) ·
* 갈래 · 점 하나 선은 안 건드림 · 남는 id = 앞 토막(`attachHead` 규칙). 노선 여럿 = D 그룹(D03)만.
* DOM · fetch · sessionStorage 없음.
* ========================================================================== */
import { attachHead, reverseLine, samePoint, type PlanNetwork } from "./D03_PlanAnalysis_Plan";
/** 끝이 맞닿은 토막을 모두 이어 한 선으로 — 못 이을 것만 남김. */
export function joinTouching(network: PlanNetwork): PlanNetwork {
let next = network;
for (let again = true; again;) {
again = false;
const ends = next.lines.map((line) =>
line.vertices.length > 1 && !line.branch ? [line.vertices[0], line.vertices.at(-1)!] : null,
);
for (let i = 0; i < ends.length && !again; i += 1)
for (let j = 0; j < ends.length && !again; j += 1) {
const [a, b] = [ends[i], ends[j]];
if (i === j || !a || !b) continue;
// j 머리가 i 꼬리에 오게 맞춤
const turned = samePoint(a[1], b[0])
? next
: samePoint(a[1], b[1])
? reverseLine(next, j)
: samePoint(a[0], b[0])
? reverseLine(next, i)
: null;
if (!turned || typeof turned === "string") continue;
const joined = attachHead(turned, j);
if (joined !== turned) [next, again] = [joined, true];
}
}
return next;
}
@@ -2,6 +2,7 @@
* D03_PlanAnalysis_Span.ts
* D 예정노선 다시 긋기(PLAN 599-3 · 요구 463-12) — 선 하나의 두 꺾임점 사이를 새로 그은 점들로 바꿈.
* 중간 노드에서 긋기(771-2 · 요구 613) = `rejoinSpan` — 닿은 노드가 앞이면 거꾸로 · 같은 노드 = 까닭 글.
* 끝 판정(774 · 요구 616) = `rejoinMiss` — 다른 중간 노드만 끝 · 시작 노드 · 끝점 = 계속 긋기.
* 끝 두 점(앞 노드 · 뒤 노드) 그대로 · 속 꺾임점에 갈래가 붙었으면 막음(까닭 글) · 뒤 갈래 차례 밈 ·
* 마크(조각 경계) = 바뀐 구간 안은 길이 비율 · 뒤는 길이 차만큼 · 조각 id 그대로(업체 열쇠).
* 노드 기록(599-2) — 그리기 칸 값 → 기록 · 옮긴 노드 어긋남(⚠) · 다시 그은 속 노드 = 그때 기록 · 뒤 노드 = 자유.
@@ -127,3 +128,16 @@ export function rejoinSpan(
...back.slice(0, -1),
]);
}
/** 다시 긋기 시작 · 끝 자리 = 중간 노드(774 · 요구 616) — 꼭짓점 `count` 개 선의 `vertex` 가 끝점 아님. */
export const isMiddleNode = (count: number, vertex: number): boolean =>
vertex > 0 && vertex < count - 1;
export const REJOIN_TIP = "끝점은 끝이 아닙니다 — 다른 중간 노드에 이어야 끝납니다.";
export const REJOIN_HOLD = "시작 노드는 끝이 아닙니다 — 다른 중간 노드에 이어야 끝납니다.";
/** 다시 긋기 끝 판정(774) — 닿은 `vertex` 가 다른 중간 노드 = null(끝) · 시작 노드 · 끝점 = 계속 긋기 까닭 글. */
export function rejoinMiss(count: number, start: number, vertex: number): string | null {
if (vertex === start) return REJOIN_HOLD;
return isMiddleNode(count, vertex) ? null : REJOIN_TIP;
}
@@ -3,6 +3,8 @@
중간 노드에서 그은 점이 같은 선 다른 노드에 닿음 = 두 노드 사이 옛 구간을 새 점으로 갈음 · 한 줄 그대로 ·
거꾸로(앞 노드에 닿음) = 점 차례 뒤집어 넣음 · 같은 노드 = 까닭 글(꼴 그대로) · 끝점에 닿음 = 끝까지 갈음 ·
노드 기록 = 꼭짓점과 같은 길이.
774-2(요구 618) B05 = 늘 노선 하나 `logic/D03_PlanAnalysis_OneLine.ts` `joinTouching` = 맞닿은 토막 이음.
774-1(요구 616) 끝 판정 `rejoinMiss` = 다른 중간 노드만 끝 · 시작 노드 · 끝점 = 계속 긋기 까닭 글 · 시작 = 중간 노드만.
./venv/Scripts/python.exe -m pytest resources/tester/test_771_2_rejoin_span.py -q
"""
@@ -16,12 +18,22 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
TS_FILE = ROOT / "D03_PlanAnalysis" / "logic" / "D03_PlanAnalysis_Span.ts"
ONE_FILE = ROOT / "D03_PlanAnalysis" / "logic" / "D03_PlanAnalysis_OneLine.ts"
TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
_RUNNER = """
import { writeFileSync } from "node:fs";
import * as plan from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.js";
import * as span from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Span.js";
import * as one from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_OneLine.js";
const two = (a, b) => {
let m = plan.newLine(plan.emptyNetwork(), a[0]);
for (const p of a.slice(1)) m = plan.appendVertex(m, 0, p);
m = plan.newLine(m, b[0]);
for (const p of b.slice(1)) m = plan.appendVertex(m, 1, p);
return m;
};
const flat = (m) => m.lines.map((l) => l.vertices);
let n = plan.newLine(plan.emptyNetwork(), [0, 0]);
for (const p of [[100, 0], [200, 0], [300, 0], [400, 0]]) n = plan.appendVertex(n, 0, p);
const L = span.nodeRecord("length", 20, 9);
@@ -36,6 +48,15 @@ writeFileSync(
tail: span.rejoinSpan(n, 0, 2, 4, [[300, 50]]),
recorded: span.rejoinSpan(n, 0, 3, 1, [[250, 50], [150, 50]], [L, S]),
same_text: span.REJOIN_SAME,
miss: [[2, 1], [2, 3], [2, 2], [2, 0], [2, 4], [1, 3]].map(([s, v]) => span.rejoinMiss(5, s, v)),
middle: [0, 1, 2, 3, 4].map((v) => span.isMiddleNode(5, v)),
tip_text: span.REJOIN_TIP,
hold_text: span.REJOIN_HOLD,
join_tail_head: flat(one.joinTouching(two([[0, 0], [1, 0]], [[1, 0], [2, 0]]))),
join_tail_tail: flat(one.joinTouching(two([[0, 0], [1, 0]], [[2, 0], [1, 0]]))),
join_head_head: flat(one.joinTouching(two([[1, 0], [0, 0]], [[1, 0], [2, 0]]))),
join_apart: flat(one.joinTouching(two([[0, 0], [1, 0]], [[5, 0], [6, 0]]))),
join_branch: flat(one.joinTouching(plan.appendVertex(plan.addBranch(n, 0, 2), 1, [200, 50]))),
}),
);
"""
@@ -48,6 +69,7 @@ def _run_ts(tmp_path: Path) -> dict:
"node",
str(TSC),
str(TS_FILE),
str(ONE_FILE),
"--outDir",
str(out),
"--rootDir",
@@ -108,3 +130,22 @@ def test_backward_records(tmp_path: Path) -> None:
assert nodes[2] is None
assert nodes[3]["kind"] == "slope"
assert nodes[4]["kind"] == "free"
def test_end_only_other_middle_node(tmp_path: Path) -> None:
"""774-1 — 다른 중간 노드 = 끝(null) · 시작 노드 · 머리 · 꼬리 끝점 = 계속 긋기 글 · 시작 = 중간 노드만."""
r = _run_ts(tmp_path)
tip, hold = r["tip_text"], r["hold_text"]
assert r["miss"] == [None, None, hold, tip, tip, None]
assert "중간 노드" in tip and "중간 노드" in hold
assert r["middle"] == [False, True, True, True, False]
def test_one_line_join_touching(tmp_path: Path) -> None:
"""774-2 — 맞닿은 두 토막 = 한 선(어느 끝이든 · 차례 이어짐) · 떨어진 토막 · 갈래 = 그대로."""
r = _run_ts(tmp_path)
assert r["join_tail_head"] == [[[0, 0], [1, 0], [2, 0]]]
assert r["join_tail_tail"] == [[[0, 0], [1, 0], [2, 0]]]
assert r["join_head_head"] in ([[[0, 0], [1, 0], [2, 0]]], [[[2, 0], [1, 0], [0, 0]]])
assert len(r["join_apart"]) == 2
assert len(r["join_branch"]) == 2 # 갈래(D03 꼴)는 안 이음