From 10402b3ee1d63d23072e64a13cf650bd163516c3 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 30 Oct 2026 14:57:51 +0900 Subject: [PATCH] =?UTF-8?q?feat(=EC=98=88=EC=A0=95=EB=85=B8=EC=84=A0):=20?= =?UTF-8?q?=EA=B5=AC=EC=97=AD=20=EB=AA=A9=EB=A1=9D=20=EC=83=89=20=EB=9D=A0?= =?UTF-8?q?=20=C2=B7=20=EC=9D=B4=EB=A6=84=20=EC=B9=B8=20=C2=B7=20=EC=97=B0?= =?UTF-8?q?=EB=8F=84=20=EA=B3=A0=EB=A6=84=20=C2=B7=20=EC=A7=80=EC=9B=80=20?= =?UTF-8?q?=C2=B7=20=EC=B8=A1=EC=A0=90=20=EA=B8=80=20=EC=9E=91=EA=B2=8C=20?= =?UTF-8?q?=C2=B7=20=EC=A7=80=EC=A0=90=20=EC=9D=B4=EB=A6=84=20=EA=B7=9C?= =?UTF-8?q?=EC=B9=99=EC=9D=80=20Plan.nameError=20(PLAN=20597-2=20=C2=B7=20?= =?UTF-8?q?597-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts | 8 +- .../D03_PlanAnalysis_UI_Pieces.ts | 219 ++++++++++++++---- .../D03_PlanAnalysis_UI_Points.ts | 12 +- .../D03_PlanAnalysis_UI_Style.css | 55 ++--- .../logic/D03_PlanAnalysis_PointEdit.ts | 23 +- resources/tester/test_597_point_edit.py | 13 +- 6 files changed, 231 insertions(+), 99 deletions(-) diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts index a685c96de..b510abfe0 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts @@ -146,7 +146,13 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { 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); const marks = createPlanPoints({ points: () => points, diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Pieces.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Pieces.ts index 9cca359f9..73ddc73ff 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Pieces.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Pieces.ts @@ -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(); 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); } diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Points.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Points.ts index ba4044b6b..933685d29 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Points.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Points.ts @@ -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)); }); diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css index e48777379..617aaca50 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css @@ -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); +} diff --git a/D03_PlanAnalysis/logic/D03_PlanAnalysis_PointEdit.ts b/D03_PlanAnalysis/logic/D03_PlanAnalysis_PointEdit.ts index 06008fd45..6cb3c8aad 100644 --- a/D03_PlanAnalysis/logic/D03_PlanAnalysis_PointEdit.ts +++ b/D03_PlanAnalysis/logic/D03_PlanAnalysis_PointEdit.ts @@ -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, + ), + }; +} diff --git a/resources/tester/test_597_point_edit.py b/resources/tester/test_597_point_edit.py index f8f25848f..b4f90f33a 100644 --- a/resources/tester/test_597_point_edit.py +++ b/resources/tester/test_597_point_edit.py @@ -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', "ab", "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 == ["본선"] # 빈 값 = 차례 이름으로