feat(638-3): 예정노선 노선 세트 화면 — 속성 판 세트 칸 · 연도 밑 세트 줄 · 이름표 「2027-1」 · [잇기] · 저장 뒤 끊김 경고 · D04 세트 줄(요구 547 · 548)

- 속성 판 · 왼쪽 칸 세트 고름 = 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 <noreply@anthropic.com>
This commit is contained in:
2026-11-01 12:00:21 +09:00
co-authored by Claude Opus 5.5
parent 0e4ce3cec1
commit eba5223356
16 changed files with 367 additions and 115 deletions
@@ -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],
@@ -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;
+77 -6
View File
@@ -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();
@@ -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: "지점 칸 · 핀 · 지점에서 긋기",
+13 -2
View File
@@ -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({
+2 -2
View File
@@ -230,9 +230,9 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
"Shift+누름 = 선 더함 · 뺌",
"빈 곳 오른쪽으로 끌기 = 창 안 선 · 왼쪽으로 = 걸친 선까지",
"고른 선 위 끌기 = 꺾임점 더해 옮김 · 노드 끌기 = 옮김",
"그리기 칸 「연도 구역 마커」 선 위로 끌어 놓기 = 구역 나눔",
"그리기 칸 「구간 나눔」 선 위로 끌어 놓기 = 구간 나눔 · 속성 판 「세트」 = 고른 범위 세트 바꿈",
"마크 끌기 = 옮김 · 선 밖으로 = 지움(앞 구간과 합침)",
"오른쪽 누름 = 메뉴(긋기 · 지점 · 노드 지움 · 연도 구역 나눔)",
"오른쪽 누름 = 메뉴(긋기 · 지점 · 노드 지움 · 구간 나눔 · 선 끝 잇기)",
"Delete = 고른 노드 · 선 · 구역 · 전체 · 마크 지움",
"가운데 끌기 = 옮기기",
"휠 = 확대 · 축소",
+9 -2
View File
@@ -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())
@@ -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<void> {
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<void> {
},
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<void> {
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<void> {
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");
+60 -40
View File
@@ -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<number, typeof network.pieces>();
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 = <K>(pieces: typeof network.pieces, of: (p: (typeof pieces)[number]) => K) => {
const out = new Map<K, typeof network.pieces>();
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),
+61 -43
View File
@@ -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<typeof setChoices> {
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,
};
}
@@ -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;
@@ -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<ReadonlyArray<Vertex>>): 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>): 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<string>;
}>,
pieces: ReadonlyArray<{ id: string; planned: ReadonlyArray<Vertex> }>,
): 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;
}
+20 -3
View File
@@ -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<void> {
? `${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<void> {
}
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(
+14 -1
View File
@@ -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
+21
View File
@@ -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 안에 갈래"]
+7 -1
View File
@@ -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)"],