auto: 2026-11-01 12:05 (ESD_LAPTOP)
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
/* =============================================================================
|
||||
* map_route_preview.ts
|
||||
* 공용 「추천노선」 미리보기(PLAN 626-1 → 636-2 공용 · 요구 530 · 546) — B05 평면 · 예정노선(D03)이 같이 씀.
|
||||
* 메뉴 = 도구줄 아이콘 하나(compass · 호버 「추천노선」) + 아래 판(항목 하나만 · 맨 끝 「끔」 · 부품 `createPickMenu`).
|
||||
* 그리기 = 지도 위 선(노랑 `--draw-map-sketch` · 검은 테 · 기본 점선) — 위성 · 어두운 바탕 모두 보임.
|
||||
* 항목 · 선 셈은 페이지 몫: B05 = 노선 추천 저장본 넷(`B05_Profile_UI_RouteEdit_RecPreview`) ·
|
||||
* 예정노선 = 가이드 선(`D03_PlanAnalysis_UI_Guide`).
|
||||
* ========================================================================== */
|
||||
|
||||
import { createPickMenu, type PickMenu, type PickMenuItem } from "@ui/ui_template_context_menu";
|
||||
import { themeColor } from "@ui/ui_template_palette";
|
||||
|
||||
type Vertex = [number, number] | [number, number, number];
|
||||
|
||||
/** 「추천노선」 메뉴 — 고름 = `onPick(값)` · 「끔」 = `onPick(null)`. */
|
||||
export function createRoutePreviewMenu(opts: {
|
||||
items: PickMenuItem[];
|
||||
onPick: (value: string | null) => void;
|
||||
}): PickMenu {
|
||||
const menu = createPickMenu({
|
||||
icon: "compass",
|
||||
title: "추천노선", // 마우스 글 = 공용 표 호버제목(634-2 · `mapToolGroups` 가 맞춤)
|
||||
items: opts.items,
|
||||
offLabel: "끔",
|
||||
onPick: opts.onPick,
|
||||
});
|
||||
menu.root.dataset.act = "recommend";
|
||||
return menu;
|
||||
}
|
||||
|
||||
/** 미리보기 선 하나 — 검은 테(5 px) 위 색 선(3 px) · 기본 노랑 점선. */
|
||||
export function paintRoutePreview(
|
||||
context: CanvasRenderingContext2D,
|
||||
line: readonly Vertex[],
|
||||
toScreen: (vertex: [number, number]) => [number, number],
|
||||
style: { dash?: number[]; color?: string } = {},
|
||||
): void {
|
||||
if (line.length < 2) return;
|
||||
context.save();
|
||||
context.setLineDash(style.dash ?? [10, 6]);
|
||||
context.lineJoin = "round";
|
||||
context.beginPath();
|
||||
line.forEach((vertex, index) => {
|
||||
const [x, y] = toScreen([vertex[0], vertex[1]]);
|
||||
if (index) context.lineTo(x, y);
|
||||
else context.moveTo(x, y);
|
||||
});
|
||||
// 지도 위 그림 색(테마 안 따름) + 검은 테 — 위성 · 어두운 바탕 모두 보임
|
||||
context.strokeStyle = "rgba(0, 0, 0, 0.6)";
|
||||
context.lineWidth = 5;
|
||||
context.stroke();
|
||||
context.strokeStyle = style.color ?? themeColor("--draw-map-sketch", "#ffe94d");
|
||||
context.lineWidth = 3;
|
||||
context.stroke();
|
||||
context.restore();
|
||||
}
|
||||
@@ -75,8 +75,8 @@ export const MAP_TOOLBAR: readonly (readonly MapToolRow[])[] = [
|
||||
{
|
||||
key: "recommend",
|
||||
title: "추천노선",
|
||||
does: "노선 추천 저장본 선 하나만 보기 · 끔(626-1 · 예정노선 = 636-2)",
|
||||
pages: B05,
|
||||
does: "B05 = 노선 추천 저장본 선 하나만 · 예정노선 = 가이드 선(지점 잇기 · 636-2) · 끔",
|
||||
pages: BOTH,
|
||||
},
|
||||
],
|
||||
[{ key: "terrain", title: "수치지형도/라이다", does: "지형 원천 보기 · 고르기", pages: BOTH }],
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
* 선 = 노선 추천 저장본(`B05_Profile/route_recommend/` · 지금 설계 지형 · 체인이 미리 셈) 그대로 — 다시 셈 · 쓰기 없음 ·
|
||||
* 저장본 없으면(409) 알림 · 끔. 옛 추천 모달 · 좌측 「노선 추천」 칸 · [적용](`as_initial`) 은 걷음(서버 길 · 시험은 남김).
|
||||
* 부르는 곳 = `_Chrome`(도구줄 `recommend` 칸) · `_RouteEdit`(그리기 `overStations` 앞).
|
||||
* 메뉴 · 선 그리기 = 공용 `A00_Common/map/map_route_preview`(636-2 · 예정노선 가이드 선과 같이 씀).
|
||||
* ========================================================================== */
|
||||
|
||||
import { createPickMenu } from "@ui/ui_template_context_menu";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { themeColor } from "@ui/ui_template_palette";
|
||||
import { createRoutePreviewMenu, paintRoutePreview } from "../A00_Common/map/map_route_preview";
|
||||
import type { AnalysisTerrain } from "./B05_Profile_Api_Replan";
|
||||
import { fetchRouteInitial, type RouteInitialLogic } from "./B05_Profile_Api_RouteInitial";
|
||||
import { LOGIC_LABEL } from "./B05_Profile_UI_RouteEdit_Initial_Model";
|
||||
@@ -41,14 +41,10 @@ export function createRecPreview(opts: {
|
||||
const lines = new Map<string, Vertex[]>();
|
||||
let shown: Vertex[] | null = null;
|
||||
let turn = 0;
|
||||
const menu = createPickMenu({
|
||||
icon: "compass",
|
||||
title: "추천노선", // 마우스 글 = 공용 표 호버제목(634-2 · `mapToolGroups` 가 맞춤)
|
||||
const menu = createRoutePreviewMenu({
|
||||
items: MODES.map((mode) => ({ value: mode, label: LOGIC_LABEL[mode] })),
|
||||
offLabel: "끔",
|
||||
onPick: (mode) => void pick(mode as RouteInitialLogic | null),
|
||||
});
|
||||
menu.root.dataset.act = "recommend";
|
||||
|
||||
async function pick(mode: RouteInitialLogic | null): Promise<void> {
|
||||
const mine = ++turn;
|
||||
@@ -81,24 +77,7 @@ export function createRecPreview(opts: {
|
||||
return {
|
||||
root: menu.root,
|
||||
draw(context, toScreen) {
|
||||
if (!shown) return;
|
||||
context.save();
|
||||
context.setLineDash([10, 6]);
|
||||
context.lineJoin = "round";
|
||||
context.beginPath();
|
||||
shown.forEach((vertex, index) => {
|
||||
const [x, y] = toScreen(vertex);
|
||||
if (index) context.lineTo(x, y);
|
||||
else context.moveTo(x, y);
|
||||
});
|
||||
// 지도 위 그림 색(테마 안 따름) + 검은 테 — 위성 · 어두운 바탕 모두 보임
|
||||
context.strokeStyle = "rgba(0, 0, 0, 0.6)";
|
||||
context.lineWidth = 5;
|
||||
context.stroke();
|
||||
context.strokeStyle = themeColor("--draw-map-sketch", "#ffe94d");
|
||||
context.lineWidth = 3;
|
||||
context.stroke();
|
||||
context.restore();
|
||||
if (shown) paintRoutePreview(context, shown, toScreen);
|
||||
},
|
||||
state: () => ({ mode: menu.value(), points: shown?.length ?? 0 }),
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: "지점 칸 · 핀 · 지점에서 긋기",
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
/* =============================================================================
|
||||
* D03_PlanAnalysis_UI_Guide.ts
|
||||
* 예정노선 가이드 추천선 화면(PLAN 636-2 · 요구 546) — 도구줄 「추천노선」 = 「가이드 선(지점 잇기)」 · 「끔」.
|
||||
* 지점 = 지점 목록 차례 · 지점 둘 이상일 때만 열림 · 높이 = 지형 칩 원천 격자 · 걸음 20 m · 기울기 ±14 % 고정(사용자 원문 ·
|
||||
* 그리기 칸 값과 따로) · 급경사 문턱 = 급경사 범위 둘째 경계(기본 70 %).
|
||||
* 셈 = 공용 `map_guide_route`(브라우저만 · 저장 안 함) · 켠 동안 지점 · 지형 · 급경사 범위가 바뀌면 다시 셈(끌기 중엔 멈춘 뒤 한 번).
|
||||
* 그림 = 닿은 선 노랑 실선 · 못 닿은 구간 = 간 데 → 다음 지점 빨강 점선 + 알림 · 끝 지점 옆 짧은 글(길이 · 최대 기울기 · 급경사 %).
|
||||
* 메뉴 · 선 그리기 = 공용 `map_route_preview`(B05 추천 미리보기와 같음).
|
||||
* ========================================================================== */
|
||||
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { mapToolTip } from "@ui/ui_template_map_tools";
|
||||
import { themeColor } from "@ui/ui_template_palette";
|
||||
import {
|
||||
guideRoute,
|
||||
guideTerrainFromGrid,
|
||||
type GuideResult,
|
||||
} from "../A00_Common/map/logic/map_guide_route";
|
||||
import { createRoutePreviewMenu, paintRoutePreview } from "../A00_Common/map/map_route_preview";
|
||||
import { readSlopeBands } from "../A00_Common/map/map_slope_bands";
|
||||
import type { DtmGrid } from "../common_util/common_util_surface_sampler";
|
||||
import { planLabel } from "./D03_PlanAnalysis_UI_DrawPaint";
|
||||
import type { PlanMapLayer } from "./D03_PlanAnalysis_UI_Map";
|
||||
|
||||
type Vertex = [number, number];
|
||||
|
||||
/** 사용자 원문(요구 546) — 걸음 · 기울기 고정. */
|
||||
const GUIDE_STEP_M = 20;
|
||||
const GUIDE_MAX_GRADE = 0.14;
|
||||
/** 끌기 · 연이은 고침이 멈춘 뒤 셈(ms). */
|
||||
const SETTLE_MS = 150;
|
||||
|
||||
export interface GuideState {
|
||||
on: boolean;
|
||||
open: boolean;
|
||||
segments: number;
|
||||
reached: number;
|
||||
lengthM: number;
|
||||
maxGradePct: number;
|
||||
steepPct: number;
|
||||
steepLimitPct: number;
|
||||
ms: number | null;
|
||||
}
|
||||
|
||||
/** 급경사 문턱 = 범위 경계를 작은 차례로 늘어놓은 둘째(기본 26.8 · 70 · … → 70). */
|
||||
function steepLimit(): number {
|
||||
const edges = [...new Set(readSlopeBands().flatMap((band) => [band.low, band.high]))];
|
||||
edges.sort((a, b) => a - b);
|
||||
return edges[1] ?? edges[0] ?? 70;
|
||||
}
|
||||
|
||||
export function createPlanGuide(opts: {
|
||||
points: () => Vertex[];
|
||||
/** 지형 칩 원천 격자(없으면 null). */
|
||||
grid: () => DtmGrid | null;
|
||||
redraw: () => void;
|
||||
}): { root: HTMLElement; layer: PlanMapLayer; state: () => GuideState } {
|
||||
let result: GuideResult | null = null;
|
||||
let shownFor: Vertex[] = [];
|
||||
let doneKey = "";
|
||||
let doneGrid: DtmGrid | null = null;
|
||||
let steepPct = 70;
|
||||
let timer = 0;
|
||||
let open: boolean | null = null;
|
||||
const menu = createRoutePreviewMenu({
|
||||
items: [{ value: "guide", label: "가이드 선(지점 잇기)" }],
|
||||
onPick: (value) => {
|
||||
[result, doneKey, doneGrid] = [null, "", null];
|
||||
if (value) compute(true);
|
||||
opts.redraw();
|
||||
},
|
||||
});
|
||||
const on = (): boolean => menu.value() === "guide";
|
||||
|
||||
function compute(picked = false): void {
|
||||
window.clearTimeout(timer);
|
||||
const points = opts.points().map(([x, y]) => [x, y] as Vertex);
|
||||
const grid = opts.grid();
|
||||
if (!grid) {
|
||||
menu.set(null);
|
||||
result = null;
|
||||
showToast("가이드 선 — 지형이 아직 없음", "info");
|
||||
return opts.redraw();
|
||||
}
|
||||
steepPct = steepLimit();
|
||||
[doneKey, doneGrid] = [JSON.stringify([points, steepPct]), grid];
|
||||
result = guideRoute(points, guideTerrainFromGrid(grid), {
|
||||
step: GUIDE_STEP_M,
|
||||
maxGrade: GUIDE_MAX_GRADE,
|
||||
steepPct,
|
||||
});
|
||||
shownFor = points;
|
||||
const missed = result.segments.filter((segment) => !segment.reached).length;
|
||||
if (missed) showToast(`가이드 선 — ${missed} 구간 ±14 % 로 못 닿음(점선 = 곧은 이음)`, "info");
|
||||
else if (picked) showToast(`가이드 선 — ${Math.round(result.ms)} ms`, "info");
|
||||
opts.redraw();
|
||||
}
|
||||
|
||||
/** 지점 둘 미만 = 막힘(켜져 있으면 끔). */
|
||||
function syncOpen(count: number): void {
|
||||
const next = count >= 2;
|
||||
if (next === open) return;
|
||||
open = next;
|
||||
menu.root.disabled = !next;
|
||||
mapToolTip(menu.root, next ? null : "지점 둘 이상일 때");
|
||||
if (!next && on()) {
|
||||
menu.set(null);
|
||||
result = null;
|
||||
}
|
||||
}
|
||||
|
||||
function summary(): Omit<GuideState, "on" | "open"> {
|
||||
const segments = result?.segments ?? [];
|
||||
const lengthM = segments.reduce((sum, segment) => sum + segment.length, 0);
|
||||
const steep = segments.reduce((sum, segment) => sum + segment.steepRatio * segment.length, 0);
|
||||
return {
|
||||
segments: segments.length,
|
||||
reached: segments.filter((segment) => segment.reached).length,
|
||||
lengthM,
|
||||
maxGradePct: Math.max(0, ...segments.map((segment) => segment.maxGrade)) * 100,
|
||||
steepPct: lengthM ? (steep / lengthM) * 100 : 0,
|
||||
steepLimitPct: steepPct,
|
||||
ms: result ? result.ms : null,
|
||||
};
|
||||
}
|
||||
|
||||
const layer: PlanMapLayer = {
|
||||
draw(context, map) {
|
||||
const points = opts.points();
|
||||
syncOpen(points.length);
|
||||
if (!on()) return;
|
||||
const key = JSON.stringify([points.map(([x, y]) => [x, y]), steepLimit()]);
|
||||
if (key !== doneKey || opts.grid() !== doneGrid) {
|
||||
window.clearTimeout(timer);
|
||||
timer = window.setTimeout(() => compute(), SETTLE_MS);
|
||||
}
|
||||
if (!result) return;
|
||||
const warn = themeColor("--draw-map-warn", "#ff5a4d");
|
||||
result.segments.forEach((segment, index) => {
|
||||
paintRoutePreview(context, segment.vertices, map.toScreen, { dash: [] });
|
||||
const goal = shownFor[index + 1];
|
||||
const last = segment.vertices[segment.vertices.length - 1] ?? shownFor[index];
|
||||
if (!segment.reached && goal && last)
|
||||
paintRoutePreview(context, [last, goal], map.toScreen, { color: warn });
|
||||
});
|
||||
const end = shownFor[shownFor.length - 1];
|
||||
if (!end) return;
|
||||
const s = summary();
|
||||
const [x, y] = map.toScreen(end);
|
||||
const text = `가이드 ${(s.lengthM / 1000).toFixed(2)} km · 최대 ${s.maxGradePct.toFixed(1)} % · 급경사 지남 ${s.steepPct.toFixed(0)} %`;
|
||||
// 끝 지점 핀 아래 · 화면 오른쪽 반이면 오른쪽 맞춤(지점 이름 · 화면 끝에 안 걸림)
|
||||
const right = x > context.canvas.clientWidth / 2;
|
||||
planLabel(context, map, x, y + 16, text, "bold 12px", right ? "right" : "left");
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
root: menu.root,
|
||||
layer,
|
||||
state: () => ({ on: on(), open: !!open, ...summary() }),
|
||||
};
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -230,9 +230,9 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
|
||||
"Shift+누름 = 선 더함 · 뺌",
|
||||
"빈 곳 오른쪽으로 끌기 = 창 안 선 · 왼쪽으로 = 걸친 선까지",
|
||||
"고른 선 위 끌기 = 꺾임점 더해 옮김 · 노드 끌기 = 옮김",
|
||||
"그리기 칸 「연도 구역 마커」 선 위로 끌어 놓기 = 구역 나눔",
|
||||
"그리기 칸 「구간 나눔」 선 위로 끌어 놓기 = 구간 나눔 · 속성 판 「세트」 = 고른 범위 세트 바꿈",
|
||||
"마크 끌기 = 옮김 · 선 밖으로 = 지움(앞 구간과 합침)",
|
||||
"오른쪽 누름 = 메뉴(긋기 · 지점 · 노드 지움 · 연도 구역 나눔)",
|
||||
"오른쪽 누름 = 메뉴(긋기 · 지점 · 노드 지움 · 구간 나눔 · 선 끝 잇기)",
|
||||
"Delete = 고른 노드 · 선 · 구역 · 전체 · 마크 지움",
|
||||
"가운데 끌기 = 옮기기",
|
||||
"휠 = 확대 · 축소",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -42,7 +42,9 @@ def test_미리보기_넷_하나만_저장본만() -> None:
|
||||
src = read(B05 / "B05_Profile_UI_RouteEdit_RecPreview.ts")
|
||||
modes = re.search(r"const MODES = \[(.*?)\]", src, re.S).group(1)
|
||||
assert re.findall(r'"(\w+)"', modes) == ["gradeline", "earthwork", "gentle", "weighted"]
|
||||
assert "recompute" not in src and "createPickMenu({" in src and 'offLabel: "끔"' in src
|
||||
assert "recompute" not in src and "createRoutePreviewMenu({" in src
|
||||
shared = read(ROOT / "A00_Common" / "map" / "map_route_preview.ts") # 636-2 공용 메뉴 · 그리기
|
||||
assert "createPickMenu({" in shared and 'offLabel: "끔"' in shared
|
||||
assert "terrain: opts.terrain()" in src # 지금 설계 지형 저장본(D7)
|
||||
chrome = read(B05 / "B05_Profile_UI_RouteEdit_Chrome.ts")
|
||||
assert "recommend: preview.root" in chrome and "terrain: terrain.root" in chrome
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"""PLAN 636-2 — 「추천노선」 메뉴 · 선 그리기 공용 · 예정노선 가이드 선(요구 546).
|
||||
|
||||
- 공용 `map_route_preview` = 메뉴(compass · 「끔」 · data-act) + 노랑 · 검은 테 선 — B05 · 예정노선이 같이 씀.
|
||||
- 도구 표 `recommend` 줄 = 두 페이지 · 호버 「추천노선」.
|
||||
- 예정노선 = 「가이드 선(지점 잇기)」 하나 · 20 m · ±14 % 고정 · 급경사 문턱 = 범위 둘째 경계 · 저장 · 서버 없음.
|
||||
- B05 = 저장본 넷 그대로 · 가이드 항목 없음.
|
||||
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_636_2_guide_screen.py -q
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
read = lambda *parts: ROOT.joinpath(*parts).read_text(encoding="utf-8") # noqa: E731
|
||||
|
||||
|
||||
def test_공용_메뉴_그리기() -> None:
|
||||
src = read("A00_Common", "map", "map_route_preview.ts")
|
||||
assert 'icon: "compass"' in src and 'offLabel: "끔"' in src
|
||||
assert 'dataset.act = "recommend"' in src and "--draw-map-sketch" in src
|
||||
for name in (
|
||||
"B05_Profile/B05_Profile_UI_RouteEdit_RecPreview.ts",
|
||||
"D03_PlanAnalysis/D03_PlanAnalysis_UI_Guide.ts",
|
||||
):
|
||||
page = read(name)
|
||||
assert "createRoutePreviewMenu({" in page and "paintRoutePreview(" in page, name
|
||||
assert "createPickMenu" not in page, name
|
||||
|
||||
|
||||
def test_도구_표_두_페이지() -> None:
|
||||
src = read("A00_Common", "map", "map_toolbar.ts")
|
||||
row = src[src.index('key: "recommend"') :]
|
||||
row = row[: row.index("}")]
|
||||
assert 'title: "추천노선"' in row and "pages: BOTH" in row
|
||||
assert "recommend: events.recommend" in read("D03_PlanAnalysis", "D03_PlanAnalysis_UI_Map.ts")
|
||||
|
||||
|
||||
def test_예정노선_가이드_고정값_보기만() -> None:
|
||||
src = read("D03_PlanAnalysis", "D03_PlanAnalysis_UI_Guide.ts")
|
||||
assert "GUIDE_STEP_M = 20" in src and "GUIDE_MAX_GRADE = 0.14" in src
|
||||
assert 'label: "가이드 선(지점 잇기)"' in src
|
||||
assert "guideRoute(" in src and "guideTerrainFromGrid(" in src and "readSlopeBands()" in src
|
||||
assert "count >= 2" in src # 지점 둘 이상일 때만
|
||||
for banned in ("fetch(", "writeState", "sessionStorage", "constraint"):
|
||||
assert banned not in src, banned
|
||||
page = read("D03_PlanAnalysis", "D03_PlanAnalysis_UI_Page.ts")
|
||||
assert (
|
||||
"recommend: guide.root" in page and "guide.layer" in page and "guide: guide.state" in page
|
||||
)
|
||||
assert len(page.splitlines()) <= 700
|
||||
|
||||
|
||||
def test_B05_저장본_넷_그대로() -> None:
|
||||
src = read("B05_Profile", "B05_Profile_UI_RouteEdit_RecPreview.ts")
|
||||
assert '"gradeline",\n "earthwork",\n "gentle",\n "weighted",' in src.replace("\r\n", "\n")
|
||||
assert "가이드 선(지점 잇기)" not in src and "guideRoute" not in src
|
||||
@@ -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 안에 갈래"]
|
||||
|
||||
@@ -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)"],
|
||||
|
||||
Reference in New Issue
Block a user