From eba5223356a4799b2152fadffbcc6a82f7a743f2 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 12:00:21 +0900 Subject: [PATCH] =?UTF-8?q?feat(638-3):=20=EC=98=88=EC=A0=95=EB=85=B8?= =?UTF-8?q?=EC=84=A0=20=EB=85=B8=EC=84=A0=20=EC=84=B8=ED=8A=B8=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=E2=80=94=20=EC=86=8D=EC=84=B1=20=ED=8C=90=20?= =?UTF-8?q?=EC=84=B8=ED=8A=B8=20=EC=B9=B8=20=C2=B7=20=EC=97=B0=EB=8F=84=20?= =?UTF-8?q?=EB=B0=91=20=EC=84=B8=ED=8A=B8=20=EC=A4=84=20=C2=B7=20=EC=9D=B4?= =?UTF-8?q?=EB=A6=84=ED=91=9C=20=E3=80=8C2027-1=E3=80=8D=20=C2=B7=20[?= =?UTF-8?q?=EC=9E=87=EA=B8=B0]=20=C2=B7=20=EC=A0=80=EC=9E=A5=20=EB=92=A4?= =?UTF-8?q?=20=EB=81=8A=EA=B9=80=20=EA=B2=BD=EA=B3=A0=20=C2=B7=20D04=20?= =?UTF-8?q?=EC=84=B8=ED=8A=B8=20=EC=A4=84(=EC=9A=94=EA=B5=AC=20547=20?= =?UTF-8?q?=C2=B7=20548)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 속성 판 · 왼쪽 칸 세트 고름 = setChoices(있는 세트 + 해마다 새 세트) → assignSet(토막 · 구역 · 구간 고름 모두) · 옛 setPiecesYear · setLineYear 자리 갈음 - 왼쪽 해 줄 밑 세트 줄(길이 · 구간 수 · 덩이 수 = 마지막 서버 셈) · 같은 세트 고름 · 되돌리기 뒤 길이 글 옛 값 흠 고침(열쇠에 구간 꼴) - 지도 이름표 「2027-1」 · 속 빈 마름모 = 같은 세트 · 마커 이름 「구간 나눔」 - [잇기] = 선 끝 노드 속성 판 · 우클릭 → 다른 선 끝 누름 = joinEnds 한 걸음 · Esc 그만 - [저장] 뒤 derived.sets parts > 1 · branch = 알림 「2027-1 이 2 덩이 · 빈틈 35.0 m」(Group.setWarnings · setGapM) - D04 표 첫 칸 = 세트 · 선 · 덩이 · 갈래 + 확정 막힘 알림 띠 - 기능 표 set(B05 보기만) · join(B05 숨김) 줄 · 시험 638_group 빈틈 · 626_3 표 줄 Co-Authored-By: Claude Opus 5.5 --- .../D03_PlanAnalysis_UI_DrawPaint.ts | 9 +- .../D03_PlanAnalysis_UI_DrawPanel.ts | 12 +- D03_PlanAnalysis/D03_PlanAnalysis_UI_Edit.ts | 83 +++++++++++++- .../D03_PlanAnalysis_UI_Features.ts | 16 ++- D03_PlanAnalysis/D03_PlanAnalysis_UI_Kit.ts | 15 ++- D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts | 4 +- D03_PlanAnalysis/D03_PlanAnalysis_UI_Menu.ts | 11 +- D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts | 6 + .../D03_PlanAnalysis_UI_Pieces.ts | 100 ++++++++++------- D03_PlanAnalysis/D03_PlanAnalysis_UI_Props.ts | 104 ++++++++++-------- .../D03_PlanAnalysis_UI_Select.ts | 6 +- .../logic/D03_PlanAnalysis_Group.ts | 49 ++++++++- D04_PlanConfirm/D04_PlanConfirm_UI_Page.ts | 23 +++- resources/tester/test_626_3_expected_edit.py | 15 ++- resources/tester/test_638_group.py | 21 ++++ ui_template/ui_template_locale_de.ts | 8 +- 16 files changed, 367 insertions(+), 115 deletions(-) diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts index 98117e92a..a6077940d 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts @@ -21,6 +21,7 @@ import { type HeightAt, } from "./logic/D03_PlanAnalysis_Draw"; import { fanPoint, type Fan } from "./logic/D03_PlanAnalysis_Fan"; +import { sameSet, setKey } from "./logic/D03_PlanAnalysis_Group"; import type { PlanNetwork, Vertex } from "./logic/D03_PlanAnalysis_Plan"; import { pointLabel } from "./logic/D03_PlanAnalysis_PointEdit"; import { nodeOff } from "./logic/D03_PlanAnalysis_Span"; @@ -297,11 +298,11 @@ function drawPieces(context: CanvasRenderingContext2D, map: ScreenMap, network: const vertices = network.lines.find((line) => line.id === piece.line)?.vertices; if (!vertices || vertices.length < 2) return; if (piece.from_m > 0) { - // 앞 구간과 같은 해 = 구역만 나눔(속 빈 마름모 · 585-3) + // 앞 구간과 같은 세트 = 구간만 나눔(속 빈 마름모 · 585-3 · 638-3) const before = network.pieces.find( (p) => p.line === piece.line && Math.abs(p.to_m - piece.from_m) < 1e-3, ); - const hollow = before?.year === piece.year; + const hollow = !!before && sameSet(before, piece); paintMark( context, map.toScreen(pointAtDistance(vertices, piece.from_m)), @@ -310,11 +311,11 @@ function drawPieces(context: CanvasRenderingContext2D, map: ScreenMap, network: ); } const [mx, my] = map.toScreen(pointAtDistance(vertices, (piece.from_m + piece.to_m) / 2)); - planLabel(context, map, mx, my + 16, `${piece.year}년`, "bold 12px"); + planLabel(context, map, mx, my + 16, setKey(piece), "bold 12px"); }); } -/** 연도 구역 마커 마름모 — 속 빈 = 같은 해 구역 나눔. */ +/** 구간 나눔 마커 마름모 — 속 빈 = 같은 세트 구간 나눔. */ export function paintMark( context: CanvasRenderingContext2D, [sx, sy]: [number, number], diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPanel.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPanel.ts index 27963ca4f..3e050daa4 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPanel.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPanel.ts @@ -1,10 +1,10 @@ /* ============================================================================= * D03_PlanAnalysis_UI_DrawPanel.ts - * D03 좌측 「그리기」 칸 몸(PLAN 599-1 · 599-4 · 요구 463-10 · 22) — [긋기] · 연도 구역 마커 · 긋기 방식 칸. + * D03 좌측 「그리기」 칸 몸(PLAN 599-1 · 599-4 · 요구 463-10 · 22) — [긋기] · 구간 나눔 마커 · 긋기 방식 칸. * * 방식 칸 `createDrawKindFields` = 공용 `A00_Common/map/map_draw_kind`(626-3) — 그리기 칸 · 노드 판(598-4) · * B05 평면 예정노선 그리기가 같은 부품을 씀(여기서 다시 내보냄). - * 연도 구역 마커 = 끌어 선 위에 놓기(`YEAR_MARK_DRAG` · 받음 = `D03_PlanAnalysis_UI_Edit` drop). + * 구간 나눔 마커 = 끌어 선 위에 놓기(`YEAR_MARK_DRAG` · 받음 = `D03_PlanAnalysis_UI_Edit` drop). * 설명 글 없음(요구 463-6) — 칸 오류 · ≈ ° 값만. * ========================================================================== */ @@ -31,11 +31,11 @@ export interface DrawPanel { state: (state: { drawing: boolean; editable: boolean; canMark: boolean }) => void; } -/** 좌측 「그리기」 칸 — [긋기] · 연도 구역 마커(끌기) · 방식 칸. */ +/** 좌측 「그리기」 칸 — [긋기] · 구간 나눔 마커(끌기) · 방식 칸. */ export function createDrawPanel(opts: { kind: DrawKindFields; onDraw: () => void; - /** 기능 표 페이지 — 연도 구역 마커 단추 = 「years」 사용일 때만. */ + /** 기능 표 페이지 — 구간 나눔 마커 단추 = 「years」 사용일 때만. */ page: PlanPage; }): DrawPanel { const drawButton = createButton({ @@ -45,12 +45,12 @@ export function createDrawPanel(opts: { onClick: opts.onDraw, }); const markButton = createButton({ - label: "연도 구역 마커", + label: "구간 나눔", variant: "ghost", size: 28, iconStart: "flag", }); - markButton.title = "선 위로 끌어 놓으면 구역을 나눕니다"; + markButton.title = "선 위로 끌어 놓으면 구간을 나눕니다"; markButton.dataset.role = "draw-year-mark"; markButton.hidden = !featureOn(opts.page, "years"); markButton.draggable = true; diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Edit.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Edit.ts index 43c33632b..161f1369d 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Edit.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Edit.ts @@ -4,7 +4,7 @@ * * 고름 층 = 노드 → 선(두 노드 사이 토막) → 구역(그은 꺾은선) → 전체(`UI_Select`) — 노드 누름 = 노드 · 선 위 누름 = 토막 · * 고른 것 다시 누름 = 한 단 넓힘(전체 다음 = 처음) · Shift+누름 = 토막 더함 · 뺌 · 빈 곳 누름 · Esc = 풂. - * 「같은 해」 = 왼쪽 해 줄로만(`selectYear`). + * 「같은 세트」 = 왼쪽 세트 줄로만(`selectSet`) · 해 줄 = 그 해 모두(`selectYear`). * 빈 곳 왼쪽 끌기 = 끌어 고르기(오토캐드 꼴 · 토막) — 오른쪽으로 = 창(실선 · 다 든 토막만) · * 왼쪽으로 = 걸침(점선 · 걸친 토막까지) · Shift = 지금 고름에 더함. * 노드 끌기 = 옮김(붙은 갈래 첫 점 같이) · 지점 자리 노드 = 지점도 같이 · 지점 12 px 안에 놓으면 그 자리로 붙음 · @@ -12,7 +12,8 @@ * 선 밖으로 끌어냄 = 지움(합침). * 지움 = Delete · 속성 판 · 왼쪽 목록 · 우클릭 메뉴가 같은 함수 — 노드 · 토막(가운데 = 구역 둘) · 구역 · 전체 · 마크. * 우클릭 = 메뉴(`UI_Menu` — 이 겹은 오른쪽 누름을 안 받음). - * 연도 구역 마커(585-3) = 「연도 마커」를 선 위로 끌어 놓음 · 메뉴 「여기서 연도 구역 나눔」(`splitAt`) = 그 자리 구간을 둘로. + * 구간 나눔 마커(585-3) = 「구간 나눔」을 선 위로 끌어 놓음 · 메뉴 「여기서 구간 나눔」(`splitAt`) = 그 자리 구간을 둘로. + * [잇기](638-3) = 선 끝 노드에서 시작(속성 판 · 우클릭) → 다른 선 끝 누름 = `joinEnds` 한 걸음 · 빈 곳 · Esc = 그만. * 끌기 중 = 화면만(`show`) · 놓을 때 `commit` 한 번 = 되돌리기 한 걸음 · 고름 = 화면 기억(저장 안 함). * 속성 판 = `D03_PlanAnalysis_UI_Props`(고름을 읽고 동작을 부름). * ========================================================================== */ @@ -21,6 +22,7 @@ import { showToast } from "@ui/ui_template_elements"; import { themeColor } from "@ui/ui_template_palette"; import { keyIs } from "../A00_Common/key_registry"; import { distanceAlong, pointAtDistance, type HeightAt } from "./logic/D03_PlanAnalysis_Draw"; +import { joinEnds, setKey, type LineEnd } from "./logic/D03_PlanAnalysis_Group"; import { insertVertex, moveMark, @@ -81,6 +83,8 @@ export interface PlanEditParams { showPoints: (points: Vertex[], next: PlanNetwork) => void; /** 고르기 받는 때(쉴 때 · 재기 아님). */ enabled: () => boolean; + /** 새 선 처음 해([잇기] 덧 토막 · `normalizePieces` · 없음 = 올해). */ + year?: () => number; /** 고치기 됨(산림청). */ editable: () => boolean; /** 고름이 바뀜 — 페이지가 좌측 줄 밝힘 · 속성 판. */ @@ -106,6 +110,14 @@ export interface PlanEdit { selectNode: (node: PlanNode) => void; selectRegion: (line: string) => void; selectYear: (year: number) => void; + /** 같은 세트 「2027-1」 모두. */ + selectSet: (key: string) => void; + /** 선 끝 노드인가(갈래 첫 점 빼고). */ + isEnd: (node: PlanNode) => boolean; + /** [잇기] 시작(이 끝) · 다음 선 끝 누름 = 이음 · 지금 잇는 끝. */ + startJoin: (node: PlanNode) => void; + joinTo: (node: PlanNode) => void; + joining: () => LineEnd | null; selectAll: () => void; /** 고른 것 지움(Delete · 판 [지움]). */ dropSelected: () => void; @@ -154,8 +166,10 @@ type Drag = export function createPlanEdit(params: PlanEditParams): PlanEdit { const sel = createPlanSelect(params.network, params.onSelect); let drag: Drag | null = null; - /** 「연도 마커」 끄는 중 미리보기 — 놓일 자리 · 놓을 수 있음 · 글. */ + /** 「구간 나눔」 끄는 중 미리보기 — 놓일 자리 · 놓을 수 있음 · 글. */ let markDrop: { point: Vertex; ok: boolean; text: string } | null = null; + /** [잇기] 시작한 선 끝. */ + let joinFrom: LineEnd | null = null; const lineIndexOf = (network: PlanNetwork, id: string): number => network.lines.findIndex((line) => line.id === id); @@ -168,6 +182,29 @@ export function createPlanEdit(params: PlanEditParams): PlanEdit { sel.sync(); } + /** 선 끝 — 첫 점(갈래 아님) · 끝 점 · 그 밖 = null. */ + function endOf(node: PlanNode): LineEnd | null { + const line = params.network().lines.find((entry) => entry.id === node.line); + if (!line) return null; + if (node.vertex === 0 && !line.branch) return { line: line.id, head: true }; + return node.vertex === line.vertices.length - 1 ? { line: line.id, head: false } : null; + } + + function startJoin(node: PlanNode): void { + joinFrom = endOf(node); + showToast( + joinFrom ? "이을 다른 선 끝을 누르세요(Esc = 그만)." : "선 끝 노드에서 잇습니다.", + "info", + ); + } + + function joinTo(node: PlanNode): void { + const [from, to] = [joinFrom, endOf(node)]; + joinFrom = null; + if (!from || !to) return void showToast("선 끝 노드를 누르세요 — 잇기를 그만둡니다.", "info"); + apply(joinEnds(params.network(), from, to, params.year?.() ?? new Date().getFullYear())); + } + function dropMark(): void { const network = params.network(); const piece = network.pieces.find((p) => p.id === sel.get()?.mark); @@ -361,6 +398,14 @@ export function createPlanEdit(params: PlanEditParams): PlanEdit { context.strokeStyle = colors.active; context.stroke(); } + const joinLine = network.lines.find((line) => line.id === joinFrom?.line)?.vertices; + if (joinFrom && joinLine) { + const [sx, sy] = map.toScreen(joinLine[joinFrom.head ? 0 : joinLine.length - 1]); + context.beginPath(); + context.arc(sx, sy, 14, 0, Math.PI * 2); + strokeCased(context, colors.warn, 3, [4, 3]); + planLabel(context, map, sx, sy - 26, "잇기 — 다른 선 끝 누름", "bold 12px"); + } if (markDrop) { const at = map.toScreen(markDrop.point); paintMark(context, at, markDrop.ok ? colors.active : colors.warn); @@ -398,6 +443,15 @@ export function createPlanEdit(params: PlanEditParams): PlanEdit { }, press(px, py, map, button, shift) { if (!params.enabled() || button !== 0) return false; // 우클릭 = 메뉴(UI_Menu) + if (joinFrom) { + const node = hit(px, py, map).node; + if (node) joinTo(node); + else { + joinFrom = null; + showToast("잇기를 그만둡니다.", "info"); + } + return true; + } const network = params.network(); const mark = markAt(network, px, py, map); if (mark !== null) { @@ -468,7 +522,7 @@ export function createPlanEdit(params: PlanEditParams): PlanEdit { point: place.point, ok: !!place.next, text: place.next - ? "여기서 구역 나눔" + ? "여기서 구간 나눔" : lineAt(params.network(), px, py, map) ? "선 끝 · 다른 마커 1 m 안 — 못 놓음" : "선 위에 놓으세요", @@ -524,8 +578,10 @@ export function createPlanEdit(params: PlanEditParams): PlanEdit { if (!seed) return; if (scope === "line") return sel.region(seed.line); if (scope === "piece") return sel.pieces([seed.id]); - const same = network.pieces.filter((p) => p.year === seed.year).map((p) => p.id); - sel.pieces([seed.id, ...same], null, "year"); + const same = network.pieces + .filter((p) => p.year === seed.year && (scope === "year" || p.set === seed.set)) + .map((p) => p.id); + sel.pieces([seed.id, ...same], null, scope); } return { @@ -543,6 +599,17 @@ export function createPlanEdit(params: PlanEditParams): PlanEdit { .map((p) => p.id); sel.pieces(ids, null, "year"); }, + selectSet(key) { + const ids = params + .network() + .pieces.filter((p) => setKey(p) === key) + .map((p) => p.id); + sel.pieces(ids, null, "set"); + }, + isEnd: (node) => !!endOf(node), + startJoin, + joinTo, + joining: () => joinFrom, selectAll: sel.all, dropSelected, dropNode, @@ -552,6 +619,10 @@ export function createPlanEdit(params: PlanEditParams): PlanEdit { splitAt, hit, key(event) { + if (joinFrom && event.key === "Escape") { + joinFrom = null; + return true; + } if (!sel.get() || !params.enabled()) return false; if (keyIs(event, "plan.delete") && params.editable()) { dropSelected(); diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Features.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Features.ts index 1a91999f8..50cd94bb2 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Features.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Features.ts @@ -20,6 +20,8 @@ export type PlanFeature = | "road-upload" | "branch" | "years" + | "set" + | "join" | "points" | "plan-work"; @@ -77,10 +79,22 @@ export const PLAN_FEATURES: readonly PlanFeatureRow[] = [ }, { key: "years", - name: "연도 구역 마커 · 구역 · 연도 고침 · 선 이름", + name: "구간 나눔 마커 · 구역 · 연도 고침 · 선 이름", pages: { d03: "use", b05: "view" }, why: "받은 몫 고정", }, + { + key: "set", + name: "노선 세트(연도-번호) 칸 · 세트 줄 · 이름표", + pages: { d03: "use", b05: "view" }, + why: "받은 몫 고정", + }, + { + key: "join", + name: "[잇기] 두 선 끝 이음", + pages: { d03: "use", b05: "hide" }, + why: "B 계획선 한 줄", + }, { key: "points", name: "지점 칸 · 핀 · 지점에서 긋기", diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Kit.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Kit.ts index fe9d3c113..d4ad580b0 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Kit.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Kit.ts @@ -47,8 +47,10 @@ export interface PlanKitParams { idle?: () => boolean; onMode: () => void; onSelect: () => void; - /** 사업연도 시작(연도 고름). */ + /** 사업연도 시작(새 세트 고름 · 새 선 처음 해). */ yearStart?: () => number; + /** 마지막 서버 세트 셈(왼쪽 세트 줄 덩이 수 · 고친 뒤 = null). */ + sets?: () => ReadonlyArray<{ key: string; parts: number }> | null; points?: PlanKitPoints; /** 페이지 키(되돌리기 · 다시하기 · 재기 끔) — 긋기 · 고르기보다 먼저. */ onKey?: (event: KeyboardEvent) => boolean; @@ -99,6 +101,7 @@ export function createPlanKit(params: PlanKitParams): PlanKit { commitPoints, showPoints: (list, next) => (points ? points.show(list, next) : params.show(next)), enabled: idle, + year: () => params.yearStart?.() ?? new Date().getFullYear(), editable, onSelect: params.onSelect, }); @@ -119,7 +122,15 @@ export function createPlanKit(params: PlanKitParams): PlanKit { respan: draw.respan, }); const pieces = featureShown(page, "years") - ? createPlanPieces({ network, edit, editable, yearOptions: props.yearOptions, commit }) + ? createPlanPieces({ + network, + edit, + editable, + setOptions: props.setOptions, + assign: props.assign, + commit, + sets: params.sets, + }) : null; const marks = points ? createPlanPoints({ diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts index e88ce68dd..088abdd8b 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts @@ -230,9 +230,9 @@ export function createPlanMap(events: PlanMapEvents): PlanMap { "Shift+누름 = 선 더함 · 뺌", "빈 곳 오른쪽으로 끌기 = 창 안 선 · 왼쪽으로 = 걸친 선까지", "고른 선 위 끌기 = 꺾임점 더해 옮김 · 노드 끌기 = 옮김", - "그리기 칸 「연도 구역 마커」 선 위로 끌어 놓기 = 구역 나눔", + "그리기 칸 「구간 나눔」 선 위로 끌어 놓기 = 구간 나눔 · 속성 판 「세트」 = 고른 범위 세트 바꿈", "마크 끌기 = 옮김 · 선 밖으로 = 지움(앞 구간과 합침)", - "오른쪽 누름 = 메뉴(긋기 · 지점 · 노드 지움 · 연도 구역 나눔)", + "오른쪽 누름 = 메뉴(긋기 · 지점 · 노드 지움 · 구간 나눔 · 선 끝 잇기)", "Delete = 고른 노드 · 선 · 구역 · 전체 · 마크 지움", "가운데 끌기 = 옮기기", "휠 = 확대 · 축소", diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Menu.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Menu.ts index 3a0a716b0..7d247a210 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Menu.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Menu.ts @@ -2,7 +2,8 @@ * D03_PlanAnalysis_UI_Menu.ts * D03 지도 우클릭 메뉴(PLAN 591-2 · 598-2 · 요구 463 16 · 22) — 부품 상자 `createMapContextMenu` 하나 · 누른 자리마다 항목. * 긋는 중 = 긋기 끝 · 핀 = 그 지점에서 긋기 · 지우기 · 마커 = 마커 지움 · - * 노드 = 노드 지움 · 지점으로 · 이어 긋기 / 갈래 시작 · 선 위 = 갈래 시작 · 여기서 연도 구역 나눔 · 다시 긋기(599) · + * 노드 = 노드 지움 · 지점으로 · 이어 긋기 / 갈래 시작 · 선 끝 = 잇기 · 여기에 잇기(638-3) · + * 선 위 = 갈래 시작 · 여기서 구간 나눔 · 다시 긋기(599) · * 빈 땅 = 여기서 긋기 시작 · 핀 · 노드 밖 = 지점 놓기(노드 둘레면 그 노드에 붙음). * 우클릭 즉시 지움 없음 — 지움은 메뉴 · 고르고 Delete. B05 우클릭(`bindNodeSplice`)과 코드 안 나눔. * 겹 = 맨 끝(맨 위) · 오른쪽 누름만 받음. @@ -82,13 +83,19 @@ export function createPlanMenu(params: PlanMenuParams): PlanMenu { const [sx, sy] = map.toScreen(point); start(sx, sy); items.push("-", ["노드 지움", () => edit.dropNode(node)]); + if (featureOn(page, "join") && edit.isEnd(node)) + items.push( + edit.joining() + ? ["여기에 잇기", () => edit.joinTo(node)] + : ["잇기 — 다른 선 끝 고름", () => edit.startJoin(node)], + ); 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(["여기서 구간 나눔", () => 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()) diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts index 91bc5c79f..e2f9d9e6a 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts @@ -79,6 +79,7 @@ import { type PlanNetwork, type Vertex, } from "./logic/D03_PlanAnalysis_Plan"; +import { setWarnings } from "./logic/D03_PlanAnalysis_Group"; import { tmParams, tmToLonLat } from "./logic/D03_PlanAnalysis_Tm"; /** 되돌리기 쌓는 수. */ @@ -108,6 +109,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { let epsg = 0; let yearStart = new Date().getFullYear(); // 새 선 처음 해 let network: PlanNetwork = emptyNetwork(); + let derived: PlanNetworkView["derived"] = null; // 마지막 서버 셈(세트 덩이 · 끊김 경고 638-3) let savedJson = JSON.stringify({ network, points, pointNames, site }); // 못 읽은 채 떠남 = 고침 없음 let undoStack: Snap[] = []; let redoStack: Snap[] = []; @@ -171,6 +173,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { }, onSelect: () => refresh(), yearStart: () => yearStart, + sets: () => (dirty() ? null : (derived?.sets ?? null)), points: { list: () => points, names: () => pointNames, @@ -460,6 +463,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { function takeServer(view: PlanNetworkView): void { if (view.year_start) yearStart = view.year_start; const server = view.network; + derived = view.derived; network = server && server.lines.length ? fromServer(server.lines, server.marks, server.pieces, yearStart) @@ -490,6 +494,8 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { redoStack = []; refresh(); showToast("예정노선을 저장했습니다.", "success"); + const broken = setWarnings(derived?.sets ?? [], derived?.pieces ?? []); + if (broken.length) showToast(`끊긴 세트 — ${broken.join(" / ")}(저장은 됨)`, "warning", 8000); return true; } catch (error) { showToast(`저장하지 못했습니다 — ${errorText(error)}`, "error"); diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Pieces.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Pieces.ts index c0824ac84..bdb4da507 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Pieces.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Pieces.ts @@ -1,9 +1,10 @@ /* ============================================================================= * D03_PlanAnalysis_UI_Pieces.ts * D03 좌측 「구역 · 연도」 목록 몸(PLAN 597 · 요구 463 14) — 구역 = 그은 꺾은선 하나(본선 · 갈래). - * 해 줄(같은 해) · 구역 줄(색 띠 = 지도 선과 같은 `routeColor(차례)` · 이름 칸 · 연도 고름 · 지움) · - * 구간 줄(측점 글 작게 · 연도 고름). 누름 = 고름 · 해 고침 · 이름 · 지움은 한 걸음(되돌리기 · 캐시 = 페이지). - * 연도 고름은 속성 판과 같은 셈(`setLineYear` · `setPiecesYear`) · 보기만 계정 = 값만. + * 해 줄(그 해 모두) · 그 밑 세트 줄(「2027-1」 · 길이 · 구간 수 · 덩이 수 = 마지막 서버 셈 · 같은 세트 고름) · + * 구역 줄(색 띠 = 지도 선과 같은 `routeColor(차례)` · 이름 칸 · 세트 고름 · 지움) · + * 구간 줄(측점 글 작게 · 세트 고름). 누름 = 고름 · 세트 고침 · 이름 · 지움은 한 걸음(되돌리기 · 캐시 = 페이지). + * 세트 고름은 속성 판과 같은 셈(`props.assign` = `assignSet` · 638-3) · 보기만 계정 = 값만. * ========================================================================== */ import { createIconButton } from "@ui/ui_template_button"; @@ -13,13 +14,8 @@ 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, - nameError, - setLineYear, - setPiecesYear, - type PlanNetwork, -} from "./logic/D03_PlanAnalysis_Plan"; +import { setKey, type LineRange } from "./logic/D03_PlanAnalysis_Group"; +import { lineNames, nameError, 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"; @@ -31,9 +27,12 @@ export interface PlanPiecesParams { network: () => PlanNetwork; edit: PlanEdit; editable: () => boolean; - /** 고를 해 — 사업연도 시작부터 열 해 + 조각에 있는 해. */ - yearOptions: () => Array<{ value: string; text: string }>; + /** 고를 세트(속성 판과 같음) · 범위를 그 세트로(한 걸음). */ + setOptions: () => Array<{ value: string; text: string }>; + assign: (ranges: LineRange[], key: string) => void; commit: (next: PlanNetwork) => void; + /** 마지막 서버 세트 셈(덩이 수 · 고친 뒤 = null). */ + sets?: () => ReadonlyArray<{ key: string; parts: number }> | null; } export interface PlanPieces { @@ -63,25 +62,29 @@ export function createPlanPieces(params: PlanPiecesParams): PlanPieces { return row; }; - /** 연도 고름 칸 — 값이 섞이면 「여러 해」. */ - function yearSelect( - years: () => number[], + /** 세트 고름 칸 — 범위 구간들 세트 · 섞이면 「여러 세트」. */ + function setSelect( + pieces: () => PlanNetwork["pieces"], 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)), + onChange: (value) => + value && + params.assign( + pieces().map(({ line, from_m, to_m }) => ({ line, from_m, to_m })), + value, + ), }); const sync = (): void => { - const set = new Set(years()); - const options = params.yearOptions(); + const keys = new Set(pieces().map(setKey)); + const options = params.setOptions(); field.setOptions( - set.size > 1 ? [{ value: "", text: "여러 해" }, ...options] : options, - set.size > 1 ? "" : String([...set][0]), + keys.size > 1 ? [{ value: "", text: "여러 세트" }, ...options] : options, + keys.size > 1 ? "" : String([...keys][0]), ); }; sync(); @@ -94,13 +97,16 @@ export function createPlanPieces(params: PlanPiecesParams): PlanPieces { const picked = new Set(edit.selected()?.pieces ?? []); const scope = edit.selected()?.scope; const editable = params.editable(); + const parts = new Map((params.sets?.() ?? []).map((s) => [s.key, s.parts])); + // 구간 꼴 · 세트 · 덩이까지 열쇠 — 되돌리기 뒤 길이 · 세트 글이 옛 값으로 남지 않게 const key = JSON.stringify([ - network.lines.map((line) => line.id), - network.pieces.map((piece) => [piece.id, piece.line]), + network.lines.map((line) => [line.id, lineLength(line.vertices).toFixed(1)]), + network.pieces.map((p) => [p.id, p.line, p.from_m.toFixed(1), p.to_m.toFixed(1), setKey(p)]), [...picked], scope, editable, - params.yearOptions().length, + params.setOptions().length, + [...parts], ]); if (key === shown) { syncs.forEach((sync) => sync()); @@ -110,14 +116,19 @@ export function createPlanPieces(params: PlanPiecesParams): PlanPieces { syncs = []; const rows: HTMLElement[] = []; - const byYear = new Map(); - for (const piece of network.pieces) - byYear.set(piece.year, [...(byYear.get(piece.year) ?? []), piece]); + const total = (pieces: typeof network.pieces): string => + meters(pieces.reduce((sum, p) => sum + p.to_m - p.from_m, 0)); + const group = (pieces: typeof network.pieces, of: (p: (typeof pieces)[number]) => K) => { + const out = new Map(); + for (const piece of pieces) out.set(of(piece), [...(out.get(of(piece)) ?? []), piece]); + return out; + }; + const byYear = group(network.pieces, (p) => p.year); for (const [year, pieces] of [...byYear].sort(([a], [b]) => a - b)) { - const length = pieces.reduce((sum, p) => sum + p.to_m - p.from_m, 0); + const bySet = group(pieces, (p) => p.set); const row = createListRow({ label: `${year}년`, - sub: `구간 ${pieces.length} · ${meters(length)}`, + sub: `세트 ${bySet.size} · ${total(pieces)}`, size: 28, active: scope === "year" && pieces.every((p) => picked.has(p.id)), onClick: () => { @@ -127,6 +138,23 @@ export function createPlanPieces(params: PlanPiecesParams): PlanPieces { }); row.dataset.year = String(year); rows.push(row); + for (const [, own] of [...bySet].sort(([a], [b]) => a - b)) { + const name = setKey(own[0]); + const lumps = parts.get(name); + const setRow = createListRow({ + label: name, + sub: `${total(own)} · 구간 ${own.length}${lumps ? ` · 덩이 ${lumps}` : ""}`, + size: 28, + active: scope === "set" && own.every((p) => picked.has(p.id)), + onClick: () => { + edit.select([own[0].id]); + edit.setScope("set"); + }, + }); + setRow.classList.add("d03-plan__piece-row"); + setRow.dataset.set = name; + rows.push(setRow); + } } network.lines.forEach((line, index) => { @@ -153,12 +181,8 @@ export function createPlanPieces(params: PlanPiecesParams): PlanPieces { 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 year = setSelect(piecesNow, "구역 세트"); + year.root.dataset.role = "line-set"; const drop = createIconButton({ icon: "trash", title: "구역 지움", @@ -208,11 +232,7 @@ export function createPlanPieces(params: PlanPiecesParams): PlanPieces { for (const piece of pieces) { 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)), - ); + const pieceYear = setSelect(() => [pieceNow()], "구간 세트"); const row = createListRow({ label: `${station(piece.from_m)} ~ ${station(piece.to_m)}`, sub: meters(piece.to_m - piece.from_m), diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Props.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Props.ts index 62bdca065..eb238d456 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Props.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Props.ts @@ -3,12 +3,14 @@ * D03 고름 속성 판(PLAN 585-2 · 598 · 요구 463 13·16 · 11) — 오토캐드 특성 창 꼴 · 판 하나가 고름 꼴마다 몸을 바꿈. * * 판 = 지도 위 떠 있는 판(공용 `createFloatPanel` · 머리 끌어 옮김 · 오른쪽 위) · 고름 있을 때만 · ✕ = 풂. - * 노드 — 경위도 · 지반고 · 측점 · 앞 선 길이 · 기울기 · [노드 지움] [지점으로](그 자리에 지점 · 이미 있으면 숨김). - * 선(토막) — 측점 · 길이 · 지반 기울기 · 든 연도 · [다시 긋기](하나 · 599) [선 지움](가운데 = 구역 둘). - * 구역 — 길이 · 노드 수 · 연도(구역 모든 구간) · 구간 줄 · [구역 지움]. + * 노드 — 경위도 · 지반고 · 측점 · 앞 선 길이 · 기울기 · [노드 지움] [지점으로](그 자리에 지점 · 이미 있으면 숨김) · + * [잇기](선 끝 · 638-3). + * 선(토막) — 측점 · 길이 · 지반 기울기 · 세트 · [다시 긋기](하나 · 599) [선 지움](가운데 = 구역 둘). + * 구역 — 길이 · 노드 수 · 세트(구역 모든 구간) · 구간 줄 · [구역 지움]. * 전체 — 길이 · 구역 수 · 해마다 길이 · [전체 지움](되돌리기 됨). - * 연도 구간(왼쪽 해 · 구간 줄) — 연도(모두 같으면 그 해 · 다르면 「여러 해」) · 구간 줄 · 마커 — 앞뒤 해 · [마커 지움]. - * 연도 고름 = 고른 구간 모두 그 해 = 되돌리기 한 걸음 · 보기만 계정 = 값만. + * 세트 구간(왼쪽 해 · 세트 · 구간 줄) — 세트(모두 같으면 그 세트 · 다르면 「여러 세트」) · 구간 줄 · 마커 — 앞뒤 세트 · [마커 지움]. + * 세트 고름(638-3 · 요구 547) = 있는 세트 + 해마다 「새 세트」(`setChoices`) → `assignSet`(고른 범위 끝에서 나눔 → + * 세트 바꿈 → 이웃 같은 세트 하나로) = 되돌리기 한 걸음 · 보기만 계정 = 값만. * 좌측 「구역 · 연도」 목록 = `UI_Pieces.ts`(페이지가 붙임). * ========================================================================== */ @@ -18,10 +20,10 @@ import { createSelectField } from "@ui/ui_template_input"; import { createKv, type KvRow } from "@ui/ui_template_kv"; import { stationLabel } from "../common_util/common_util_station"; import { groundSlopePct, lineLength, type HeightAt } from "./logic/D03_PlanAnalysis_Draw"; +import { assignSet, setChoices, setKey, type LineRange } from "./logic/D03_PlanAnalysis_Group"; import { lineNames, samePoint, - setPiecesYear, type PlanNetwork, type PlanPiece, type Vertex, @@ -45,7 +47,7 @@ import { PLAN_STATION_INTERVAL_M } from "./D03_PlanAnalysis_UI_Map"; import { selectionLength } from "./D03_PlanAnalysis_UI_Select"; import "./D03_PlanAnalysis_UI_DrawParts.css"; -/** 조각 연도 고를 수 — 사업연도 시작부터. */ +/** 새 세트 고를 해 — 사업연도 시작부터. */ const YEAR_SPAN = 10; const station = (m: number): string => stationLabel(m, PLAN_STATION_INTERVAL_M, 1); @@ -78,8 +80,10 @@ export interface PlanProps { panel: HTMLElement; /** 고름 · 꼴이 바뀜 — 판 다시. */ render: () => void; - /** 고를 해 — 사업연도 시작부터 열 해 + 조각에 있는 해. */ - yearOptions: () => Array<{ value: string; text: string }>; + /** 고를 세트 — 있는 세트 + 해마다 「새 세트」(값 = 세트 열쇠). */ + setOptions: () => Array<{ value: string; text: string }>; + /** 이 범위를 고른 세트로(되돌리기 한 걸음). */ + assign: (ranges: LineRange[], key: string) => void; } export function createPlanProps(params: PlanPropsParams): PlanProps { @@ -97,11 +101,19 @@ export function createPlanProps(params: PlanPropsParams): PlanProps { float.root.dataset.role = "plan-props"; float.root.hidden = true; - function yearOptions(): Array<{ value: string; text: string }> { + /** 고를 세트 — 사업연도 시작부터 열 해(새 세트) + 있는 세트. */ + function choices(): ReturnType { const start = params.yearStart?.() ?? new Date().getFullYear(); - const years = new Set(Array.from({ length: YEAR_SPAN }, (_, i) => start + i)); - params.network().pieces.forEach((piece) => years.add(piece.year)); - return [...years].sort().map((year) => ({ value: String(year), text: `${year}년` })); + const years = Array.from({ length: YEAR_SPAN }, (_, i) => start + i); + return setChoices(params.network(), years); + } + + const setOptions = (): Array<{ value: string; text: string }> => + choices().map((c) => ({ value: c.key, text: c.fresh ? `${c.year} 새 세트` : c.key })); + + function assign(ranges: LineRange[], key: string): void { + const target = choices().find((c) => c.key === key); + if (target && ranges.length) params.commit(assignSet(params.network(), ranges, target)); } const button = (label: string, role: string, onClick: () => void): HTMLButtonElement => { @@ -110,25 +122,26 @@ export function createPlanProps(params: PlanPropsParams): PlanProps { return b; }; - /** 연도 고름 — 구간 모두 그 해(되돌리기 한 걸음). */ - function yearField(pieces: PlanPiece[]): HTMLElement { - const years = new Set(pieces.map((piece) => piece.year)); - const options = yearOptions(); - const ids = pieces.map((piece) => piece.id); - const year = createSelectField({ - label: "연도", - options: years.size > 1 ? [{ value: "", text: "여러 해" }, ...options] : options, - value: years.size > 1 ? "" : String(pieces[0]?.year ?? ""), + /** 세트 고름 — 고른 범위 모두 그 세트(되돌리기 한 걸음) · 범위 = 토막 · 구간. */ + function setField(pieces: PlanPiece[], ranges: LineRange[]): HTMLElement { + const keys = new Set(pieces.map(setKey)); + const options = setOptions(); + const field = createSelectField({ + label: "세트", + options: keys.size > 1 ? [{ value: "", text: "여러 세트" }, ...options] : options, + value: keys.size > 1 ? "" : ([...keys][0] ?? ""), size: 24, - ariaLabel: "고른 구간 연도", - disabled: !params.editable() || !featureOn(page, "years"), - onChange: (value) => - value && params.commit(setPiecesYear(params.network(), ids, Number(value))), + ariaLabel: "고른 구간 세트", + disabled: !params.editable() || !featureOn(page, "set"), + onChange: (value) => value && assign(ranges, value), }); - year.select.dataset.role = "props-year"; - return year.root; + field.select.dataset.role = "props-set"; + return field.root; } + const rangesOf = (pieces: PlanPiece[]): LineRange[] => + pieces.map(({ line, from_m, to_m }) => ({ line, from_m, to_m })); + /** 구간 줄 — 누름 = 그 구간만. */ function pieceRows(network: PlanNetwork, pieces: PlanPiece[]): HTMLElement { const names = lineNames(network.lines); @@ -138,7 +151,7 @@ export function createPlanProps(params: PlanPropsParams): PlanProps { const index = network.lines.findIndex((line) => line.id === piece.line); const row = button( `${names[index]} ${station(piece.from_m)}~${station(piece.to_m)} · ` + - `${meters(piece.to_m - piece.from_m)} · ${piece.year}년`, + `${meters(piece.to_m - piece.from_m)} · ${setKey(piece)}`, "props-piece", () => edit.select([piece.id]), ); @@ -227,19 +240,21 @@ export function createPlanProps(params: PlanPropsParams): PlanProps { actions: [ button("노드 지움", "props-drop-node", () => edit.dropNode(picked.node!)), toPoint, + ...(featureOn(page, "join") && edit.isEnd(picked.node!) + ? [button("잇기", "props-join", () => edit.startJoin(picked.node!))] + : []), ], }; } if (picked.kind === "segment") { const single = picked.segments.length === 1 ? picked.segments[0] : null; - const years = [ - ...new Set( - segmentPieces(network, picked.segments).map( - (id) => network.pieces.find((p) => p.id === id)?.year, - ), - ), - ].sort(); + const ids = segmentPieces(network, picked.segments); + const inSegments = network.pieces.filter((p) => ids.includes(p.id)); + const ranges = picked.segments.flatMap((seg) => { + const range = segmentRange(network, seg); + return range ? [{ line: seg.line, from_m: range[0], to_m: range[1] }] : []; + }); const rows: KvRow[] = [{ label: "길이", value: meters(length) }]; if (single) { const [from, to] = segmentRange(network, single)!; @@ -247,8 +262,8 @@ export function createPlanProps(params: PlanPropsParams): PlanProps { rows.unshift({ label: "측점", value: `${station(from)} ~ ${station(to)}` }); rows.push({ label: "지반 기울기", value: percent(groundSlopePct(a, b, z)) }); } - if (yearsShown(network)) - rows.push({ label: "연도", value: years.map((y) => `${y}년`).join(" · ") }); + 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( @@ -260,7 +275,7 @@ export function createPlanProps(params: PlanPropsParams): PlanProps { title: single ? `${nameOf(single.line)} · 선 ${single.index + 1}` : `선 ${picked.segments.length}개`, - parts: [createKv(rows)], + parts, actions, }; } @@ -274,7 +289,9 @@ export function createPlanProps(params: PlanPropsParams): PlanProps { { label: "길이", value: meters(length) }, { label: "노드", value: `${vertices.length}개` }, ]), - ...(yearsShown(network) ? [yearField(pieces), pieceRows(network, pieces)] : []), + ...(yearsShown(network) + ? [setField(pieces, rangesOf(pieces)), pieceRows(network, pieces)] + : []), ], actions: [button("구역 지움", "props-drop-line", () => edit.dropRegion(picked.line!))], }; @@ -302,7 +319,7 @@ export function createPlanProps(params: PlanPropsParams): PlanProps { // 연도 구간 · 마커 const lineIds = new Set(pieces.map((piece) => piece.line)); const total = pieces.reduce((sum, piece) => sum + piece.to_m - piece.from_m, 0); - const parts: HTMLElement[] = [yearField(pieces), pieceRows(network, pieces)]; + const parts: HTMLElement[] = [setField(pieces, rangesOf(pieces)), pieceRows(network, pieces)]; const actions: HTMLButtonElement[] = []; const mark = network.pieces.find((p) => p.id === picked.mark); if (mark) { @@ -312,7 +329,7 @@ export function createPlanProps(params: PlanPropsParams): PlanProps { parts.unshift( createKv([ { label: "측점", value: station(mark.from_m) }, - { label: "앞 · 뒤 해", value: `${before?.year ?? "-"}년 · ${mark.year}년` }, + { label: "앞 · 뒤 세트", value: `${before ? setKey(before) : "-"} · ${setKey(mark)}` }, ]), ); actions.push(button("마커 지움", "props-drop-mark", edit.dropMark)); @@ -343,6 +360,7 @@ export function createPlanProps(params: PlanPropsParams): PlanProps { render() { renderPanel(); }, - yearOptions, + setOptions, + assign, }; } diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Select.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Select.ts index 282f6cc13..ba9edf6bc 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Select.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Select.ts @@ -2,7 +2,7 @@ * D03_PlanAnalysis_UI_Select.ts * D03 고름 상태(PLAN 598-1 · 요구 463 9 · 13) — 노드 → 선(토막) → 구역 → 전체 · 연도 구간(왼쪽 해 줄) · 마커. * 넓힘 = 같은 것을 다시 누름 — 씨(처음 누른 노드 · 토막)에서 한 단 위 · 전체 다음 = 씨로 돌아옴. - * 「같은 해」 = 왼쪽 목록 해 줄로만(`year`). + * 「같은 세트」 = 왼쪽 목록 세트 줄로만(`set`) · 해 줄 = 그 해 모두(`year`). * 상태 = 화면 기억(저장 안 함) · 꼴이 바뀌면 `sync` 가 사라진 것을 뺌. * ========================================================================== */ @@ -19,8 +19,8 @@ import { } from "./logic/D03_PlanAnalysis_Select"; export type PlanSelectKind = "node" | "segment" | "line" | "all" | "pieces" | "mark"; -/** 왼쪽 목록 밝힘 꼴(구간 · 선 · 같은 해). */ -export type PlanScope = "piece" | "line" | "year"; +/** 왼쪽 목록 밝힘 꼴(구간 · 선 · 같은 해 · 같은 세트). */ +export type PlanScope = "piece" | "line" | "year" | "set"; export interface PlanSelection { kind: PlanSelectKind; diff --git a/D03_PlanAnalysis/logic/D03_PlanAnalysis_Group.ts b/D03_PlanAnalysis/logic/D03_PlanAnalysis_Group.ts index 511dc8436..375c0888d 100644 --- a/D03_PlanAnalysis/logic/D03_PlanAnalysis_Group.ts +++ b/D03_PlanAnalysis/logic/D03_PlanAnalysis_Group.ts @@ -5,9 +5,11 @@ * [잇기](두 선 끝 · 닿으면 바로 · 떨어지면 곧은 토막 덧 · 이은 토막 = 앞 선 세트). * 새 선 처음 세트 · 갈래 · 이어 긋기 = `D03_PlanAnalysis_Plan.normalizePieces`. * ⚠ 짝 = `D03_PlanAnalysis_Network.derive` 세트 합침(서버 [저장] 정본) — 거울 시험 638(`fixtures/d03_638_set_cases.json`). - * 끊김 · 갈래 경고 셈은 서버 한 곳(여기 없음). DOM · fetch · sessionStorage 없음. + * 끊김 · 갈래 판정은 서버 한 곳(`derived.sets` parts · branch) — 여기는 그 경고 글 · 빈틈 숫자만(`setWarnings`). + * DOM · fetch · sessionStorage 없음. * ========================================================================== */ +import { segmentLength } from "./D03_PlanAnalysis_Draw"; import { appendVertex, attachHead, @@ -18,6 +20,7 @@ import { splitPiece, type PlanNetwork, type PlanPiece, + type Vertex, } from "./D03_PlanAnalysis_Plan"; /** 세트 — 연도 · 그 해 번호(1 부터). */ @@ -167,3 +170,47 @@ export function joinEnds( const joined = joinAt(next, x, y) ?? joinAt(next, y, x); return joined ? normalizePieces(joined, year) : "두 선을 이을 수 없습니다 — 선 끝을 고르세요."; } + +/** 세트 조각 꺾은선 사이 가장 짧은 빈틈(m) — 끝이 다른 조각 꼭짓점에 닿으면 한 덩이(서버 `_set_shape` 닿음) · + * 다른 덩이 끝끼리 가장 가까운 거리 · 덩이 하나 = 0. */ +export function setGapM(lines: ReadonlyArray>): number { + const root = lines.map((_, i) => i); + const find = (i: number): number => (root[i] === i ? i : (root[i] = find(root[i]))); + const ends = (line: ReadonlyArray): Vertex[] => [line[0], line[line.length - 1]]; + lines.forEach((a, i) => + lines.forEach((b, j) => { + const touch = ends(a).some((end) => b.some((p) => segmentLength(end, p) <= TOUCH_M)); + if (i !== j && touch) root[find(i)] = find(j); + }), + ); + let gap = Infinity; + lines.forEach((a, i) => + lines.forEach((b, j) => { + if (find(i) === find(j)) return; + for (const p of ends(a)) for (const q of ends(b)) gap = Math.min(gap, segmentLength(p, q)); + }), + ); + return Number.isFinite(gap) ? gap : 0; +} + +/** [저장] 응답 세트 → 경고 글 — 덩이 2 이상 「2027-1 이 2 덩이 · 빈틈 35.0 m」 · 갈래 「2027-1 안에 갈래」. */ +export function setWarnings( + sets: ReadonlyArray<{ + key: string; + parts: number; + branch: boolean; + pieces: ReadonlyArray; + }>, + pieces: ReadonlyArray<{ id: string; planned: ReadonlyArray }>, +): string[] { + const out: string[] = []; + for (const s of sets) { + if (s.parts > 1) { + const lines = pieces.filter((p) => s.pieces.includes(p.id) && p.planned.length > 1); + const gap = setGapM(lines.map((p) => p.planned)); + out.push(`${s.key} 이 ${s.parts} 덩이 · 빈틈 ${gap.toFixed(1)} m`); + } + if (s.branch) out.push(`${s.key} 안에 갈래`); + } + return out; +} diff --git a/D04_PlanConfirm/D04_PlanConfirm_UI_Page.ts b/D04_PlanConfirm/D04_PlanConfirm_UI_Page.ts index 7af2d942e..83d97cd86 100644 --- a/D04_PlanConfirm/D04_PlanConfirm_UI_Page.ts +++ b/D04_PlanConfirm/D04_PlanConfirm_UI_Page.ts @@ -1,7 +1,8 @@ /* ============================================================================= * D04_PlanConfirm_UI_Page.ts * D04 확정 · 업체 · 내보내기(요구서 345 3-4 · 요구 391 · PLAN 531-3 · 563-2) — B05~B09 틀(group_workflow). - * 본문 = 조각 표(연도 · 선 · 연장 · 시점 · 종점 · 측점 · 확정 · 지정 업체) · 조각마다 업체 여럿 + * 본문 = 세트 표(세트 「2027-1」 · 선 · 연장 · 시점 · 종점 · 측점 · 확정 · 지정 업체) · 세트마다 업체 여럿 + * (끊긴 · 갈래 세트 = 줄에 「2 덩이」 · 「갈래」 + 확정 막힘 알림 · 서버 [확정]이 막음 · 638-3) * (조각 없는 옛 프로젝트 = 연도 한 줄 · 옛 연도 지정 업체는 그 연도 첫 줄에 보임) * (등록 회사 찾기 = 가입 화면 회사 검색 · 아니면 업체명 + 메일 수기). * 좌측 = 확정할 연도 고르기 + [내보내기] [확정] [저장]. @@ -53,12 +54,21 @@ interface YearRow { end?: EndPoint; } -/** 표 한 줄 — 조각(piece_id) · 옛 연도(piece_id null) */ +/** 표 한 줄 — 세트(piece_id = 세트 열쇠 · label 「2027-1」) · 옛 연도(piece_id null) */ interface PieceRow extends YearRow { piece_id: string | null; line_name: string | null; + label?: string | null; + parts?: number; + branch?: boolean; } +/** 끊긴 · 갈래 세트 까닭 — 「2 덩이」 · 「갈래」(없으면 빈 글). */ +const brokenText = (row: PieceRow): string => + [(row.parts ?? 1) > 1 ? `${row.parts} ${t("D04_Parts")}` : "", row.branch ? t("D04_Branch") : ""] + .filter(Boolean) + .join(" · "); + interface Assignee { year: number; piece_id?: string | null; @@ -269,7 +279,7 @@ export async function renderD04PlanConfirm(root: HTMLElement): Promise { ? `${station(row.start_chainage_m)} ~ ${station(row.end_chainage_m)}` : t("D04_NotSaved"); const tr = tableRow(columns, [ - row.line_name ? `${row.year} · ${row.line_name}` : String(row.year), + [row.label ?? row.year, row.line_name, brokenText(row)].filter(Boolean).join(" · "), fmt(row.length_m), where(row.start), where(row.end), @@ -283,6 +293,13 @@ export async function renderD04PlanConfirm(root: HTMLElement): Promise { } const notes = draft() ? [createBanner({ tone: "warning", text: t("D04_Dirty") })] : []; notes[0]?.classList.add("d04-confirm__dirty"); + const broken = summary.pieces + .filter((row) => brokenText(row)) + .map((row) => `${row.label ?? row.year} ${brokenText(row)}`); + if (broken.length) + notes.push( + createBanner({ tone: "warning", text: `${t("D04_Broken")} ${broken.join(" · ")}` }), + ); main.replaceChildren(el("h2", { text: summary.name }), ...notes, tableRoot); pickList.replaceChildren( diff --git a/resources/tester/test_626_3_expected_edit.py b/resources/tester/test_626_3_expected_edit.py index c5e595509..d384c9279 100644 --- a/resources/tester/test_626_3_expected_edit.py +++ b/resources/tester/test_626_3_expected_edit.py @@ -99,7 +99,20 @@ def test_D03_긋기_고르기_메뉴_속성판_그대로_한_선(): for src in (draw, menu, props): assert "oneLine" not in src table = _src(D03 / "D03_PlanAnalysis_UI_Features.ts") - for key, b05 in (("branch", "hide"), ("years", "view"), ("points", "hide"), ("draw", "use")): + # 638-3 — 세트 칸 · 세트 줄 = B05 보기만 · [잇기] = B05 숨김 · 세트 고침 = assignSet 한 길 + assert 'disabled: !params.editable() || !featureOn(page, "set")' in props + assert 'featureOn(page, "join") && edit.isEnd(' in props + menu + assert "setPiecesYear" not in props and "setLineYear" not in _src( + D03 / "D03_PlanAnalysis_UI_Pieces.ts" + ) + for key, b05 in ( + ("branch", "hide"), + ("years", "view"), + ("set", "view"), + ("join", "hide"), + ("points", "hide"), + ("draw", "use"), + ): assert re.search(rf'key: "{key}",[^}}]*b05: "{b05}"', table, re.S), key tools = _src(ROOT / "A00_Common" / "map" / "map_toolbar.ts") # 634-2 — 표 줄에 호버제목(title) · 걷음은 635-3 diff --git a/resources/tester/test_638_group.py b/resources/tester/test_638_group.py index 05477b724..d0c071b6c 100644 --- a/resources/tester/test_638_group.py +++ b/resources/tester/test_638_group.py @@ -127,6 +127,20 @@ out.joinBranchTail = view(group.joinEnds( { ...withBranch, lines: [...withBranch.lines, line("line-2", [[50, 40], [50, 90]])], pieces: [...withBranch.pieces, piece("p9", "line-2", 0, 50, 2029, 1)] }, { line: "branch-1", head: false }, { line: "line-2", head: true }, Y)); +// 저장 뒤 경고(638-3) — 빈틈 = 다른 덩이 끝끼리 가장 짧은 거리 · 닿은 조각 = 한 덩이 +const planned = [ + { id: "a", planned: [[0, 0], [50, 0]] }, + { id: "b", planned: [[50, 0], [80, 0]] }, + { id: "c", planned: [[115, 0], [200, 0]] }, + { id: "d", planned: [[0, 10], [0, 60]] }, +]; +out.gapOne = group.setGapM([planned[0].planned, planned[1].planned]); +out.gapTwo = group.setGapM(planned.slice(0, 3).map((p) => p.planned)); +out.warnings = group.setWarnings([ + { key: "2027-1", parts: 2, branch: false, pieces: ["a", "b", "c"] }, + { key: "2027-2", parts: 1, branch: true, pieces: ["d"] }, + { key: "2028-1", parts: 1, branch: false, pieces: ["d"] }, +], planned); writeFileSync(process.argv[2], JSON.stringify(out)); """ @@ -296,3 +310,10 @@ def test_잇기_못_함_까닭_글_갈래_꼬리는_됨(r): (2028, 4), (2029, 1), ] + + +def test_저장_뒤_경고_빈틈(r): + """638-3 — 닿은 조각 = 한 덩이(빈틈 0) · 덩이 둘 = 끝끼리 가장 짧은 거리 · 갈래 = 「안에 갈래」 · 멀쩡한 세트 = 글 없음.""" + assert r["gapOne"] == 0 + assert r["gapTwo"] == pytest.approx(35) + assert r["warnings"] == ["2027-1 이 2 덩이 · 빈틈 35.0 m", "2027-2 안에 갈래"] diff --git a/ui_template/ui_template_locale_de.ts b/ui_template/ui_template_locale_de.ts index 1f6689cbe..6be7b1d44 100644 --- a/ui_template/ui_template_locale_de.ts +++ b/ui_template/ui_template_locale_de.ts @@ -21,7 +21,13 @@ export const ui_locales_de = { DE_Step_Empty: ["준비 중인 단계입니다.", "This step is not ready yet."], /* --- D04 확정 · 업체 · 내보내기 · B03 예정노선 가져오기(531-3) --- */ - D04_Col_Year: ["연도", "Year"], + D04_Col_Year: ["세트", "Set"], + D04_Parts: ["덩이", "parts"], + D04_Branch: ["갈래", "branch"], + D04_Broken: [ + "끊기거나 갈래가 있는 세트는 확정이 막힙니다 — 예정노선에서 한 줄로 이어 오세요:", + "Broken or branched sets cannot be confirmed — join them in the planned route:", + ], D04_Col_Length: ["연장 (m)", "Length (m)"], D04_Col_Start: ["시점 (위도, 경도)", "Start (lat, lon)"], D04_Col_End: ["종점 (위도, 경도)", "End (lat, lon)"],