feat(예정노선): 지점 칸 [추가] · 이름 · 경위도 손 고침 · 칸 설명 글 걷음 · 선마다 차례 색 (PLAN 597-2)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-30 14:46:22 +09:00
co-authored by Claude Sonnet 5.5
parent 69b3aa852f
commit 0bf234e2b5
5 changed files with 363 additions and 24 deletions
@@ -6,7 +6,7 @@
* 지점 핀(585-1) = 따로 겹(`pointLayer` · 겹 목록 맨 끝 = 늘 맨 위) · 색 = 목록 줄과 같은 `pointColor(i)` · 끌기 · 메뉴 = `D03_PlanAnalysis_UI_Points`.
* ========================================================================== */
import { pointColor } from "@ui/ui_template_palette";
import { pointColor, routeColor } from "@ui/ui_template_palette";
import { drawStationTicks } from "../B04_PreProcess/B04_PreProcess_UI_MapOverlays";
import {
distanceAlong,
@@ -18,6 +18,7 @@ import {
type HeightAt,
} from "./logic/D03_PlanAnalysis_Draw";
import type { PlanNetwork, Vertex } from "./logic/D03_PlanAnalysis_Plan";
import { pointLabel } from "./logic/D03_PlanAnalysis_PointEdit";
import {
planColors,
PLAN_STATION_INTERVAL_M,
@@ -126,7 +127,7 @@ export function paintNetwork(
context.lineJoin = "round";
network.lines.forEach((line, index) => {
if (line.vertices.length < 2) return;
context.strokeStyle = index === active ? colors.active : colors.line;
context.strokeStyle = index === active ? colors.active : routeColor(index);
context.lineWidth = line.branch ? 2.5 : 3;
context.beginPath();
line.vertices.forEach((v, i) => {
@@ -163,7 +164,7 @@ export function paintNetwork(
}
/** 지점 핀 겹(585-1) — 꼭지 = 지점 · 둥근 머리 · 「지점 N」 · 화면 px 고정 크기 · 돌린 지도에서도 섬. */
export function pointLayer(points: () => Vertex[]): PlanMapLayer {
export function pointLayer(points: () => Vertex[], names: () => string[] = () => []): PlanMapLayer {
return {
draw(context, map) {
points().forEach((point, i) => {
@@ -188,7 +189,7 @@ export function pointLayer(points: () => Vertex[]): PlanMapLayer {
context.fillStyle = "#ffffff";
context.fill();
});
planLabel(context, map, sx, sy - 38, `지점 ${i + 1}`, "bold 12px");
planLabel(context, map, sx, sy - 38, pointLabel(names(), i), "bold 12px");
});
},
};
+153 -20
View File
@@ -1,7 +1,8 @@
/* =============================================================================
* D03_PlanAnalysis_UI_Points.ts
* D03 지점(PLAN 591-2 · 요구 452 · 453) — 옛 기준 지정(D02) 지점 마킹을 노선분석으로.
* 지점 = 긋기에 붙는 점 · 목록 하나(줄 왼 띠 = 지점 색 `pointColor` · 「지점 N」 · 🗑) · 핀 = 늘 맨 위(`pointLayer`).
* 지점 = 긋기에 붙는 점 · 목록 하나(줄 왼 띠 = 지점 색 `pointColor` · 이름 · 🗑) · 핀 = 늘 맨 위(`pointLayer`).
* [추가] = 지도 보기 가운데에 바로 놓음(597-2) · 줄 누름 = 그 줄만 펼침(이름 · 경도 · 위도 — 칸 떠남 · Enter 가 한 걸음).
* 핀 끌기 = 지점 + 그 자리 노선 노드 같이(`moveNodesAt`) · 놓을 때 한 걸음(되돌리기 · 캐시 = 페이지).
* 지도 우클릭 = 메뉴(부품 상자 `createMapContextMenu`) — 핀: 여기서 긋기 · 지움 / 그 밖: 긋기 시작(빈 땅 = 새 본선 ·
* 선 위 = 갈래 · 선 끝 = 이어 긋기) · 지점 놓기(꺾임점 둘레면 그 점에) · 긋는 중 = 긋기 끝.
@@ -10,18 +11,27 @@
* ========================================================================== */
import { createIconButton } from "@ui/ui_template_button";
import { createButton } from "@ui/ui_template_elements";
import { createInputField } from "@ui/ui_template_input";
import { createMapContextMenu, type MapContextMenuItem } from "@ui/ui_template_context_menu";
import { createListRow } from "@ui/ui_template_list_row";
import { pointColor } from "@ui/ui_template_palette";
import { createText } from "@ui/ui_template_text";
import { moveNodesAt, type PlanNetwork, type Vertex } from "./logic/D03_PlanAnalysis_Plan";
import {
addPoint,
MAX_POINTS,
movePointTo,
parseLonLat,
pointLabel,
pointNameError,
renamePoint,
type PointsState,
} from "./logic/D03_PlanAnalysis_PointEdit";
import { tmParams, tmToLonLat } from "./logic/D03_PlanAnalysis_Tm";
import type { PlanDraw } from "./D03_PlanAnalysis_UI_Draw";
import { pointLayer, screenGap, SNAP_PX } from "./D03_PlanAnalysis_UI_DrawPaint";
import type { PlanMapLayer, ScreenMap } from "./D03_PlanAnalysis_UI_Map";
/** 지점 상한 — 서버 `MAX_POINTS` 와 같음. */
const MAX_POINTS = 40;
/** 핀 머리 = 꼭지 위 px(`pointLayer` 그림과 같음). */
const PIN_HEAD_PX = 18;
@@ -56,10 +66,22 @@ export interface PlanPoints {
export function createPlanPoints(params: PlanPointsParams): PlanPoints {
const list = document.createElement("div");
list.className = "d03-plan__points";
const hint = createText("", "muted");
hint.classList.add("d03-plan__hint");
const addButton = createButton({
label: "추가",
iconStart: "plus",
variant: "ghost",
size: 24,
onClick: addAtCenter,
});
addButton.dataset.role = "points-add";
const head = document.createElement("div");
head.className = "d03-plan__points-head";
head.append(addButton);
const panel = document.createElement("div");
panel.append(list, hint);
panel.append(head, list);
let openIndex: number | null = null; // 펼친 줄(하나만)
let syncEditor: (() => void) | null = null;
let shown = ""; // 지금 그린 틀 열쇠 — 같으면 값만 갈아 끼움(칸 초점 지키기)
const menu = createMapContextMenu("d03-plan");
let menuAt: [number, number] = [0, 0];
let drag: {
@@ -69,7 +91,14 @@ export function createPlanPoints(params: PlanPointsParams): PlanPoints {
last: [Vertex[], PlanNetwork] | null;
} | null = null;
const name = (i: number): string => `지점 ${i + 1}`;
const name = (i: number): string => pointLabel(params.names(), i);
const state = (): PointsState => ({
points: params.points(),
names: params.names(),
network: params.network(),
});
const commitState = (next: PointsState): void =>
params.commit(next.points, next.names, next.network);
/** 핀 집기 — 꼭지 · 머리(보이는 화면 위쪽) 둘레 · 뒤 지점(위에 그려짐) 먼저. */
function pinAt(px: number, py: number, map: ScreenMap): number | null {
@@ -88,6 +117,7 @@ export function createPlanPoints(params: PlanPointsParams): PlanPoints {
}
function remove(i: number): void {
openIndex = null;
params.commit(
params.points().filter((_, k) => k !== i),
params.names().filter((_, k) => k !== i),
@@ -134,7 +164,7 @@ export function createPlanPoints(params: PlanPointsParams): PlanPoints {
return true;
}
const pins = pointLayer(params.points);
const pins = pointLayer(params.points, params.names);
pins.press = (px, py, map, button) => (button === 2 ? openMenu(px, py, map) : false);
const input: PlanMapLayer = {
@@ -163,12 +193,101 @@ export function createPlanPoints(params: PlanPointsParams): PlanPoints {
},
};
/** [추가] — 지도 보기 가운데에 바로 놓고 그 줄을 펼침. */
function addAtCenter(): void {
const at = params.center();
if (!at || !params.editable() || params.points().length >= MAX_POINTS) return;
openIndex = params.points().length;
commitState(addPoint(state(), at));
}
/** 펼친 줄 — 이름 · 경도 · 위도 칸(Enter · 칸 떠남 = 한 걸음 · 잘못 쓴 값은 까닭만 보이고 안 받음). */
function editorOf(i: number): { root: HTMLElement; sync: () => void } {
const tm = tmParams(params.epsg());
const editable = params.editable();
const field = (label: string, role: string, placeholder?: string) => {
const f = createInputField({ label, size: 24, placeholder });
f.input.dataset.role = role;
f.input.disabled = !editable || (role !== "point-name" && !tm);
return f;
};
const nameField = field("이름", "point-name", `지점 ${i + 1}`);
const lonField = field("경도", "point-lon");
const latField = field("위도", "point-lat");
const lonLatNow = (): [number, number] | null => {
const p = params.points()[i];
return tm && p ? tmToLonLat(tm, p[0], p[1]) : null;
};
const sync = (): void => {
const set = (f: ReturnType<typeof field>, text: string): void => {
if (document.activeElement !== f.input) f.input.value = text;
};
set(nameField, params.names()[i] ?? "");
const ll = lonLatNow();
set(lonField, ll ? ll[0].toFixed(6) : "");
set(latField, ll ? ll[1].toFixed(6) : "");
};
nameField.input.addEventListener("input", () =>
nameField.setError(pointNameError(nameField.input.value.trim()) ?? undefined),
);
nameField.input.addEventListener("change", () => {
const text = nameField.input.value.trim();
const error = pointNameError(text);
nameField.setError(error ?? undefined);
if (!error && text !== (params.names()[i] ?? "")) commitState(renamePoint(state(), i, text));
});
const moved = (): void => {
const parsed = parseLonLat(lonField.input.value, latField.input.value);
const bad = typeof parsed === "string";
lonField.setError(bad && parsed.startsWith("경도") ? parsed : undefined);
latField.setError(bad && parsed.startsWith("위도") ? parsed : undefined);
if (bad || !tm) return;
const now = lonLatNow();
if (
now &&
now[0].toFixed(6) === parsed[0].toFixed(6) &&
now[1].toFixed(6) === parsed[1].toFixed(6)
)
return;
commitState(movePointTo(state(), i, tm, parsed[0], parsed[1]));
};
lonField.input.addEventListener("change", moved);
latField.input.addEventListener("change", moved);
const root = document.createElement("div");
root.className = "d03-plan__point-edit";
root.append(nameField.root, lonField.root, latField.root);
sync();
return { root, sync };
}
function render(): void {
const tm = tmParams(params.epsg());
const editable = params.editable();
const points = params.points();
if (openIndex !== null && openIndex >= points.length) openIndex = null;
addButton.hidden = !editable;
addButton.disabled = points.length >= MAX_POINTS || !params.center();
const key = `${points.length}|${openIndex}|${editable}|${tm ? 1 : 0}`;
const lonLatOf = (i: number): string | undefined => {
const ll = tm ? tmToLonLat(tm, points[i][0], points[i][1]) : null;
return ll ? `${ll[0].toFixed(6)}, ${ll[1].toFixed(6)}` : undefined;
};
if (key === shown) {
// 틀 그대로 — 이름 · 좌표 글과 펼친 칸 값만 갈아 끼움
points.forEach((_, i) => {
const row = list.children[i]?.querySelector(".ui-list-row");
const label = row?.querySelector(".ui-list-row__label");
const sub = row?.querySelector(".ui-list-row__sub");
if (label) label.textContent = name(i);
if (sub) sub.textContent = lonLatOf(i) ?? "";
});
syncEditor?.();
return;
}
shown = key;
syncEditor = null;
list.replaceChildren(
...params.points().map((point, i) => {
const lonLat = tm ? tmToLonLat(tm, point[0], point[1]) : null;
...points.map((_, i) => {
const drop = createIconButton({
icon: "trash",
title: `${name(i)} 지우기`,
@@ -176,22 +295,36 @@ export function createPlanPoints(params: PlanPointsParams): PlanPoints {
onClick: () => remove(i),
});
drop.hidden = !editable;
return createListRow({
const row = createListRow({
label: name(i),
sub: lonLat ? `${lonLat[0].toFixed(6)}, ${lonLat[1].toFixed(6)}` : undefined,
sub: lonLatOf(i),
stripe: pointColor(i),
size: 28,
trailing: drop,
active: i === openIndex,
});
row.dataset.point = String(i);
row.tabIndex = 0;
const toggle = (): void => {
openIndex = openIndex === i ? null : i;
render();
};
row.addEventListener("click", (event) => {
if (!(event.target as Element).closest(".ui-list-row__trailing")) toggle();
});
row.addEventListener("keydown", (event) => {
if (event.key === "Enter" && event.target === row) toggle();
});
const item = document.createElement("div");
item.append(row);
if (i === openIndex) {
const editor = editorOf(i);
syncEditor = editor.sync;
item.append(editor.root);
}
return item;
}),
);
hint.textContent = !editable
? params.points().length
? "보기만 — 지점은 산림청이 놓습니다."
: "지점 없음"
: params.points().length
? "핀을 끌면 그 자리 노선 점도 같이 옮깁니다 · 지도 우클릭 「지점 놓기」로 더함."
: "지도 우클릭 「지점 놓기」 — 긋기가 붙는 점(없어도 우클릭으로 긋기 시작).";
}
return {
@@ -195,3 +195,21 @@
.d03-plan__piece-year {
font-weight: 600;
}
/* 597 지점 칸 — [추가] 머리 · 펼친 줄(이름 · 경도 · 위도) */
.d03-plan__points-head {
display: flex;
justify-content: flex-end;
margin-bottom: var(--spacing-8);
}
.d03-plan__point-edit {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--spacing-8) var(--spacing-8) var(--spacing-8) calc(var(--spacing-8) * 2);
}
.d03-plan__point-edit > .ui-field {
margin: 0;
}
@@ -0,0 +1,76 @@
/* =============================================================================
* D03_PlanAnalysis_PointEdit.ts
* D03 지점 칸 손 고침 계산(PLAN 597-2 · 요구 463 7 · 8) — 화면 · fetch 없음(logic/).
* 이름 = 20 자 안 · 파일 이름에 못 쓰는 글자(`/ \ : * ? " < > |`) 막음 · 빈 값 = 「지점 N」 보임.
* 좌표 = 경위도 손 고침 → 작업 좌표(TM) · 그 자리 노선 노드도 같이(`moveNodesAt`).
* ⚠ 이름 한도 · 글자 = `Plan.ts` 의 `NAME_MAX` · `nameError`(594-1)와 같은 규칙 — 그쪽이 들어오면 거기서 가져옴.
* ========================================================================== */
import { moveNodesAt, type PlanNetwork, type Vertex } from "./D03_PlanAnalysis_Plan";
import { lonLatToTm, type TmParams } from "./D03_PlanAnalysis_Tm";
/** 지점 상한 — 서버 `MAX_POINTS` 와 같음. */
export const MAX_POINTS = 40;
export const POINT_NAME_MAX = 20;
const BAD_CHARS = /[\\/:*?"<>|]/;
/** 이름 까닭 글 — 쓸 수 있으면 null. */
export function pointNameError(name: string): string | null {
if (name.length > POINT_NAME_MAX) return `이름은 ${POINT_NAME_MAX} 자까지`;
if (BAD_CHARS.test(name)) return '이름에 / \\ : * ? " < > | 는 못 씁니다';
return null;
}
/** 보이는 이름 — 빈 값 = 「지점 N」. */
export function pointLabel(names: string[], index: number): string {
return names[index]?.trim() || `지점 ${index + 1}`;
}
/** 경위도 글 → 값 · 못 읽거나 범위 밖이면 까닭 글. */
export function parseLonLat(lonText: string, latText: string): [number, number] | string {
const lon = Number(lonText.trim());
const lat = Number(latText.trim());
if (!lonText.trim() || !Number.isFinite(lon) || Math.abs(lon) > 180) return "경도는 −180 ~ 180";
if (!latText.trim() || !Number.isFinite(lat) || Math.abs(lat) > 90) return "위도는 −90 ~ 90";
return [lon, lat];
}
export interface PointsState {
points: Vertex[];
names: string[];
network: PlanNetwork;
}
/** 지점 더함 — 이름 비움. 상한이면 그대로. */
export function addPoint(state: PointsState, at: Vertex): PointsState {
if (state.points.length >= MAX_POINTS) return state;
return {
...state,
points: [...state.points, [at[0], at[1]]],
names: [...state.names, ""],
};
}
/** 이름 바꿈 — 빈 값 허용. */
export function renamePoint(state: PointsState, index: number, name: string): PointsState {
const names = state.points.map((_, k) => state.names[k] ?? "");
names[index] = name.trim();
return { ...state, names };
}
/** 경위도로 옮김 — 그 자리 노선 노드도 같이. */
export function movePointTo(
state: PointsState,
index: number,
tm: TmParams,
lon: number,
lat: number,
): PointsState {
const to = lonLatToTm(tm, lon, lat);
const from = state.points[index];
return {
...state,
points: state.points.map((p, k): Vertex => (k === index ? to : p)),
network: moveNodesAt(state.network, from, to),
};
}
+111
View File
@@ -0,0 +1,111 @@
"""597-2 예정노선 지점 칸 손 고침 계산 — 이름 규칙 · 보이는 이름 · 경위도 읽기 · 더함 · 이름 바꿈 · 경위도로 옮김
(요구 463 7 · 8) — TS `logic/D03_PlanAnalysis_PointEdit.ts`.
./venv/Scripts/python.exe -m pytest resources/tester/test_597_point_edit.py -q
"""
from __future__ import annotations
import json
import re
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
TS_FILE = ROOT / "D03_PlanAnalysis" / "logic" / "D03_PlanAnalysis_PointEdit.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 pe from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_PointEdit.js";
import * as tmx from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Tm.js";
const Y = 2026;
const tm = tmx.tmParams(5187);
// 지점 (100,0) 에서 시작하는 선 · 지점 둘
let n = plan.newLine(plan.emptyNetwork(), [100, 0]);
n = plan.normalizePieces(plan.appendVertex(n, 0, [200, 0]), Y);
const state = { points: [[100, 0], [500, 500]], names: ["", "가"], network: n };
const added = pe.addPoint(state, [7, 8]);
const full = { points: Array.from({ length: 40 }, (_, i) => [i, 0]), names: [], network: n };
const [lon, lat] = tmx.tmToLonLat(tm, 100, 0);
const moved = pe.movePointTo(state, 0, tm, lon + 0.001, lat);
const back = tmx.tmToLonLat(tm, ...moved.points[0]);
writeFileSync(process.argv[2], JSON.stringify({
errors: ["", "가나다", "a/b", "a" + String.fromCharCode(92) + "b", "a:b", "a*b", "a?b", 'a"b', "a<b", "a>b", "a|b", "x".repeat(20), "x".repeat(21)]
.map((s) => pe.pointNameError(s)),
labels: [pe.pointLabel(["", " ", "가"], 0), pe.pointLabel(["", " ", "가"], 1), pe.pointLabel(["", " ", "가"], 2), pe.pointLabel([], 3)],
parse: [pe.parseLonLat("128.5", "36.9"), pe.parseLonLat("", "36.9"), pe.parseLonLat("128.5", "abc"), pe.parseLonLat("181", "0"), pe.parseLonLat("0", "-91")],
added: [added.points, added.names, added.network === n],
full: pe.addPoint(full, [1, 1]) === full,
renamed: [pe.renamePoint(state, 1, " 나 ").names, pe.renamePoint({ ...state, names: [] }, 1, "다").names],
moved: { points: moved.points, lines: moved.network.lines.map((l) => l.vertices), back, expect: [lon + 0.001, lat], untouched: moved.points[1], names: moved.names },
}));
"""
@pytest.fixture(scope="module")
def got(tmp_path_factory: pytest.TempPathFactory) -> dict:
tmp_path = tmp_path_factory.mktemp("pe")
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,
) # fmt: skip
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_name_rule(got: dict) -> None:
ok, bad = got["errors"][:2], got["errors"][2:11]
assert ok == [None, None] # 빈 이름 · 한글 = 됨
assert all(msg and "못 씁니다" in msg for msg in bad) # 파일 이름 못 쓰는 글자 아홉
assert got["errors"][11] is None # 20 자
assert "20 자" in got["errors"][12] # 21 자 = 막음
def test_label_blank_falls_back(got: dict) -> None:
assert got["labels"] == ["지점 1", "지점 2", "가", "지점 4"]
def test_parse_lonlat(got: dict) -> None:
ok, no_lon, bad_lat, big_lon, big_lat = got["parse"]
assert ok == [128.5, 36.9]
assert no_lon.startswith("경도") and bad_lat.startswith("위도")
assert big_lon.startswith("경도") and big_lat.startswith("위도")
def test_add_and_limit(got: dict) -> None:
points, names, network_same = got["added"]
assert points == [[100, 0], [500, 500], [7, 8]] and names == ["", "가", ""]
assert network_same # 더해도 노선 그대로
assert got["full"] # 40 개 = 그대로
def test_rename(got: dict) -> None:
assert got["renamed"][0] == ["", "나"] # 앞뒤 빈칸 뺌
assert got["renamed"][1] == ["", "다"] # 이름 목록이 짧아도 차례 맞춤
def test_move_by_lonlat_drags_node(got: dict) -> None:
moved = got["moved"]
assert moved["back"] == pytest.approx(moved["expect"], abs=1e-7) # 경위도 왕복
assert moved["lines"][0][0] == moved["points"][0] # 그 자리 노선 노드도 같이
assert moved["lines"][0][1] == [200, 0] # 다른 노드는 그대로
assert moved["untouched"] == [500, 500] and moved["names"] == ["", "가"]