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

This commit is contained in:
2026-10-30 15:14:52 +09:00
7 changed files with 232 additions and 100 deletions
@@ -18,8 +18,8 @@ import {
type HeightAt,
} from "./logic/D03_PlanAnalysis_Draw";
import type { PlanNetwork, Vertex } from "./logic/D03_PlanAnalysis_Plan";
import { nodeOff } from "./logic/D03_PlanAnalysis_Span";
import { pointLabel } from "./logic/D03_PlanAnalysis_PointEdit";
import { nodeOff } from "./logic/D03_PlanAnalysis_Span";
import {
planColors,
PLAN_STATION_INTERVAL_M,
+7 -1
View File
@@ -161,7 +161,13 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
yearStart: () => yearStart,
commit,
});
const pieces = createPlanPieces({ network: () => network, edit });
const pieces = createPlanPieces({
network: () => network,
edit,
editable: () => editable,
yearOptions: props.yearOptions,
commit,
});
const measure = createPlanMeasure(
() => z,
() => network,
+174 -45
View File
@@ -1,13 +1,26 @@
/* =============================================================================
* D03_PlanAnalysis_UI_Pieces.ts
* D03 좌측 「구역 · 연도」 목록 몸(PLAN 597) — 해 줄(같은 해) · 선 머리 줄(선) · 구간 줄(구간).
* 속성 판(`UI_Props.ts`)에서 떼어냄 — 목록 = 보기 + 고르기.
* D03 좌측 「구역 · 연도」 목록 몸(PLAN 597 · 요구 463 14) — 구역 = 그은 꺾은선 하나(본선 · 갈래).
* 해 줄(같은 해) · 구역 줄(색 띠 = 지도 선과 같은 `routeColor(차례)` · 이름 칸 · 연도 고름 · 지움) ·
* 구간 줄(측점 글 작게 · 연도 고름). 누름 = 고름 · 해 고침 · 이름 · 지움은 한 걸음(되돌리기 · 캐시 = 페이지).
* 연도 고름은 속성 판과 같은 셈(`setLineYear` · `setPiecesYear`) · 보기만 계정 = 값만.
* ========================================================================== */
import { createText } from "@ui/ui_template_text";
import { createIconButton } from "@ui/ui_template_button";
import { createInputField, createSelectField } from "@ui/ui_template_input";
import { createListRow } from "@ui/ui_template_list_row";
import { askConfirm } from "@ui/ui_template_modal";
import { routeColor } from "@ui/ui_template_palette";
import { stationLabel } from "../common_util/common_util_station";
import { lineLength } from "./logic/D03_PlanAnalysis_Draw";
import { lineNames, type PlanNetwork } from "./logic/D03_PlanAnalysis_Plan";
import {
lineNames,
nameError,
setLineYear,
setPiecesYear,
type PlanNetwork,
} from "./logic/D03_PlanAnalysis_Plan";
import { setLineName } from "./logic/D03_PlanAnalysis_PointEdit";
import type { PlanEdit } from "./D03_PlanAnalysis_UI_Edit";
import { PLAN_STATION_INTERVAL_M } from "./D03_PlanAnalysis_UI_Map";
@@ -17,6 +30,10 @@ const meters = (m: number): string => `${m.toFixed(1)} m`;
export interface PlanPiecesParams {
network: () => PlanNetwork;
edit: PlanEdit;
editable: () => boolean;
/** 고를 해 — 사업연도 시작부터 열 해 + 조각에 있는 해. */
yearOptions: () => Array<{ value: string; text: string }>;
commit: (next: PlanNetwork) => void;
}
export interface PlanPieces {
@@ -25,79 +42,191 @@ export interface PlanPieces {
render: () => void;
}
/** 좌측 줄 — 누름 = 고름(div · 열쇠 Enter 도). */
const pickRow = (row: HTMLElement, onPick: () => void): HTMLElement => {
row.tabIndex = 0;
row.setAttribute("role", "button");
row.addEventListener("click", onPick);
row.addEventListener("keydown", (event) => event.key === "Enter" && onPick());
return row;
};
export function createPlanPieces(params: PlanPiecesParams): PlanPieces {
const { edit } = params;
const list = document.createElement("div");
list.className = "d03-plan__pieces";
let shown = ""; // 지금 그린 틀 열쇠 — 같으면 값만 갈아 끼움(칸 초점 지키기)
let syncs: Array<() => void> = [];
/** 줄 안 칸 눌림은 줄 고름으로 안 번지게. */
const isControl = (event: Event): boolean =>
!!(event.target as Element).closest("input, select, button, .ui-list-row__trailing");
/** 줄 하나 — 누름 = 고름(열쇠 Enter 도) · 줄 안 칸은 제외. */
const pickable = (row: HTMLElement, onPick: () => void): HTMLElement => {
row.tabIndex = 0;
row.addEventListener("click", (event) => isControl(event) || onPick());
row.addEventListener("keydown", (event) => {
if (event.key === "Enter" && event.target === row) onPick();
});
return row;
};
/** 연도 고름 칸 — 값이 섞이면 「여러 해」. */
function yearSelect(
years: () => number[],
label: string,
pick: (year: number) => void,
): { root: HTMLElement; sync: () => void } {
const field = createSelectField({
options: [],
size: 24,
ariaLabel: label,
disabled: !params.editable(),
onChange: (value) => value && pick(Number(value)),
});
const sync = (): void => {
const set = new Set(years());
const options = params.yearOptions();
field.setOptions(
set.size > 1 ? [{ value: "", text: "여러 해" }, ...options] : options,
set.size > 1 ? "" : String([...set][0]),
);
};
sync();
return { root: field.root, sync };
}
function render(): void {
const network = params.network();
const names = lineNames(network.lines);
const picked = new Set(edit.selected()?.pieces ?? []);
const scope = edit.selected()?.scope;
const editable = params.editable();
const key = JSON.stringify([
network.lines.map((line) => line.id),
network.pieces.map((piece) => [piece.id, piece.line]),
[...picked],
scope,
editable,
params.yearOptions().length,
]);
if (key === shown) {
syncs.forEach((sync) => sync());
return;
}
shown = key;
syncs = [];
const rows: HTMLElement[] = [];
const byYear = new Map<number, typeof network.pieces>();
for (const piece of network.pieces)
byYear.set(piece.year, [...(byYear.get(piece.year) ?? []), piece]);
for (const [year, pieces] of [...byYear].sort(([a], [b]) => a - b)) {
const row = document.createElement("div");
row.className = "d03-plan__piece d03-plan__piece-year";
row.dataset.year = String(year);
row.classList.toggle(
"is-selected",
scope === "year" && pieces.every((p) => picked.has(p.id)),
);
const length = pieces.reduce((sum, p) => sum + p.to_m - p.from_m, 0);
row.append(createText(`${year}년 · 구간 ${pieces.length} · ${meters(length)}`, "muted"));
rows.push(
pickRow(row, () => {
const row = createListRow({
label: `${year}년`,
sub: `구간 ${pieces.length} · ${meters(length)}`,
size: 28,
active: scope === "year" && pieces.every((p) => picked.has(p.id)),
onClick: () => {
edit.select([pieces[0].id]);
edit.setScope("year");
}),
);
},
});
row.dataset.year = String(year);
rows.push(row);
}
network.lines.forEach((line, index) => {
const pieces = network.pieces.filter((piece) => piece.line === line.id);
if (!pieces.length) return;
const head = document.createElement("div");
head.className = "d03-plan__piece d03-plan__piece-head";
head.dataset.line = line.id;
head.classList.toggle(
"is-selected",
scope === "line" && pieces.every((p) => picked.has(p.id)),
const lineNow = (): (typeof network.lines)[number] =>
params.network().lines.find((entry) => entry.id === line.id) ?? line;
const piecesNow = (): typeof pieces =>
params.network().pieces.filter((piece) => piece.line === line.id);
const nameField = createInputField({ size: 24, placeholder: names[index] });
nameField.input.value = line.name ?? "";
nameField.input.setAttribute("aria-label", "구역 이름");
nameField.input.dataset.role = "line-name";
nameField.input.disabled = !editable;
nameField.input.addEventListener("input", () =>
nameField.setError(nameError(nameField.input.value) ?? undefined),
);
head.append(createText(`${names[index]} · ${meters(lineLength(line.vertices))}`, "muted"));
nameField.input.addEventListener("change", () => {
const error = nameError(nameField.input.value);
nameField.setError(error ?? undefined);
const text = nameField.input.value.trim();
if (!error && text !== (lineNow().name ?? ""))
params.commit(setLineName(params.network(), line.id, text));
});
const year = yearSelect(
() => piecesNow().map((piece) => piece.year),
"구역 연도",
(value) => params.commit(setLineYear(params.network(), line.id, value)),
);
year.root.dataset.role = "line-year";
const drop = createIconButton({
icon: "trash",
title: "구역 지움",
size: 24,
onClick: async () => {
const ok = await askConfirm({
message: `「${lineNames(params.network().lines)[index]}」 구역을 지울까요?`,
okLabel: "지움",
danger: true,
});
if (!ok) return;
edit.select([piecesNow()[0].id]);
edit.dropLine();
},
});
drop.dataset.role = "line-drop";
drop.hidden = !editable;
const tail = document.createElement("span");
tail.className = "d03-plan__line-tail";
tail.append(year.root, drop);
const head = createListRow({
label: "",
sub: `구간 ${pieces.length} · ${meters(lineLength(line.vertices))}`,
stripe: routeColor(index),
size: 28,
trailing: tail,
active: scope === "line" && pieces.every((p) => picked.has(p.id)),
});
head.querySelector(".ui-list-row__label")?.replaceChildren(nameField.root);
head.dataset.line = line.id;
rows.push(
pickRow(head, () => {
pickable(head, () => {
edit.select([pieces[0].id]);
edit.setScope("line");
}),
);
syncs.push(() => {
year.sync();
nameField.input.placeholder = lineNames(params.network().lines)[index] ?? "";
if (document.activeElement !== nameField.input)
nameField.input.value = lineNow().name ?? "";
const sub = head.querySelector(".ui-list-row__sub");
if (sub)
sub.textContent = `구간 ${piecesNow().length} · ${meters(lineLength(lineNow().vertices))}`;
});
for (const piece of pieces) {
const row = document.createElement("div");
row.className = "d03-plan__piece";
row.classList.toggle("is-selected", picked.has(piece.id));
row.dataset.piece = piece.id;
row.append(
createText(
`${station(piece.from_m)} ~ ${station(piece.to_m)} (${meters(piece.to_m - piece.from_m)})`,
"muted",
),
createText(`${piece.year}년`, "muted"),
const pieceNow = (): (typeof pieces)[number] =>
params.network().pieces.find((entry) => entry.id === piece.id) ?? piece;
const pieceYear = yearSelect(
() => [pieceNow().year],
"구간 연도",
(value) => params.commit(setPiecesYear(params.network(), [piece.id], value)),
);
rows.push(pickRow(row, () => edit.select([piece.id])));
const row = createListRow({
label: `${station(piece.from_m)} ~ ${station(piece.to_m)}`,
sub: meters(piece.to_m - piece.from_m),
size: 28,
trailing: pieceYear.root,
active: picked.has(piece.id),
});
row.classList.add("d03-plan__piece-row");
row.dataset.piece = piece.id;
rows.push(pickable(row, () => edit.select([piece.id])));
syncs.push(pieceYear.sync);
}
});
if (!rows.length) rows.push(createText("선을 그으면 구역이 생깁니다.", "muted"));
list.replaceChildren(...rows);
}
@@ -16,14 +16,18 @@ 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 { moveNodesAt, type PlanNetwork, type Vertex } from "./logic/D03_PlanAnalysis_Plan";
import {
moveNodesAt,
nameError,
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";
@@ -228,11 +232,11 @@ export function createPlanPoints(params: PlanPointsParams): PlanPoints {
set(latField, ll ? ll[1].toFixed(6) : "");
};
nameField.input.addEventListener("input", () =>
nameField.setError(pointNameError(nameField.input.value.trim()) ?? undefined),
nameField.setError(nameError(nameField.input.value.trim()) ?? undefined),
);
nameField.input.addEventListener("change", () => {
const text = nameField.input.value.trim();
const error = pointNameError(text);
const error = nameError(text);
nameField.setError(error ?? undefined);
if (!error && text !== (params.names()[i] ?? "")) commitState(renamePoint(state(), i, text));
});
+19 -36
View File
@@ -106,28 +106,6 @@
gap: 4px;
}
.d03-plan__piece-head {
margin: 6px 0 0;
font-weight: 600;
color: var(--color-text);
}
.d03-plan__piece {
display: flex;
gap: var(--spacing-8);
align-items: center;
justify-content: space-between;
}
.d03-plan__piece > .ui-text {
margin: 0;
}
.d03-plan__piece > .ui-field {
flex: 0 0 6.5rem;
margin: 0;
}
/* 지형 받기 범위 미리보기 — 카카오 지도 */
.d03-plan__extent > .ui-text {
margin: 0 0 6px;
@@ -152,12 +130,6 @@
margin-bottom: 6px;
}
/* 580-3 고른 선 · 조각 줄 밝힘 */
.d03-plan__piece.is-selected {
background: var(--color-accent-soft);
border-radius: var(--radius-md);
}
/* 585-2 고른 구간 속성 판 — 도구줄 아래 오른쪽 · 몸 스크롤 · 구간 줄 = 고스트 단추 */
.d03-plan__props {
top: calc(var(--spacing-8) * 6);
@@ -188,14 +160,6 @@
justify-content: flex-start;
}
.d03-plan__piece[role="button"] {
cursor: pointer;
}
.d03-plan__piece-year {
font-weight: 600;
}
/* 597 지점 칸 — [추가] 머리 · 펼친 줄(이름 · 경도 · 위도) */
.d03-plan__points-head {
display: flex;
@@ -231,3 +195,22 @@
.d03-plan__draw-fields .ui-field {
margin: 0;
}
/* 597 구역 · 연도 목록 — 구역 줄(이름 칸 · 연도 고름 · 지움) · 구간 줄(측점 글 작게) */
.d03-plan__line-tail {
display: flex;
gap: var(--spacing-4);
align-items: center;
}
.d03-plan__pieces .ui-list-row__label > .ui-field {
margin: 0;
}
.d03-plan__piece-row {
padding-left: calc(var(--spacing-8) * 2);
}
.d03-plan__piece-row .ui-list-row__label {
font-size: var(--text-caption);
}
@@ -1,9 +1,8 @@
/* =============================================================================
* D03_PlanAnalysis_PointEdit.ts
* D03 지점 칸 손 고침 계산(PLAN 597-2 · 요구 463 7 · 8) — 화면 · fetch 없음(logic/).
* 이름 = 20 자 안 · 파일 이름에 못 쓰는 글자(`/ \ : * ? " < > |`) 막음 · 빈 값 = 「지점 N」 보임.
* 이름 규칙 = `Plan.ts` 의 `nameError`(20 자 · 파일 이름 못 쓰는 글자 · 서버 400 짝) · 빈 값 = 「지점 N」 보임.
* 좌표 = 경위도 손 고침 → 작업 좌표(TM) · 그 자리 노선 노드도 같이(`moveNodesAt`).
* ⚠ 이름 한도 · 글자 = `Plan.ts` 의 `NAME_MAX` · `nameError`(594-1)와 같은 규칙 — 그쪽이 들어오면 거기서 가져옴.
* ========================================================================== */
import { moveNodesAt, type PlanNetwork, type Vertex } from "./D03_PlanAnalysis_Plan";
@@ -11,15 +10,6 @@ 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 {
@@ -74,3 +64,14 @@ export function movePointTo(
network: moveNodesAt(state.network, from, to),
};
}
/** 선 이름 바꿈(597-3) — 빈 값 = 차례 이름으로 돌아감(`name` 비움). */
export function setLineName(network: PlanNetwork, lineId: string, name: string): PlanNetwork {
const text = name.trim();
return {
...network,
lines: network.lines.map((line) =>
line.id === lineId ? { ...line, name: text || null } : line,
),
};
}
+11 -2
View File
@@ -28,6 +28,8 @@ const tm = tmx.tmParams(5187);
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 named = pe.setLineName(pe.setLineName(n, n.lines[0].id, ' 본선A '), 'none', 'x');
const cleared = pe.setLineName(named, n.lines[0].id, ' ');
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);
@@ -35,10 +37,11 @@ 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)),
.map((s) => plan.nameError(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],
lineName: [named.lines[0].name, plan.lineNames(named.lines), cleared.lines[0].name, plan.lineNames(cleared.lines)],
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 },
@@ -75,7 +78,7 @@ def got(tmp_path_factory: pytest.TempPathFactory) -> dict:
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 all(msg and "쓸 수 없습니다" in msg for msg in bad) # 파일 이름 못 쓰는 글자 아홉
assert got["errors"][11] is None # 20 자
assert "20 자" in got["errors"][12] # 21 자 = 막음
@@ -109,3 +112,9 @@ def test_move_by_lonlat_drags_node(got: dict) -> None:
assert moved["lines"][0][0] == moved["points"][0] # 그 자리 노선 노드도 같이
assert moved["lines"][0][1] == [200, 0] # 다른 노드는 그대로
assert moved["untouched"] == [500, 500] and moved["names"] == ["", "가"]
def test_line_name(got: dict) -> None:
named, names, cleared, back = got["lineName"]
assert named == "본선A" and names == ["본선A"] # 앞뒤 빈칸 뺌 · 이름 우선
assert cleared is None and back == ["본선"] # 빈 값 = 차례 이름으로