Merge remote-tracking branch 'origin/sub_laptop_6' into sub_laptop_3

This commit is contained in:
2026-10-30 14:45:00 +09:00
4 changed files with 292 additions and 29 deletions
+100 -21
View File
@@ -3,12 +3,15 @@
* D03 손 긋기 겹 · 그리기 칸(PLAN 562-3 · 580 · 요구서 389 3장 8~10 · 440 3~7) — 점을 찍어 직선으로 이음.
*
* 우클릭 메뉴(591-2 · `D03_PlanAnalysis_UI_Points`) = 이 자리에서 시작 — 빈 땅도 새 본선(지점 없이 긋기).
* [긋기] 하나 — 첫 누름 자리로 시작: 지점 = 새 본선(측점 No.0 = 긋기 시작한 지점 · 반대는 [방향 바꾸기]) ·
* [긋기] 하나 — 첫 누름 자리로 시작: 지점 = 새 본선(측점 No.0 = 긋기 시작한 지점) ·
* 선이 이미 놓인 지점 = 그 점의 갈래 · 선 꼬리 = 이어 긋기 · 갈래 없는 선 머리 = 뒤집어 이어 긋고 끝낼 때
* 되돌림 · 선 위 · 꺾임점 = 갈래. 다른 지점 · 다른 선 둘레(SNAP_PX)에 오면 거기 붙고 그 점에서 끝 —
* 머리가 다른 선 꼬리에 닿으면 한 선으로 잇고 · 꺾임점에 닿으면 그 점의 갈래.
* 머리가 다른 선 꼬리에 닿으면 한 선으로 잇고 · 꺾임점에 닿으면 그 점의 갈래 · 본선 꼬리가 다른 구역에
* 닿아 끝나면 저절로 그 점의 갈래(No.0 = 닿은 점 · 599 · 요구서 463 3장 10).
* 다시 긋기(599-3 · `respan`) = 고른 구간 앞 노드부터 긋고 원래 뒤 노드에 붙으면 끝(속 구간 흐리게 ·
* Enter 로 끝내면 취소) — 끝나면 `replaceSpan` 한 번 = 되돌리기 한 걸음.
* 미리보기 = 마지막 점 → 마우스 길이(m) · 가상 종단 기울기(원지반 % · ° · + 오르막) — 제약 칸 넷:
* 자유 · 길이 고정 · 기울기 고정(기울기 고정 · 길이 자유 · 마우스 방향 첫 자리) · 둘 다(양각기 원 위 후보 중
* 자유 · 길이 고정 · 기울기 고정(기울기 고정 · 길이 자유 · 마우스 방향 첫 자리) · 길이·기울기 고정(양각기 원 위 후보 중
* 마우스에 가까운 것) · 제약 기울기 = 오르내림 같은 크기(562-1 짝이 |기울기|로 셈).
* 긋기는 해를 안 물음(585-3) — 새 선 = 사업연도 시작 해 · 갈래 = 붙은 자리 구간 해 · 이어 긋기 = 끝 구간이 늘어남 ·
* 여러 해 = 다 그은 뒤 그리기 칸 「연도 구역 마커」를 선 위로 끌어 구역을 나누고 속성 판에서 해를 고름.
@@ -41,6 +44,7 @@ import {
type PlanNetwork,
type Vertex,
} from "./logic/D03_PlanAnalysis_Plan";
import { replaceSpan, respanBlocked } from "./logic/D03_PlanAnalysis_Span";
import {
fmt,
lineAt,
@@ -103,6 +107,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;
}
export function createPlanDraw(params: PlanDrawParams): PlanDraw {
@@ -116,6 +122,10 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
let kind: DrawConstraint = "free";
let lengthM = DRAW_DEFAULT_LENGTH_M;
let slopePct = DRAW_DEFAULT_SLOPE_PCT;
// 다시 긋기 — 선 id · 앞 · 뒤 꺾임점 · 새로 찍은 속 점
let span: { line: string; from: number; to: number; through: Vertex[] } | null = null;
const spanLine = (): number =>
span ? params.network().lines.findIndex((line) => line.id === span!.line) : -1;
// ── 좌측 칸 ─────────────────────────────────────────────
const kindFields = createDrawKindFields({
@@ -171,13 +181,22 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
/** 마우스 자리 → 찍을 점 · 값 — 붙기가 제약보다 먼저. */
function previewAt(px: number, py: number, map: ScreenMap): DrawPreview | null {
if (active === null) return null;
const from = lastOf(active);
const spanVertices = span ? params.network().lines[spanLine()]?.vertices : undefined;
if (active === null && !spanVertices) return null;
const from = spanVertices
? (span!.through[span!.through.length - 1] ?? spanVertices[span!.from])
: lastOf(active!);
if (!from) return null;
const z = params.heightAt();
const cursor = map.toMetric(px, py);
candidates = kind === "both" ? compassCandidates(from, lengthM, slopePct, z) : [];
const snap = snapAt(px, py, map);
// 다시 긋기 = 원래 뒤 노드에만 붙음
const end = spanVertices?.[span!.to];
const snap = end
? screenGap(map, end, px, py) <= SNAP_PX
? { point: end, note: "뒤 노드에 붙음", onLine: null }
: null
: snapAt(px, py, map);
let point: Vertex | null = cursor;
let note: string | null = null;
if (snap) {
@@ -227,6 +246,12 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
samePoint(other.vertices[0], tail),
);
if (next >= 0) network = attachHead(network, next);
else if (!entry.branch) {
// 꼬리가 다른 구역 꺾임점에 닿음 = 그 점의 갈래(No.0 = 닿은 점)
const turned = reverseLine(network, line);
const joined = typeof turned === "string" ? turned : attachHead(turned, line);
if (typeof joined !== "string" && joined !== turned) network = joined;
}
}
if (network !== params.network()) params.commit(network);
}
@@ -235,6 +260,22 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
strokeStart = null;
preview = null;
candidates = [];
span = null;
}
function respan(lineId: string, from: number, to: 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");
return false;
}
setMode("draw");
span = { line: lineId, from, to, through: [] };
panelState();
params.onMode();
return true;
}
function setMode(next: DrawMode): void {
@@ -335,12 +376,22 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
}
function addPoint(): boolean {
if (active === null || !preview) return false;
if ((active === null && !span) || !preview) return false;
if (preview.blocked) {
showToast(preview.note ?? "여기에는 점을 찍을 수 없습니다.", "info");
return false;
}
if (span) {
if (!preview.snapped) return (span.through.push(preview.point), true);
const next = replaceSpan(params.network(), spanLine(), span.from, span.to, span.through);
if (typeof next === "string") showToast(next, "info");
else params.commit(next);
span = null;
setMode("idle");
return true;
}
const line = active;
if (line === null) return false;
const ended = preview.snapped;
let next = params.network();
if (preview.onLine)
@@ -355,23 +406,41 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
const layer: PlanMapLayer = {
draw(context, map) {
const network = params.network();
paintNetwork(context, map, network, active, params.heightAt(), () => {
const from = active === null ? null : lastOf(active);
if (from && preview)
paintPreview(
context,
map,
from,
preview,
kind === "both" ? { lengthM, candidates } : null,
);
});
let network = params.network();
let stroke = active;
const index = spanLine();
const dim = span && index >= 0 ? { line: index, from: span.from, to: span.to } : null;
if (span && index >= 0) {
// 다시 긋는 선 = 앞 노드 + 찍은 점(그리는 선 꼴)
const head = network.lines[index].vertices[span.from];
const temp = { id: "", vertices: [head, ...span.through], branch: null };
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,
kind === "both" ? { lengthM, candidates } : null,
);
},
dim,
);
},
press(px, py, map, button) {
if (!editable) return false;
if (mode === "draw" && button === 0) {
if (active === null) {
if (active === null && !span) {
const started = begin(px, py, map);
panelState();
params.onMode();
@@ -413,6 +482,7 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
},
key(event) {
if (mode === "draw" && keyIs(event, "plan.drawFinish")) {
if (span) showToast("다시 긋기는 뒤 노드에 붙어야 끝 — 취소했습니다.", "info");
setMode("idle");
return true;
}
@@ -423,6 +493,10 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
setMode("idle");
return true;
}
if (mode === "draw" && span && keyIs(event, "plan.drawBack")) {
if (!span.through.pop()) setMode("idle");
return true;
}
if (mode === "draw" && active !== null && keyIs(event, "plan.drawBack")) {
const network = params.network();
const step =
@@ -440,9 +514,13 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
return false;
},
sync() {
if (active !== null && active >= params.network().lines.length) {
if (
(active !== null && active >= params.network().lines.length) ||
(span && spanLine() < 0)
) {
active = null;
flip = false;
span = null;
mode = "idle";
}
panelState();
@@ -456,5 +534,6 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
if (slope !== undefined) slopePct = slope;
kindFields.set({ kind, lengthM, slopePct });
},
respan,
};
}
@@ -112,7 +112,7 @@ export function lineAt(
}
/** 그은 선 · 측점 · 구간 글 · 꺾임점 · 마크 · 연도 · (extra = 미리보기).
* `active` = 그리는 선(굵은 보라). */
* `active` = 그리는 선(굵은 보라) · `dim` = 다시 긋는 구간(599-3 · 흐리게). */
export function paintNetwork(
context: CanvasRenderingContext2D,
map: ScreenMap,
@@ -120,6 +120,7 @@ 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";
@@ -128,13 +129,22 @@ export function paintNetwork(
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();
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);
});
context.stroke();
};
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);
if (map.stations)
drawStationTicks(
context,
@@ -0,0 +1,73 @@
/* =============================================================================
* D03_PlanAnalysis_Span.ts
* D 예정노선 다시 긋기(PLAN 599-3 · 요구 463-12) — 선 하나의 두 꺾임점 사이를 새로 그은 점들로 바꿈.
* 끝 두 점(앞 노드 · 뒤 노드) 그대로 · 속 꺾임점에 갈래가 붙었으면 막음(까닭 글) · 뒤 갈래 차례 밈 ·
* 마크(조각 경계) = 바뀐 구간 안은 길이 비율 · 뒤는 길이 차만큼 · 조각 id 그대로(업체 열쇠).
* DOM · fetch · sessionStorage 없음 · 길이 셈 = 562-1 짝(`D03_PlanAnalysis_Draw`).
* ========================================================================== */
import { lineLength } from "./D03_PlanAnalysis_Draw";
import type { PlanNetwork, Vertex } from "./D03_PlanAnalysis_Plan";
const round3 = (value: number): number => Math.round(value * 1000) / 1000;
/** `line` 째 선 `from`~`to` 꺾임점 사이를 다시 그을 수 있나 — 못 하면 까닭 글. */
export function respanBlocked(
network: PlanNetwork,
line: number,
from: number,
to: number,
): string | null {
const entry = network.lines[line];
if (!entry || !(from >= 0 && to > from && to < entry.vertices.length))
return "다시 그을 구간을 고르세요.";
if (
network.lines.some(
(o) => o.branch?.line === entry.id && o.branch.vertex > from && o.branch.vertex < to,
)
)
return "구간 속 점에 갈래가 붙어 있습니다 — 갈래를 먼저 지우세요.";
return null;
}
/** `line` 째 선 `from`~`to` 꺾임점 사이 속 점을 `through` 로 바꿈(끝 두 점 그대로) — 막히면 까닭 글. */
export function replaceSpan(
network: PlanNetwork,
line: number,
from: number,
to: number,
through: ReadonlyArray<Vertex>,
): PlanNetwork | string {
const blocked = respanBlocked(network, line, from, to);
if (blocked) return blocked;
const entry = network.lines[line];
const old = entry.vertices;
const vertices: Vertex[] = [
...old.slice(0, from + 1),
...through.map(([x, y]): Vertex => [x, y]),
...old.slice(to),
];
const shift = through.length - (to - from - 1);
const at = (list: ReadonlyArray<Vertex>, i: number): number => lineLength(list.slice(0, i + 1));
const [startM, oldEndM, newEndM] = [at(old, from), at(old, to), at(vertices, to + shift)];
const carry = (m: number): number =>
m <= startM
? m
: m >= oldEndM
? m - oldEndM + newEndM
: startM + ((m - startM) / (oldEndM - startM)) * (newEndM - startM);
return {
lines: network.lines.map((other, i) => {
if (i === line) return { ...other, vertices };
const branch = other.branch;
return branch?.line === entry.id && branch.vertex >= to
? { ...other, branch: { ...branch, vertex: branch.vertex + shift } }
: other;
}),
pieces: network.pieces.map((piece) =>
piece.line === entry.id
? { ...piece, from_m: round3(carry(piece.from_m)), to_m: round3(carry(piece.to_m)) }
: piece,
),
};
}
+101
View File
@@ -0,0 +1,101 @@
"""D03 다시 긋기(PLAN 599-3 · 요구 463-12) — TS `logic/D03_PlanAnalysis_Span.ts`.
두 꺾임점 사이를 새 점으로 바꿈 · 끝 두 점 그대로 · 뒤 갈래 차례 밈 · 속 점 갈래 = 막음 ·
마크 = 바뀐 구간 안 길이 비율 · 뒤는 길이 차 · 조각 id 그대로.
./venv/Scripts/python.exe -m pytest resources/tester/test_599_3_plan_span.py -q
"""
from __future__ import annotations
import json
import math
import re
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
TS_FILE = ROOT / "D03_PlanAnalysis" / "logic" / "D03_PlanAnalysis_Span.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";
const Y = 2026;
let n = plan.newLine(plan.emptyNetwork(), [0, 0]);
for (const p of [[100, 0], [200, 0], [300, 0]]) n = plan.appendVertex(n, 0, p);
n = plan.normalizePieces(n, Y);
n = plan.normalizePieces(plan.splitPiece(n, "main", 150), Y);
n = plan.normalizePieces(plan.splitPiece(n, "main", 250), Y);
n = plan.addBranch(n, 0, 2);
n = plan.normalizePieces(plan.appendVertex(n, 1, [200, 80]), Y);
const done = span.replaceSpan(n, 0, 1, 2, [[150, 50]]);
const wide = span.replaceSpan(n, 0, 0, 3, [[150, 50]]);
const bad = span.replaceSpan(n, 0, 2, 2, []);
const straight = span.replaceSpan(n, 0, 0, 2, []);
writeFileSync(process.argv[2], JSON.stringify({ before: n, done, wide, bad, straight }));
"""
def _run_ts(tmp_path: Path) -> dict:
out = tmp_path / "js"
subprocess.run( # noqa: S603 — 고정 실행 파일
[
"node",
str(TSC),
str(TS_FILE),
"--outDir",
str(out),
"--rootDir",
str(ROOT),
"--module",
"esnext",
"--target",
"es2022",
"--moduleResolution",
"bundler",
"--ignoreConfig",
],
cwd=str(ROOT),
check=True,
capture_output=True,
)
for emitted in out.rglob("*.js"):
emitted.write_text(
re.sub(r'(from\s+"\.\.?/[^"]+)"', r'\1.js"', emitted.read_text(encoding="utf-8")),
encoding="utf-8",
)
(out / "runner.mjs").write_text(_RUNNER, encoding="utf-8")
result = tmp_path / "out.json"
subprocess.run( # noqa: S603
["node", str(out / "runner.mjs"), str(result)],
cwd=str(ROOT),
check=True,
capture_output=True,
)
return json.loads(result.read_text(encoding="utf-8"))
def test_replace_span(tmp_path: Path) -> None:
r = _run_ts(tmp_path)
done = r["done"]
main = done["lines"][0]
assert main["vertices"] == [[0, 0], [100, 0], [150, 50], [200, 0], [300, 0]]
assert done["lines"][1]["branch"] == {"line": "main", "vertex": 3} # 뒤 갈래 한 칸 밈
new_span = 2 * math.hypot(50, 50)
pieces = [(p["id"], p["from_m"], p["to_m"]) for p in done["pieces"] if p["line"] == "main"]
before = [p["id"] for p in r["before"]["pieces"] if p["line"] == "main"]
assert [p[0] for p in pieces] == before # 조각 id 그대로
assert pieces[1][1] == round(100 + new_span / 2, 3) # 구간 속 마크 = 길이 비율
assert pieces[2][1] == round(250 - 200 + 100 + new_span, 3) # 뒤 마크 = 길이 차
assert pieces[2][2] == round(300 - 200 + 100 + new_span, 3)
def test_blocked_and_straight(tmp_path: Path) -> None:
r = _run_ts(tmp_path)
assert "갈래" in r["wide"] # 속 점(2)에 갈래
assert "고르세요" in r["bad"]
straight = r["straight"]["lines"][0]["vertices"]
assert straight == [[0, 0], [200, 0], [300, 0]] # 속 점 없이 곧게
assert r["straight"]["lines"][1]["branch"]["vertex"] == 1