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

This commit is contained in:
2026-10-30 14:40:09 +09:00
9 changed files with 266 additions and 116 deletions
+25 -101
View File
@@ -11,18 +11,15 @@
* 자유 · 길이 고정 · 기울기 고정(기울기 고정 · 길이 자유 · 마우스 방향 첫 자리) · 둘 다(양각기 원 위 후보 중
* 마우스에 가까운 것) · 제약 기울기 = 오르내림 같은 크기(562-1 짝이 |기울기|로 셈).
* 긋기는 해를 안 물음(585-3) — 새 선 = 사업연도 시작 해 · 갈래 = 붙은 자리 구간 해 · 이어 긋기 = 끝 구간이 늘어남 ·
* 여러 해 = 다 그은 뒤 도구줄 「연도 마커」를 선 위로 끌어 구역을 나누고 속성 판에서 해를 고름.
* 여러 해 = 다 그은 뒤 그리기 칸 「연도 구역 마커」를 선 위로 끌어 구역을 나누고 속성 판에서 해를 고름.
* 고르기 · 고치기 · 마커(쉴 때) = `D03_PlanAnalysis_UI_Edit`.
* 키 = Enter 끝 · Esc 이번 긋기 취소 · Backspace 마지막 점 빼기(설정 창 단축키 표 D03 줄).
* 셈 = 562-1 짝(`logic/D03_PlanAnalysis_Draw`) · 꼴 다루기 = `logic/D03_PlanAnalysis_Plan` ·
* 값을 바꾸면 `commit(다음 꼴)` 한 번 = 되돌리기 한 걸음(스택 · 캐시 = 페이지).
* ========================================================================== */
import { createButton, showToast } from "@ui/ui_template_elements";
import { showToast } from "@ui/ui_template_elements";
import { keyIs } from "../A00_Common/key_registry";
import { createInputField } from "@ui/ui_template_input";
import { createSegment } from "@ui/ui_template_segment";
import { createText } from "@ui/ui_template_text";
import {
compassCandidates,
compassPoint,
@@ -50,17 +47,19 @@ import {
paintNetwork,
paintPreview,
screenGap,
slopeDeg,
SNAP_PX,
} from "./D03_PlanAnalysis_UI_DrawPaint";
import {
createDrawKindFields,
createDrawPanel,
DRAW_DEFAULT_LENGTH_M,
DRAW_DEFAULT_SLOPE_PCT,
type DrawConstraint,
} from "./D03_PlanAnalysis_UI_DrawPanel";
import type { PlanMapLayer, ScreenMap } from "./D03_PlanAnalysis_UI_Map";
/** 제약 칸 처음 값 — 길이 = 측점 간격(지식DB 설계제원_총괄) · 기울기 = 간선 20 km/h 일반지형 상한(종단선형). */
export const DRAW_DEFAULT_LENGTH_M = 20;
export const DRAW_DEFAULT_SLOPE_PCT = 9;
const START_HINT = "지점 · 선 끝 · 선 위를 눌러 시작합니다 — 빈 땅은 우클릭 「여기서 긋기 시작」.";
export type DrawConstraint = "free" | "length" | "slope" | "both";
export type DrawMode = "idle" | "draw";
export interface DrawPreview {
@@ -87,7 +86,7 @@ export interface PlanDrawParams {
export interface PlanDraw {
layer: PlanMapLayer;
/** 좌측 「그리기」 칸 몸(모드 단추 · 제약 칸 · 안내). */
/** 좌측 「그리기」 칸 몸(`D03_PlanAnalysis_UI_DrawPanel` — 긋기 · 연도 구역 마커 · 방식 칸). */
panel: HTMLElement;
mode: () => DrawMode;
setMode: (mode: DrawMode) => void;
@@ -119,94 +118,23 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
let slopePct = DRAW_DEFAULT_SLOPE_PCT;
// ── 좌측 칸 ─────────────────────────────────────────────
const drawButton = createButton({
label: "긋기",
variant: "filled",
size: 28,
onClick: () => setMode(mode === "draw" ? "idle" : "draw"),
const kindFields = createDrawKindFields({
value: { kind, lengthM, slopePct },
onChange: (value) => ({ kind, lengthM, slopePct } = value),
});
const modeRow = document.createElement("div");
modeRow.className = "d03-plan__row";
modeRow.append(drawButton);
const kindSegment = createSegment({
options: [
{ value: "free", label: "자유" },
{ value: "length", label: "길이 고정" },
{ value: "slope", label: "기울기 고정" },
{ value: "both", label: "둘 다" },
],
value: kind,
size: 24,
ariaLabel: "긋기 제약",
onChange: (value) => {
kind = value as DrawConstraint;
fieldsState();
},
const drawPanel = createDrawPanel({
kind: kindFields,
onDraw: () => setMode(mode === "draw" ? "idle" : "draw"),
});
kindSegment.root.dataset.role = "draw-constraint";
const lengthField = createInputField({
label: "길이",
type: "number",
value: String(lengthM),
min: 1,
unit: "m",
size: 24,
onInput: (value) => {
const next = Number(value);
if (next > 0) lengthM = next;
lengthField.setError(next > 0 ? undefined : "0 보다 큰 수");
},
});
lengthField.input.dataset.role = "draw-length";
const slopeField = createInputField({
label: "기울기(± 같음)",
type: "number",
value: String(slopePct),
unit: "%",
size: 24,
onInput: (value) => {
const next = Number(value);
const ok = value.trim() !== "" && Number.isFinite(next);
if (ok) slopePct = next;
slopeField.setError(ok ? undefined : "숫자");
degText();
},
});
slopeField.input.dataset.role = "draw-slope";
const slopeDegText = createText("", "muted");
slopeDegText.dataset.role = "draw-slope-deg";
const degText = (): void => {
slopeDegText.textContent = `≈ ${fmt(slopeDeg(Math.abs(slopePct)))}°`;
};
degText();
const fieldRow = document.createElement("div");
fieldRow.className = "d03-plan__row";
fieldRow.append(lengthField.root, slopeField.root, slopeDegText);
const hint = createText("", "muted");
hint.classList.add("d03-plan__hint");
const panel = document.createElement("div");
panel.className = "d03-plan__draw";
panel.append(modeRow, kindSegment.root, fieldRow, hint);
function fieldsState(): void {
lengthField.input.disabled = kind === "free" || kind === "slope";
slopeField.input.disabled = kind === "free" || kind === "length";
}
const panel = drawPanel.root;
function panelState(): void {
drawButton.querySelector(".ui-btn__label")!.textContent = mode === "draw" ? "긋기 끝" : "긋기";
drawButton.disabled = !editable;
drawButton.setAttribute("aria-pressed", String(mode === "draw"));
panel.dataset.mode = mode;
hint.textContent = !editable
? "보기만 — 산림청 계정에서 긋습니다."
: mode === "draw"
? active === null
? START_HINT
: "누름 = 점 · Enter 끝 · Esc 취소 · Backspace 마지막 점 빼기"
: "[긋기]로 지점 · 선에서 긋습니다 · 지도 우클릭 = 긋기 시작 · 지점 놓기 · 선 누름 = 고름 · 「연도 마커」를 선 위로 끌면 구역을 나눕니다.";
drawPanel.state({
drawing: mode === "draw",
editable,
canMark: params.network().lines.some((line) => line.vertices.length > 1),
});
}
fieldsState();
// ── 셈 ──────────────────────────────────────────────────
const lastOf = (line: number): Vertex | null => {
@@ -524,13 +452,9 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
constraint: () => ({ kind, lengthM, slopePct }),
setConstraint(next, length, slope) {
kind = next;
kindSegment.set(next);
if (length !== undefined) [lengthM, lengthField.input.value] = [length, String(length)];
if (slope !== undefined) {
[slopePct, slopeField.input.value] = [slope, String(slope)];
degText();
}
fieldsState();
if (length !== undefined) lengthM = length;
if (slope !== undefined) slopePct = slope;
kindFields.set({ kind, lengthM, slopePct });
},
};
}
@@ -0,0 +1,190 @@
/* =============================================================================
* D03_PlanAnalysis_UI_DrawPanel.ts
* D03 좌측 「그리기」 칸 몸(PLAN 599-1 · 599-4 · 요구 463-10 · 22) — [긋기] · 연도 구역 마커 · 긋기 방식 칸.
*
* 방식 칸 `createDrawKindFields` = 마디 고름 2 × 2(자유 · 길이 고정 · 기울기 고정 · 길이·기울기 고정) +
* 길이 · 기울기 두 열(기울기 아래 ≈ °) — 그리기 칸 · 노드 판(598-4)이 같은 부품을 씀.
* 연도 구역 마커 = 끌어 선 위에 놓기(`YEAR_MARK_DRAG` · 받음 = `D03_PlanAnalysis_UI_Edit` drop).
* 설명 글 없음(요구 463-6) — 칸 오류 · ≈ ° 값만.
* ========================================================================== */
import type { UiSize } from "@ui/ui_template_button";
import { createButton } from "@ui/ui_template_elements";
import { createInputField } from "@ui/ui_template_input";
import { createSegment } from "@ui/ui_template_segment";
import { createText } from "@ui/ui_template_text";
import { fmt, slopeDeg } from "./D03_PlanAnalysis_UI_DrawPaint";
import { YEAR_MARK_DRAG } from "./D03_PlanAnalysis_UI_Map";
/** 제약 칸 처음 값 — 길이 = 측점 간격(지식DB 설계제원_총괄) · 기울기 = 간선 20 km/h 일반지형 상한(종단선형). */
export const DRAW_DEFAULT_LENGTH_M = 20;
export const DRAW_DEFAULT_SLOPE_PCT = 9;
export type DrawConstraint = "free" | "length" | "slope" | "both";
export interface DrawKindValue {
kind: DrawConstraint;
lengthM: number;
slopePct: number;
}
export const DRAW_KIND_LABELS: Record<DrawConstraint, string> = {
free: "자유",
length: "길이 고정",
slope: "기울기 고정",
both: "길이·기울기 고정",
};
export interface DrawKindFields {
root: HTMLElement;
get: () => DrawKindValue;
/** 값을 바꿈(onChange 는 안 부름). */
set: (value: Partial<DrawKindValue>) => void;
setDisabled: (disabled: boolean) => void;
}
/** 긋기 방식 칸 — 고름 · 길이 · 기울기가 바뀔 때마다 `onChange(지금 값)`(빈 · 틀린 수는 칸 오류만). */
export function createDrawKindFields(opts: {
value: DrawKindValue;
onChange: (value: DrawKindValue) => void;
size?: UiSize;
}): DrawKindFields {
const size = opts.size ?? 24;
let value = { ...opts.value };
let disabled = false;
const changed = (): void => opts.onChange({ ...value });
const segment = createSegment({
options: (Object.keys(DRAW_KIND_LABELS) as DrawConstraint[]).map((kind) => ({
value: kind,
label: DRAW_KIND_LABELS[kind],
})),
value: value.kind,
size,
columns: 2,
ariaLabel: "긋기 방식",
onChange: (next) => {
value.kind = next as DrawConstraint;
fieldsState();
changed();
},
});
segment.root.dataset.role = "draw-constraint";
const lengthField = createInputField({
label: "길이",
type: "number",
value: String(value.lengthM),
min: 1,
unit: "m",
size,
onInput: (text) => {
const next = Number(text);
lengthField.setError(next > 0 ? undefined : "0 보다 큰 수");
if (!(next > 0)) return;
value.lengthM = next;
changed();
},
});
lengthField.input.dataset.role = "draw-length";
const slopeField = createInputField({
label: "기울기",
type: "number",
value: String(value.slopePct),
unit: "%",
size,
onInput: (text) => {
const next = Number(text);
const ok = text.trim() !== "" && Number.isFinite(next);
slopeField.setError(ok ? undefined : "숫자");
if (!ok) return;
value.slopePct = next;
degText();
changed();
},
});
slopeField.input.dataset.role = "draw-slope";
const slopeDegText = createText("", "muted");
slopeDegText.dataset.role = "draw-slope-deg";
const degText = (): void => {
slopeDegText.textContent = `≈ ${fmt(slopeDeg(Math.abs(value.slopePct)))}°`;
};
const slopeColumn = document.createElement("div");
slopeColumn.append(slopeField.root, slopeDegText);
const fields = document.createElement("div");
fields.className = "d03-plan__draw-fields";
fields.append(lengthField.root, slopeColumn);
const root = document.createElement("div");
root.className = "d03-plan__draw-kind";
root.append(segment.root, fields);
function fieldsState(): void {
for (const button of segment.root.querySelectorAll("button")) button.disabled = disabled;
lengthField.input.disabled = disabled || value.kind === "free" || value.kind === "slope";
slopeField.input.disabled = disabled || value.kind === "free" || value.kind === "length";
}
degText();
fieldsState();
return {
root,
get: () => ({ ...value }),
set(next) {
value = { ...value, ...next };
segment.set(value.kind);
lengthField.input.value = String(value.lengthM);
slopeField.input.value = String(value.slopePct);
lengthField.setError(undefined);
slopeField.setError(undefined);
degText();
fieldsState();
},
setDisabled(next) {
disabled = next;
fieldsState();
},
};
}
export interface DrawPanel {
root: HTMLElement;
/** 단추 글 · 막힘 — 긋는 중 · 고칠 수 있음 · 마커 놓을 선 있음. */
state: (state: { drawing: boolean; editable: boolean; canMark: boolean }) => void;
}
/** 좌측 「그리기」 칸 — [긋기] · 연도 구역 마커(끌기) · 방식 칸. */
export function createDrawPanel(opts: { kind: DrawKindFields; onDraw: () => void }): DrawPanel {
const drawButton = createButton({
label: "긋기",
variant: "filled",
size: 28,
onClick: opts.onDraw,
});
const markButton = createButton({
label: "연도 구역 마커",
variant: "ghost",
size: 28,
iconStart: "flag",
});
markButton.title = "선 위로 끌어 놓으면 구역을 나눕니다";
markButton.dataset.role = "draw-year-mark";
markButton.draggable = true;
markButton.addEventListener("dragstart", (event) => {
event.dataTransfer?.setData(YEAR_MARK_DRAG, "1");
if (event.dataTransfer) event.dataTransfer.effectAllowed = "copy";
});
const row = document.createElement("div");
row.className = "d03-plan__row";
row.append(drawButton, markButton);
const root = document.createElement("div");
root.className = "d03-plan__draw";
root.append(row, opts.kind.root);
return {
root,
state({ drawing, editable, canMark }) {
drawButton.querySelector(".ui-btn__label")!.textContent = drawing ? "긋기 끝" : "긋기";
drawButton.disabled = !editable;
drawButton.setAttribute("aria-pressed", String(drawing));
markButton.disabled = !editable || drawing || !canMark;
root.dataset.mode = drawing ? "draw" : "idle";
},
};
}
+3 -11
View File
@@ -35,7 +35,7 @@ import type { GlBase } from "./D03_PlanAnalysis_UI_MapGl";
import type { Vertex } from "./logic/D03_PlanAnalysis_Plan";
import { localTmFrame, tmParams } from "./logic/D03_PlanAnalysis_Tm";
/** 도구줄 「연도 마커」 끌어 놓기 자료 꼴(585-3). */
/** 「연도 구역 마커」 끌어 놓기 자료 꼴(585-3 · 단추 = 그리기 칸 599-4). */
export const YEAR_MARK_DRAG = "application/x-aislo-year-mark";
/** 측점 간격(m) — B 규칙 측점과 같음. */
@@ -124,7 +124,7 @@ export interface PlanTools {
view3d: boolean;
/** 거리재기 켜짐(눌림). */
measure: boolean;
/** 「연도 마커」 끌 수 있음(고칠 수 있고 선 있음). */
/** 받기만 — 연도 구역 마커 단추는 그리기 칸(599-4 · `D03_PlanAnalysis_UI_DrawPanel`)이 켬끔. */
yearMark: boolean;
}
@@ -197,12 +197,6 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
const redoButton = mapToolButton("redo", { onClick: events.onRedo });
const viewButton = mapToolButton("view3d", { onClick: events.on3d });
const measureButton = mapToolButton("measure", { onClick: events.onMeasure });
const yearMarkButton = mapToolButton("year-mark", { disabled: true });
yearMarkButton.draggable = true;
yearMarkButton.addEventListener("dragstart", (event) => {
event.dataTransfer?.setData(YEAR_MARK_DRAG, "1");
if (event.dataTransfer) event.dataTransfer.effectAllowed = "copy";
});
const tools = createMapTools({
label: "평면 도구",
hint: {
@@ -216,7 +210,7 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
"Shift+누름 = 구간 더함 · 뺌",
"빈 곳 오른쪽으로 끌기 = 창 안 구간 · 왼쪽으로 = 걸친 구간까지",
"고른 선 위 끌기 = 꺾임점 더해 옮김",
"「연도 마커」 선 위로 끌어 놓기 = 구역 나눔",
"그리기 칸 「연도 구역 마커」 선 위로 끌어 놓기 = 구역 나눔",
"꺾임점 · 마크 끌기 = 옮김 · 마크를 선 밖으로 = 지움(앞 구간과 합침)",
"오른쪽 누름 = 꺾임점 · 마크 지움",
"Delete = 고른 선 · 마크 지움",
@@ -229,7 +223,6 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
[undoButton, redoButton],
[viewButton, "rotate-ccw", "rotate-cw"],
[markButton("stations", "측점"), markButton("slopes", "구간 길이 · 기울기"), measureButton],
[yearMarkButton],
],
});
const toolbar = tools.root;
@@ -526,7 +519,6 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
if (tools.view3d !== undefined) viewButton.disabled = !tools.view3d;
if (tools.measure !== undefined)
measureButton.setAttribute("aria-pressed", String(tools.measure));
if (tools.yearMark !== undefined) yearMarkButton.disabled = !tools.yearMark;
},
redraw: drawTop,
redrawBase: draw,
@@ -195,3 +195,21 @@
.d03-plan__piece-year {
font-weight: 600;
}
/* 599 그리기 칸 — 방식 칸(마디 고름 2 × 2 · 길이 · 기울기 두 열) */
.d03-plan__draw-kind {
display: flex;
flex-direction: column;
gap: 6px;
}
.d03-plan__draw-fields {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--spacing-8);
align-items: start;
}
.d03-plan__draw-fields .ui-field {
margin: 0;
}
+10 -2
View File
@@ -30,11 +30,12 @@ def test_d03_parts():
three = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_3D.ts")
css = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css")
draw = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts")
draw_panel = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPanel.ts")
tools = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts")
layers = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts")
assert 'createElement("progress")' not in ts and "createProgressBar()" in ts
# 562-3 — 날 단추 · 입력 · 고르기 없음 · 도구줄 · 제약 칸 · 연도 고름은 부품
for src in (ts, draw, tools, layers):
for src in (ts, draw, draw_panel, tools, layers):
for raw in ('createElement("button")', 'createElement("input")', 'createElement("select")'):
assert raw not in src, raw
# 568-3 — 도구줄 = 공용 지도 도구줄 · 아홉([?] · 되돌리기 · 다시하기 · 3D · 돌리기 둘 · 측점 · 기울기 · 재기)
@@ -76,7 +77,14 @@ def test_d03_parts():
("place", "false"),
], keys
assert ".d03-plan__layers" in css and ".d03-plan__legend" in css
assert "createSegment({" in draw and draw.count("createInputField(") == 2
# 599-1 — 방식 칸 = 마디 고름 2 × 2 · 길이 · 기울기 두 열 · 노드 판(598-4)과 같은 함수
assert "createSegment({" in draw_panel and draw_panel.count("createInputField(") == 2
assert "columns: 2" in draw_panel and "export function createDrawKindFields(" in draw_panel
assert ".d03-plan__draw-fields" in css and "createDrawKindFields(" in draw
assert '"길이·기울기 고정"' in draw_panel and '"둘 다"' not in draw_panel
assert "d03-plan__hint" not in draw # 칸 설명 글 걷음(요구 463-6)
# 599-4 — 연도 구역 마커 = 그리기 칸 단추(끌어 놓기) · 지도 도구줄에서 뺌
assert "YEAR_MARK_DRAG" in draw_panel and '"year-mark"' not in tools
props = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Props.ts") # 585-2 조각 연도 = 속성 판
assert "createSelectField(" in props and "createFloatPanel(" in props
assert 'createElement("button")' not in props and 'createElement("select")' not in props
-1
View File
@@ -55,7 +55,6 @@ def test_단추_목록_아이콘_차례():
"footprint",
"measure",
"station-snap",
"year-mark",
]
names = _icon_names()
assert all(icon in names for _, icon, _ in rows)
-1
View File
@@ -31,7 +31,6 @@ export const MAP_TOOLS = {
footprint: { icon: "road", title: "절/성토선", toggle: true },
measure: { icon: "ruler", title: "거리 재기", toggle: true },
"station-snap": { icon: "magnet", title: "측점 스냅", toggle: true },
"year-mark": { icon: "flag", title: "연도 구역 마커 — 선 위로 끌어 놓기" },
} satisfies Record<string, { icon: IconName; title: string; toggle?: boolean }>;
export type MapToolAct = keyof typeof MAP_TOOLS;
+13
View File
@@ -48,6 +48,19 @@
cursor: not-allowed;
}
/* 여러 줄(columns) — 칸 사이 1px 틈에 틀 색 바탕이 보여 줄 · 칸 나눔선 */
.ui-segment[data-columns] {
display: grid;
grid-template-columns: repeat(var(--ui-segment-columns), 1fr);
gap: 1px;
height: auto;
background: var(--color-border);
}
.ui-segment[data-columns] > .ui-segment__item {
height: var(--ui-size-h);
border-left: 0;
}
/* 숨김 — display 가 [hidden] 을 덮지 않게(공용 기본 꼴이 안 실린 곳 · 페이지 꾸밈 위에서도) */
:is(.ui-segment, .ui-segment__item)[hidden] {
display: none !important;
+7
View File
@@ -4,6 +4,7 @@
*
* 꼴 = 글 · 아이콘(+ title) · 글 + 아이콘
* 크기 = data-size 20 · 24 · 28 · 32(기본 28)
* 줄 = 한 줄(기본) · `columns: 2` = 2 × 2 처럼 여러 줄 칸(긴 이름 넷 · 599-1 D03 긋기 방식)
* 상태 = 마우스 `:hover` · 눌림 `:active` · 막힘 `disabled` · 고름 = aria-checked — CSS 는 `[data-state]` 로도 받음
* 쓰기 = `createSegment({ options: [{ value: "list", label: "목록" }, { value: "card", label: "카드" }], value: "list", onChange })`
* 대신 = B01 `.b01-sys-seg` · `.b01-home__seg` · `.b01-proj-seg` · B04 `b04-surface__sheet-method` · B05 `b05-routerec__pills` ·
@@ -37,12 +38,18 @@ export function createSegment(opts: {
/** 20 · 24 · 28 · 32 권함 */
size?: UiSize;
ariaLabel?: string;
/** 한 줄 칸 수 — 넘으면 다음 줄(없음 = 한 줄) */
columns?: number;
}): SegmentHandle {
const root = document.createElement("div");
root.className = "ui-segment";
root.setAttribute("role", "radiogroup");
if (opts.ariaLabel) root.setAttribute("aria-label", opts.ariaLabel);
setUiSize(root, opts.size ?? 28);
if (opts.columns) {
root.dataset.columns = String(opts.columns);
root.style.setProperty("--ui-segment-columns", String(opts.columns));
}
let current = opts.value;
const items = opts.options.map((o) => {
const b = document.createElement("button");