Merge remote-tracking branches 'origin/main_laptop_1' and 'origin/sub_laptop_3' into sub_laptop_1
This commit is contained in:
@@ -76,6 +76,8 @@ export interface RouteDraft {
|
||||
stationSnapStep?: number | null;
|
||||
/** 분석 지형(`las` · `sheet` · null = 확정 지표면) — ground 단계가 고를 격자. */
|
||||
terrain: string | null;
|
||||
/** 고친 예정노선(626-3 · B05 평면 그리기) — 없음 · null = 그대로 · [] = 지움 · 점 묶음 = [저장] 때 `expected_route_edit.csv`. */
|
||||
expected?: Vertex[] | null;
|
||||
}
|
||||
|
||||
/** 묶음이 받는 입력 다섯. 없으면 null(= 저장분 그대로). cross · pipes · structures 모양은 주인 몫. */
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/* 공용 긋기 방식 칸(626-3 · `map_draw_kind.ts`) — 고름 아래 길이 · 기울기 두 열 */
|
||||
.map-draw-kind {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.map-draw-kind__fields {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--spacing-8);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.map-draw-kind__fields[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.map-draw-kind__fields .ui-field {
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.map-draw-kind__fields .ui-input-unit,
|
||||
.map-draw-kind__fields .ui-input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
/* =============================================================================
|
||||
* map_draw_kind.ts
|
||||
* 공용 긋기 방식 칸(PLAN 626-3 · 599-1 · 625-1) — 예정노선(D03) 그리기 칸 · 노드 판 · B05 평면 예정노선 그리기가 같이 씀.
|
||||
* 꼴 = 마디 고름 2 × 2(자유지정 · 길이·기울기 고정 / 길이 고정 · 기울기 고정) + 길이 · 기울기 두 열(기울기 아래 ≈ °) ·
|
||||
* 방식에 안 쓰는 칸은 숨김(막힘 = 권한 없음만) · 설명 글 없음(요구 463-6).
|
||||
* 쓰는 법 = `createDrawKindFields({ value, onChange })` → { root, get, set, setDisabled }.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { UiSize } from "@ui/ui_template_button";
|
||||
import { createInputField } from "@ui/ui_template_input";
|
||||
import { createSegment } from "@ui/ui_template_segment";
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
import "./map_draw_kind.css";
|
||||
|
||||
/** 기울기 % → 각(°) 한 자리 글. */
|
||||
const slopeDegText1 = (pct: number): string => ((Math.atan(pct / 100) * 180) / Math.PI).toFixed(1);
|
||||
|
||||
/** 제약 칸 처음 값 — 길이 = 측점 간격(지식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: "자유지정",
|
||||
both: "길이·기울기 고정",
|
||||
length: "길이 고정",
|
||||
slope: "기울기 고정",
|
||||
};
|
||||
|
||||
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 = `≈ ${slopeDegText1(Math.abs(value.slopePct))}°`;
|
||||
};
|
||||
const slopeColumn = document.createElement("div");
|
||||
slopeColumn.append(slopeField.root, slopeDegText);
|
||||
const fields = document.createElement("div");
|
||||
fields.className = "map-draw-kind__fields";
|
||||
fields.append(lengthField.root, slopeColumn);
|
||||
const root = document.createElement("div");
|
||||
root.className = "map-draw-kind";
|
||||
root.append(segment.root, fields);
|
||||
|
||||
function fieldsState(): void {
|
||||
for (const button of segment.root.querySelectorAll("button")) button.disabled = disabled;
|
||||
lengthField.input.disabled = slopeField.input.disabled = disabled;
|
||||
fields.hidden = value.kind === "free";
|
||||
lengthField.root.hidden = value.kind === "slope";
|
||||
slopeColumn.hidden = 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
/* =============================================================================
|
||||
* map_forest_layers.ts
|
||||
* 공용 지도 — 받은 산림 · 환경 자료 겹(PLAN 622-2 · 624-2 · 요구 529 · 530) — 서버 줌별 조각(622-1)을 GL 고정 소스로(위성 · 등고선 615 꼴).
|
||||
* 벡터 = 국유림 임도(forest_road · 간선 · 작업 · 산불 색 · 줌 따라 굵기) · 산림입지도(forest_soil · 토양 무리 색 ·
|
||||
* 벡터 = 임도망도(forest_road · 전국 국가임도 · 간선 · 작업 · 산불 색 · 줌 따라 굵기 · 줌 14 부터 연도 · 종류 글) · 산림입지도(forest_soil · 토양 무리 색 ·
|
||||
* 반투명 · z10 아래 숨김) · 경제림(eco_forest · 국유 · 사유 테두리) /
|
||||
* 그림 = 생태자연도(ecology) · 산사태위험지도(landslide · 옛 캔버스는 받은 범위 한 장 그대로).
|
||||
* 조각만 쓰는 겹(`TILE_ONLY`) = 받기 없음 · 옛 캔버스(그래픽 카드 끔)엔 안 보임.
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
import type { ExpressionSpecification, SourceSpecification } from "maplibre-gl";
|
||||
import type { LegendItem } from "@ui/ui_template_flow_legend";
|
||||
import { tileSource, type GlSpec } from "@ui/ui_template_gl_map";
|
||||
import { GL_FONT, tileSource, type GlSpec } from "@ui/ui_template_gl_map";
|
||||
import { themeColor } from "@ui/ui_template_palette";
|
||||
import type { MapLayerKey } from "./map_layer_list";
|
||||
|
||||
@@ -147,7 +147,7 @@ export function forestAreaSpecs(): Spec[] {
|
||||
];
|
||||
}
|
||||
|
||||
/** 국유림 임도 선 — 검은 테 + 사업 구분 색(올린 임도망도 위). */
|
||||
/** 임도망도(전국 국가임도) 선 — 검은 테 + 사업 구분 색(도로 위 · 임도(업로드) 아래). */
|
||||
export function forestRoadSpecs(): Spec[] {
|
||||
const color = [
|
||||
"match",
|
||||
@@ -171,6 +171,42 @@ export function forestRoadSpecs(): Spec[] {
|
||||
];
|
||||
}
|
||||
|
||||
/** 임도망도 글(요구 534) — 줌 14 부터 선 따라 「개설 연도 · 종류」(연도 0 = 종류만) · 겹치면 뺌 · 꼴 = 지명 글. */
|
||||
export function forestRoadLabelSpec(): Spec {
|
||||
const kind = [
|
||||
"match",
|
||||
["get", "kind"],
|
||||
...ROAD_KINDS.flatMap(({ kind, label }) => [kind, label]),
|
||||
"",
|
||||
] as unknown as ExpressionSpecification;
|
||||
return {
|
||||
key: "nationalRoad",
|
||||
id: "nationalRoad-label",
|
||||
type: "symbol",
|
||||
source: "forest_road",
|
||||
"source-layer": "forest_road",
|
||||
minzoom: 14,
|
||||
layout: {
|
||||
"symbol-placement": "line",
|
||||
"symbol-spacing": 300,
|
||||
"text-field": [
|
||||
"concat",
|
||||
["case", [">", ["get", "year"], 0], ["concat", ["to-string", ["get", "year"]], " · "], ""],
|
||||
kind,
|
||||
],
|
||||
"text-font": [GL_FONT],
|
||||
"text-size": 11,
|
||||
"text-max-angle": 30,
|
||||
"text-padding": 2,
|
||||
},
|
||||
paint: {
|
||||
"text-color": themeColor("--draw-map-place", "#f8fafc"),
|
||||
"text-halo-color": "rgba(0, 0, 0, 0.7)",
|
||||
"text-halo-width": 1.5,
|
||||
},
|
||||
} as unknown as Spec;
|
||||
}
|
||||
|
||||
/** 범례 — 켠 조각 겹(산사태위험 = 범례 자리 `LANDSLIDE_LEGEND` 따로). */
|
||||
export function forestLegendRows(on: Record<MapLayerKey, boolean>): LegendItem[][] {
|
||||
const item = (label: string, color: string, kind?: LegendItem["kind"]): LegendItem => ({
|
||||
@@ -180,7 +216,7 @@ export function forestLegendRows(on: Record<MapLayerKey, boolean>): LegendItem[]
|
||||
});
|
||||
const rows: LegendItem[][] = [];
|
||||
if (on.nationalRoad)
|
||||
rows.push(ROAD_KINDS.map(({ label, color }) => item(`국유림 임도 ${label}`, color, "line")));
|
||||
rows.push(ROAD_KINDS.map(({ label, color }) => item(`임도망도 ${label}`, color, "line")));
|
||||
if (on.ecoForest)
|
||||
rows.push(ECO_KINDS.map(({ own, color }) => item(`경제림 ${own}`, color, "line")));
|
||||
if (on.ecology)
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
/* =============================================================================
|
||||
* map_layer_data.ts
|
||||
* 공용 지도 겹 자료 도움(PLAN 569-2 · 581 · 586-2 · 624-3) — D03 예정노선 · B05 평면이 같이 씀(D03 `UI_LayerDefs` 에서 옮김).
|
||||
* 임도 짐작 구분 · 물 면 도엽 이름 · 넓이 글 · 그림 읽기 · GL 자료(지물 색 붙이기 · 그림 귀퉁이 · 음영 그늘 그림).
|
||||
* 도로 소로 · 미분류 구분 · 물 면 도엽 이름 · 넓이 글 · 그림 읽기 · GL 자료(지물 색 붙이기 · 그림 귀퉁이 · 음영 그늘 그림).
|
||||
* ========================================================================== */
|
||||
|
||||
import type { GlImage } from "@ui/ui_template_gl_map";
|
||||
import type { GeoJsonCollection, PlanRaster } from "./map_layer_api";
|
||||
|
||||
/** 도로 중 기존 임도로 짐작하는 구분. */
|
||||
/** 도로 중 소로 · 미분류 구분(옛 임도 짐작 · 겹 걷음 · 페이지 자료 셈만 씀). */
|
||||
export const FOREST_ROAD_KINDS = new Set(["소로", "미분류"]);
|
||||
/** 물 면 = 도엽 수계(E) 면 셋. */
|
||||
export const WATER_KEYS = ["실폭하천", "수계면E003", "수계면E005"];
|
||||
|
||||
@@ -19,7 +19,7 @@ export type MapLayerKey =
|
||||
| "stream"
|
||||
| "water"
|
||||
| "road"
|
||||
| "forestRoad"
|
||||
| "forestRoad" // 걷은 겹(임도 짐작 · 요구 532) — 목록 · 꼴 없음 · 페이지 자료 셈 이음만 남음
|
||||
| "forestRoadFile"
|
||||
| "nationalRoad"
|
||||
| "ecoForest"
|
||||
@@ -36,7 +36,7 @@ export type MapLayerKey =
|
||||
| "building"
|
||||
| "place";
|
||||
|
||||
/** 올린 임도망 고름(605-2) — 묶음 하나 또는 그 안의 선 하나(지도 강조). */
|
||||
/** 임도(업로드) 고름(605-2) — 묶음 하나 또는 그 안의 선 하나(지도 강조). */
|
||||
export interface RoadPick {
|
||||
bundle: string;
|
||||
line?: number;
|
||||
@@ -63,23 +63,17 @@ export const GROUPS: readonly (readonly LayerDef[])[] = [
|
||||
{ key: "water", icon: "ripple", title: "물 면(도엽 실폭하천 · 저수지 등)", on: false },
|
||||
{ key: "road", icon: "road", title: "기존 도로(도엽 도로중심선)", on: false },
|
||||
{
|
||||
key: "forestRoad",
|
||||
key: "nationalRoad",
|
||||
icon: "route",
|
||||
title: "기존 임도 짐작(도엽 도로 소로 · 미분류 = 주황 점선 · 정식 임도망도 아님)",
|
||||
title:
|
||||
"임도망도(산림청 전국 국가임도 · 간선 · 작업 · 산불 색 · 지방 · 민간임도는 「임도(업로드)」로)",
|
||||
on: false,
|
||||
},
|
||||
{
|
||||
key: "forestRoadFile",
|
||||
icon: "map",
|
||||
title:
|
||||
"올린 임도망도(산림청 시군구 SHP = 밝은 분홍 실선 · 왼쪽 「위치」 칸에서 올리고 묶음 고름 · 지움)",
|
||||
on: false,
|
||||
},
|
||||
{
|
||||
key: "nationalRoad",
|
||||
icon: "flag",
|
||||
title:
|
||||
"국유림 임도(산림청 전국 국가임도 · 간선 · 작업 · 산불 색 · 지방 · 민간임도 없음 · 참고용)",
|
||||
"임도(업로드)(산림청 시군구 SHP 등 사용자가 올린 지방 · 민간임도 = 밝은 분홍 실선 · 왼쪽 「위치」 칸에서 올리고 묶음 고름 · 지움)",
|
||||
on: false,
|
||||
},
|
||||
],
|
||||
@@ -155,7 +149,7 @@ export const MAP_LAYER_PAGES: Record<
|
||||
{ hide: readonly MapLayerKey[]; on?: readonly MapLayerKey[]; pref: string }
|
||||
> = {
|
||||
d03: { hide: [], pref: "aislo:pref:d03.layers" },
|
||||
// A② 처음 = 지금 그대로(등고선 · 하천) · A⑨ 띠(타당성) · 올린 임도망도 숨김
|
||||
// A② 처음 = 지금 그대로(등고선 · 하천) · A⑨ 띠(타당성) · 임도(업로드) 숨김
|
||||
b05: {
|
||||
hide: ["bands", "forestRoadFile"],
|
||||
on: ["contour", "stream"],
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* map_layer_specs.ts
|
||||
* 공용 지도 GL 겹 꼴(PLAN 586-2 · 624-2 · 요구 530) — 바탕 겹 스물의 MapLibre 꼴 · 차례(아래 → 위):
|
||||
* 위성 · 그림 겹(음영 · 급경사 · 산사태위험 · 생태자연도) · 면 겹(산림입지 · 경제림 · 보호구역 · 띠 · 물 면 · 건물) ·
|
||||
* 선 겹(지적 · 등고선 · 도로 · 임도 짐작 · 올린 임도망도 · 국유림 임도 · 세류 · 단층 · 지질구조선 · 0차곡 · 산사태취약 필지) ·
|
||||
* 점 · 글(산사태취약 · 지명 · 등고 수치).
|
||||
* 선 겹(지적 · 등고선 · 도로 · 임도망도 · 임도(업로드) · 세류 · 단층 · 지질구조선 · 0차곡 · 산사태취약 필지) ·
|
||||
* 점 · 글(산사태취약 · 임도망도 연도 · 종류 · 지명 · 등고 수치).
|
||||
* 자료 = 받는 쪽이 지물에 붙인 `_color` · 고정 소스(위성 · 등고 · 산림 조각) 이름 = `map_forest_layers` · `ui_template_gl_map`.
|
||||
* 카메라 · 지도 세우기는 페이지 몫(D03 `UI_MapGl.createGlBase` · B05 `createTmGl`) — 여기는 꼴만.
|
||||
* ========================================================================== */
|
||||
@@ -11,7 +11,12 @@
|
||||
import type { ExpressionSpecification, FilterSpecification } from "maplibre-gl";
|
||||
import { GL_FONT, type GlSpec as GlMapSpec } from "@ui/ui_template_gl_map";
|
||||
import { themeColor } from "@ui/ui_template_palette";
|
||||
import { forestAreaSpecs, forestRasterSpecs, forestRoadSpecs } from "./map_forest_layers";
|
||||
import {
|
||||
forestAreaSpecs,
|
||||
forestRasterSpecs,
|
||||
forestRoadLabelSpec,
|
||||
forestRoadSpecs,
|
||||
} from "./map_forest_layers";
|
||||
import {
|
||||
LINE_KEYS,
|
||||
LINE_LAYERS,
|
||||
@@ -20,7 +25,7 @@ import {
|
||||
type RoadPick,
|
||||
} from "./map_layer_list";
|
||||
|
||||
/** 자료 소스(임도 짐작 = 도로 소스를 거름 · 위성 = 조각). */
|
||||
/** 자료 소스(위성 = 조각 · 걷은 임도 짐작 = 소스 없음). */
|
||||
export type GlSource = Exclude<MapLayerKey, "satellite" | "forestRoad">;
|
||||
|
||||
const GL_FONT_BOLD = [`${GL_FONT} SemiBold`, GL_FONT];
|
||||
@@ -196,19 +201,7 @@ export function mapLayerSpecs(light: number, pick: RoadPick | null): GlSpec[] {
|
||||
color: HALO,
|
||||
width: 3.2,
|
||||
}),
|
||||
...haloLine(
|
||||
"forestRoad",
|
||||
"road",
|
||||
themeColor("--draw-map-forest-road", "#f59e0b"),
|
||||
2,
|
||||
[7, 4],
|
||||
{ color: HALO, width: 3.6 },
|
||||
[
|
||||
"in",
|
||||
["get", "도로구분"],
|
||||
["literal", ["소로", "미분류"]],
|
||||
] as unknown as FilterSpecification,
|
||||
),
|
||||
...forestRoadSpecs(), // 임도망도(전국 국가임도) = 벡터 조각 · 임도(업로드) 아래
|
||||
...haloLine(
|
||||
"forestRoadFile",
|
||||
"forestRoadFile",
|
||||
@@ -217,7 +210,6 @@ export function mapLayerSpecs(light: number, pick: RoadPick | null): GlSpec[] {
|
||||
[],
|
||||
{ color: HALO, width: 4 },
|
||||
),
|
||||
...forestRoadSpecs(), // 국유림 임도 = 벡터 조각
|
||||
// 고른 묶음 · 선 강조(605-2) — 같은 자료에서 걸러 한 겹 더 굵게
|
||||
...["-pick-halo", "-pick"].map(
|
||||
(tail, i) =>
|
||||
@@ -269,6 +261,7 @@ export function mapLayerSpecs(light: number, pick: RoadPick | null): GlSpec[] {
|
||||
"circle-stroke-width": 1.5,
|
||||
},
|
||||
} as GlSpec,
|
||||
forestRoadLabelSpec(), // 임도망도 글 — 지명 아래(겹치면 지명이 앞섬)
|
||||
{
|
||||
key: "place",
|
||||
id: "place-dot",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* =============================================================================
|
||||
* map_legend.ts
|
||||
* 공용 지도 범례(PLAN 569-2 · 622-2 · 624-2 · 요구 530) — 켠 겹 범례 칸 하나(줄마다 `createLegend`) + 페이지가 붙이는 덧 줄.
|
||||
* 조각 겹 범례(산사태위험 · 국유림 임도 · 경제림 · 생태자연도) = 받을 자료 없음 → 켬끔만 보고 여기서 만듦(`tileLegendRows`).
|
||||
* 조각 겹 범례(산사태위험 · 임도망도 · 경제림 · 생태자연도) = 받을 자료 없음 → 켬끔만 보고 여기서 만듦(`tileLegendRows`).
|
||||
* 받은 자료 범례 줄(수 · 넓이 · 급경사 범위 · 624-3 D03 에서 옮김) = `legendRows(on, data)` — 받은 자료 꼴은 `LegendData`
|
||||
* (D03 옛 캔버스 자료 · B05 GL 자료 둘 다 맞음 · 받는 일은 페이지 몫).
|
||||
* 쓰는 법 = `const legend = createMapLegend("d03-plan__legend")` → `legend.show(rows, [덧 줄])` · 줄 · 덧 없으면 숨김.
|
||||
@@ -189,15 +189,9 @@ export function legendRows(
|
||||
label: `건물 ${(data.building.features?.length ?? 0).toLocaleString()}동`,
|
||||
color: "var(--draw-map-building)",
|
||||
});
|
||||
if (on.forestRoad && data.road)
|
||||
tail.push({
|
||||
label: `임도 짐작 ${data.road.small.features?.length ?? 0}가닥`,
|
||||
color: "var(--draw-map-forest-road)",
|
||||
kind: "dash",
|
||||
});
|
||||
if (on.forestRoadFile && data.forestRoadFile)
|
||||
tail.push({
|
||||
label: `올린 임도망도 ${data.forestRoadFile.info.count}가닥 ${km(data.forestRoadFile.info.length_m)}`,
|
||||
label: `임도(업로드) ${data.forestRoadFile.info.count}가닥 ${km(data.forestRoadFile.info.length_m)}`,
|
||||
color: "var(--draw-map-forest-road-file)",
|
||||
kind: "line",
|
||||
});
|
||||
|
||||
@@ -45,6 +45,13 @@ export const MAP_TOOLBAR: readonly (readonly MapToolRow[])[] = [
|
||||
{ key: "station-snap", does: "곡선 가운데(MC)를 간격 배수에 맞춤", pages: B05 },
|
||||
{ key: "snap-step", name: "측점 스냅 간격", does: "스냅 간격 고르기", pages: B05 },
|
||||
],
|
||||
[
|
||||
{
|
||||
key: "expected-draw",
|
||||
does: "예정노선 그리기 판 켬끔 — 고친 예정노선 따라 평면선 다시 추적(626-3)",
|
||||
pages: B05,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
key: "recommend",
|
||||
|
||||
@@ -73,6 +73,8 @@ export interface RoutePlanResponse {
|
||||
project_id: string;
|
||||
/** 예상노선(원본) **점 묶음** [[x, y], …] — 사업지 좌표계(m). 폴리라인이 아니다. */
|
||||
expected: Array<[number, number]>;
|
||||
/** 고친 예정노선(626-2 · [저장] 때 `expected_route_edit.csv`) — 없으면 빈 목록 · 원본 `expected` 는 그대로. */
|
||||
expected_edited?: Array<[number, number]>;
|
||||
/** 계획노선 폴리라인(원호 포함) — 그려 보이는 선. 잡는 대상이 아니다. */
|
||||
planned: Array<[number, number]>;
|
||||
/** 잡아 옮기는 노드(꺾임점). 편집은 이것으로 한다(2026-09-06 사용자 지시). */
|
||||
|
||||
@@ -0,0 +1,442 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_ExpectedDraw.ts
|
||||
* B05 평면 예정노선 그리기(PLAN 626-3 · 요구 530-11 · 12 · 결정 D3 · D4 · D9 · D10) — 도구줄 「예정노선 그리기」 칸.
|
||||
* 켬 = 떠 있는 판(새로 긋기 · 구간 다시 긋기 · 지움 + 공용 방식 칸 `map_draw_kind`) · 고치는 예정노선이 맨 위 ·
|
||||
* 캔버스 왼쪽 누름 · 맴돎 · 두 번 클릭 · 오른쪽 클릭을 먼저 받음 = 계획선 · 구조물 · 등고선 고르기 · 재기(Shift+누름) ·
|
||||
* 노드 넣기 · 지움 막음 · 가운데 끌기 · 휠은 그대로.
|
||||
* 긋기 = D03 과 같은 방식 칸 · 부채꼴 · +/− 색 · Shift 사이 기울기 · Tab(자유지정 ↔ 길이·기울기) · Enter 끝 · Esc 취소 ·
|
||||
* Backspace 마지막 점 빼기. 구간 다시 긋기 = 예정노선 위를 눌러 시작 · 다시 예정노선 위를 누르면 끝(그 사이 갈음 ·
|
||||
* Enter = 취소). 지움 = 고친 예정노선 버리고 파일입력 예정노선으로.
|
||||
* 끝 → 확인창(직접 고친 평면 버림 · 예정노선 따라 통째 다시 추적) → `POST /route/follow`(저장 0) →
|
||||
* `onFollow(평면, 예정노선)` = 편집기 한 걸음(되돌리기 됨) → 페이지 [저장] 이 손 편집과 같은 길 + 초안 `expected`.
|
||||
* 예정노선 세 벌 = 파일입력(`expected` · 안 바뀜) · 저장본(`expected_edited`) · 이번 고침(`edit` · 걸음 사진에 실림).
|
||||
* 셈 = `logic/B05_Profile_ExpectedDraw` · 그림 = D03 `paintPreview` · `strokeCased` · `slopeColor` 그대로.
|
||||
* ========================================================================== */
|
||||
|
||||
import { keyIs } from "../A00_Common/key_registry";
|
||||
import { listenWhileAlive } from "@ui/ui_template_window_watch";
|
||||
import { createButton, showToast } from "@ui/ui_template_elements";
|
||||
import { createFloatPanel } from "@ui/ui_template_float_panel";
|
||||
import { askConfirm } from "@ui/ui_template_modal";
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
import { heightAt as gridHeightAt } from "../common_util/common_util_surface_sampler";
|
||||
import {
|
||||
createDrawKindFields,
|
||||
DRAW_DEFAULT_LENGTH_M,
|
||||
DRAW_DEFAULT_SLOPE_PCT,
|
||||
type DrawConstraint,
|
||||
} from "../A00_Common/map/map_draw_kind";
|
||||
import { groundSlopePct, segmentLength } from "../D03_PlanAnalysis/logic/D03_PlanAnalysis_Draw";
|
||||
import type { Fan } from "../D03_PlanAnalysis/logic/D03_PlanAnalysis_Fan";
|
||||
import {
|
||||
paintPreview,
|
||||
slopeColor,
|
||||
strokeCased,
|
||||
} from "../D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint";
|
||||
import type { DrawPreview } from "../D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw";
|
||||
import { planColors } from "../D03_PlanAnalysis/D03_PlanAnalysis_UI_Map";
|
||||
import { requestJson, type RoutePlanResponse } from "./B05_Profile_Api_Replan";
|
||||
import { loadDesignTerrainGrid, type DesignTerrainGrid } from "./B05_Profile_Api_TerrainGrid";
|
||||
import { flattenServerPlan, type FlattenedPlan } from "./logic/B05_Profile_UI_RouteEdit_Edits";
|
||||
import {
|
||||
leadBefore,
|
||||
nearestOnLine,
|
||||
pickNext,
|
||||
respliceLine,
|
||||
} from "./logic/B05_Profile_ExpectedDraw";
|
||||
|
||||
type Vertex = [number, number];
|
||||
|
||||
/** 예정노선 위로 볼 반경(px) — D03 붙음 반경과 같음. */
|
||||
const SNAP_PX = 12;
|
||||
const IDLE_HINT = "새로 긋기 · 구간 다시 긋기 · 지움 중 하나를 고릅니다.";
|
||||
const KEYS_HINT = "Enter 끝 · Esc 취소 · Backspace 점 빼기 · Tab 방식 · Shift 사이 기울기";
|
||||
|
||||
/** `POST /route/follow` 응답 — GET `/route/plan` 칸 이름(626-2). */
|
||||
type FollowResponse = Pick<RoutePlanResponse, "planned" | "nodes" | "curves" | "node_edits">;
|
||||
|
||||
export interface ExpectedDrawParams {
|
||||
projectId: string;
|
||||
canvas: HTMLCanvasElement;
|
||||
/** 도구줄 칸 · 판을 얹을 도구 줄 칸. */
|
||||
button: HTMLButtonElement;
|
||||
host: HTMLElement;
|
||||
toScreen: (vertex: Vertex) => [number, number];
|
||||
toMetric: (px: number, py: number) => Vertex;
|
||||
/** 보이는 화면 px → 그리기 px(돌린 지도). */
|
||||
unrotate: (px: number, py: number) => [number, number];
|
||||
pxPerMeter: () => number;
|
||||
uprightRad: () => number;
|
||||
redraw: () => void;
|
||||
/** 판을 켬 — 편집기가 고른 노드 · 곡선 판을 풂. */
|
||||
onOpen: () => void;
|
||||
/** 다시 추적한 평면 · 고친 예정노선([] = 지움) — 편집기 한 걸음. */
|
||||
onFollow: (plan: FlattenedPlan, expected: Vertex[]) => void;
|
||||
}
|
||||
|
||||
export interface ExpectedDraw {
|
||||
/** 노선을 읽음 — 파일입력 예정노선 · 저장본(없으면 빈). */
|
||||
load: (original: Vertex[], saved: Vertex[]) => void;
|
||||
/** 이번 고침(걸음 사진 칸) — null = 연 때 그대로. */
|
||||
edit: () => Vertex[] | null;
|
||||
setEdit: (edit: Vertex[] | null) => void;
|
||||
/** 지금 보일 예정노선(같은 배열을 돌려줌 — 재기 높이 표가 배열로 낡음을 봄). */
|
||||
line: () => Vertex[];
|
||||
paint: (context: CanvasRenderingContext2D) => void;
|
||||
/** 검증 창구 — 판 켬 · 긋는 중 · 미리보기. */
|
||||
state: () => {
|
||||
open: boolean;
|
||||
stroke: string | null;
|
||||
points: number;
|
||||
preview: DrawPreview | null;
|
||||
};
|
||||
}
|
||||
|
||||
type Stroke =
|
||||
| { mode: "new"; points: Vertex[] }
|
||||
| { mode: "respan"; start: { atM: number; point: Vertex } | null; through: Vertex[] };
|
||||
|
||||
export function createExpectedDraw(params: ExpectedDrawParams): ExpectedDraw {
|
||||
const { canvas } = params;
|
||||
let original: Vertex[] = [];
|
||||
let saved: Vertex[] = [];
|
||||
let edit: Vertex[] | null = null;
|
||||
let open = false;
|
||||
let busy = false;
|
||||
let stroke: Stroke | null = null;
|
||||
let preview: DrawPreview | null = null;
|
||||
let fan: Fan | null = null;
|
||||
let hoverAt: { px: number; py: number; shift: boolean } | null = null;
|
||||
let grid: DesignTerrainGrid | null = null;
|
||||
let kind: DrawConstraint = "both"; // 기억 안 함(B9)
|
||||
let lengthM = DRAW_DEFAULT_LENGTH_M;
|
||||
let slopePct = DRAW_DEFAULT_SLOPE_PCT;
|
||||
|
||||
const line = (): Vertex[] =>
|
||||
edit === null ? (saved.length > 1 ? saved : original) : edit.length > 1 ? edit : original;
|
||||
const z = (x: number, y: number): number => (grid ? gridHeightAt(grid.grid, x, y) : NaN);
|
||||
|
||||
// ── 판 ─────────────────────────────────────────────────
|
||||
const panel = createFloatPanel({ title: "예정노선 그리기", onClose: () => setOpen(false) });
|
||||
panel.root.classList.add("b05-routeedit__expected-draw");
|
||||
panel.root.hidden = true;
|
||||
const hint = createText(IDLE_HINT, "muted");
|
||||
const kindFields = createDrawKindFields({
|
||||
value: { kind, lengthM, slopePct },
|
||||
onChange: (value) => {
|
||||
({ kind, lengthM, slopePct } = value);
|
||||
rehover();
|
||||
},
|
||||
});
|
||||
const row = document.createElement("div");
|
||||
row.className = "b05-routeedit__expected-row";
|
||||
row.append(
|
||||
createButton({ label: "새로 긋기", variant: "filled", size: 28, onClick: () => begin("new") }),
|
||||
createButton({
|
||||
label: "구간 다시 긋기",
|
||||
variant: "ghost",
|
||||
size: 28,
|
||||
onClick: () => begin("respan"),
|
||||
}),
|
||||
createButton({ label: "지움", variant: "ghost", size: 28, onClick: () => void clear() }),
|
||||
);
|
||||
panel.body.append(row, kindFields.root, hint);
|
||||
params.host.append(panel.root);
|
||||
params.button.addEventListener("click", () => setOpen(!open));
|
||||
|
||||
function setOpen(next: boolean): void {
|
||||
open = next;
|
||||
stroke = null;
|
||||
preview = fan = null;
|
||||
params.button.setAttribute("aria-pressed", String(open));
|
||||
params.button.classList.toggle("is-active", open);
|
||||
panel.root.hidden = !open;
|
||||
hint.textContent = IDLE_HINT;
|
||||
canvas.style.removeProperty("cursor");
|
||||
if (open) {
|
||||
params.onOpen();
|
||||
void loadDesignTerrainGrid(params.projectId).then((loaded) => (grid = loaded));
|
||||
}
|
||||
params.redraw();
|
||||
}
|
||||
|
||||
function begin(mode: Stroke["mode"]): void {
|
||||
if (busy) return;
|
||||
if (mode === "respan" && line().length < 2)
|
||||
return showToast("다시 그을 예정노선이 없습니다 — 새로 긋기를 쓰세요.", "info");
|
||||
stroke = mode === "new" ? { mode, points: [] } : { mode, start: null, through: [] };
|
||||
preview = fan = null;
|
||||
hint.textContent =
|
||||
(mode === "new"
|
||||
? "첫 점을 누릅니다 · "
|
||||
: "예정노선 위를 눌러 시작 · 다시 예정노선 위를 누르면 끝 · ") + KEYS_HINT;
|
||||
canvas.style.cursor = "crosshair";
|
||||
params.redraw();
|
||||
}
|
||||
|
||||
function stop(message = IDLE_HINT): void {
|
||||
stroke = null;
|
||||
preview = fan = null;
|
||||
hint.textContent = message;
|
||||
canvas.style.removeProperty("cursor");
|
||||
params.redraw();
|
||||
}
|
||||
|
||||
// ── 셈 ──────────────────────────────────────────────────
|
||||
/** 화면 px 둘레 예정노선 위 자리 — 멀면 null. */
|
||||
function onLine(px: number, py: number): { atM: number; point: Vertex } | null {
|
||||
const shown = line();
|
||||
if (shown.length < 2) return null;
|
||||
const hit = nearestOnLine(shown, params.toMetric(px, py));
|
||||
const [sx, sy] = params.toScreen(hit.point);
|
||||
return Math.hypot(sx - px, sy - py) <= SNAP_PX ? hit : null;
|
||||
}
|
||||
|
||||
/** 그은 점(구간 다시 긋기 = 시작 자리 + 속 점). */
|
||||
function strokePoints(): Vertex[] {
|
||||
if (!stroke) return [];
|
||||
if (stroke.mode === "new") return stroke.points;
|
||||
return stroke.start ? [stroke.start.point, ...stroke.through] : [];
|
||||
}
|
||||
|
||||
function previewAt(px: number, py: number, shift: boolean): DrawPreview | null {
|
||||
const points = strokePoints();
|
||||
const from = points[points.length - 1];
|
||||
if (!stroke || !from) return null;
|
||||
const cursor = params.toMetric(px, py);
|
||||
const prev =
|
||||
points.length > 1
|
||||
? points[points.length - 2]
|
||||
: stroke.mode === "respan" && stroke.start
|
||||
? leadBefore(line(), stroke.start.atM)
|
||||
: null;
|
||||
const end = stroke.mode === "respan" ? onLine(px, py) : null;
|
||||
const pick = pickNext(kind, from, cursor, lengthM, slopePct, z, prev, shift);
|
||||
fan = pick.fan;
|
||||
const point = end ? end.point : pick.point;
|
||||
const at = point ?? cursor;
|
||||
const length = segmentLength(from, at);
|
||||
return {
|
||||
point: at,
|
||||
lengthM: length,
|
||||
slopePct: groundSlopePct(from, at, z),
|
||||
note: end ? "예정노선에 붙음 — 끝" : pick.note,
|
||||
blocked: !point || length <= 0,
|
||||
snapped: !!end,
|
||||
lengthOnly: pick.lengthOnly && !end,
|
||||
onLine: null,
|
||||
};
|
||||
}
|
||||
|
||||
function rehover(): void {
|
||||
if (hoverAt) preview = previewAt(hoverAt.px, hoverAt.py, hoverAt.shift);
|
||||
params.redraw();
|
||||
}
|
||||
|
||||
function press(px: number, py: number, shift: boolean): void {
|
||||
if (!stroke || busy) return void (open && !stroke && showToast(IDLE_HINT, "info"));
|
||||
if (stroke.mode === "new" && !stroke.points.length) {
|
||||
stroke.points.push(params.toMetric(px, py));
|
||||
return rehover();
|
||||
}
|
||||
if (stroke.mode === "respan" && !stroke.start) {
|
||||
stroke.start = onLine(px, py);
|
||||
if (!stroke.start) showToast("예정노선 위를 눌러 시작합니다.", "info");
|
||||
return rehover();
|
||||
}
|
||||
preview = previewAt(px, py, shift);
|
||||
if (!preview || preview.blocked)
|
||||
return showToast(preview?.note ?? "여기에는 점을 찍을 수 없습니다.", "info");
|
||||
if (stroke.mode === "respan" && preview.snapped) {
|
||||
const end = onLine(px, py)!;
|
||||
const next = respliceLine(line(), stroke.start!.atM, end.atM, stroke.through);
|
||||
return void follow(next);
|
||||
}
|
||||
if (stroke.mode === "new") stroke.points.push(preview.point);
|
||||
else stroke.through.push(preview.point);
|
||||
rehover();
|
||||
}
|
||||
|
||||
function finish(): void {
|
||||
if (!stroke) return;
|
||||
if (stroke.mode === "respan")
|
||||
return stop("구간 다시 긋기는 예정노선 위를 눌러 끝 — 취소했습니다.");
|
||||
if (stroke.points.length < 2) return void showToast("점이 2개 이상이어야 합니다.", "info");
|
||||
void follow([...stroke.points]);
|
||||
}
|
||||
|
||||
/** 다시 추적 — 확인창 → `/route/follow`(저장 0) → 편집기 한 걸음. `next` = [] 면 파일입력 예정노선. */
|
||||
async function follow(next: Vertex[]): Promise<void> {
|
||||
stop();
|
||||
const ok = await askConfirm({
|
||||
title: "평면선 다시 추적",
|
||||
message:
|
||||
(next.length ? "고친 예정노선" : "파일입력 예정노선") +
|
||||
"을 따라 평면선을 통째로 다시 추적합니다 — 직접 고친 평면(노드 · 곡선)은 버립니다. " +
|
||||
"[저장] 전까지는 되돌리기로 물릴 수 있습니다.",
|
||||
okLabel: "다시 추적",
|
||||
});
|
||||
if (!ok) return;
|
||||
busy = true;
|
||||
try {
|
||||
const points = next.length ? next : original;
|
||||
const plan = await requestJson<FollowResponse>(
|
||||
`/projects/${params.projectId}/route/follow`,
|
||||
{ method: "POST", body: JSON.stringify({ points, crs: "tm" }) },
|
||||
60000,
|
||||
);
|
||||
params.onFollow(
|
||||
flattenServerPlan(plan.nodes ?? [], plan.curves ?? [], plan.node_edits),
|
||||
next,
|
||||
);
|
||||
} catch (error) {
|
||||
showToast(
|
||||
`평면선을 다시 추적하지 못했습니다.${error instanceof Error ? ` ${error.message}` : ""}`,
|
||||
"error",
|
||||
);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function clear(): Promise<void> {
|
||||
if (busy) return;
|
||||
if (line() === original) return showToast("고친 예정노선이 없습니다.", "info");
|
||||
await follow([]);
|
||||
}
|
||||
|
||||
// ── 누름 · 키 — 판이 켜진 동안 먼저 받음(계획선 · 구조물 · 재기로 안 샘) ──
|
||||
const at = (event: MouseEvent): [number, number] => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
return params.unrotate(event.clientX - rect.left, event.clientY - rect.top);
|
||||
};
|
||||
const claim = (event: Event): void => {
|
||||
event.stopImmediatePropagation();
|
||||
event.preventDefault();
|
||||
};
|
||||
canvas.addEventListener(
|
||||
"pointerdown",
|
||||
(event) => {
|
||||
if (!open || event.button !== 0) return;
|
||||
claim(event);
|
||||
press(...at(event), event.shiftKey);
|
||||
},
|
||||
true,
|
||||
);
|
||||
canvas.addEventListener(
|
||||
"pointermove",
|
||||
(event) => {
|
||||
if (!open || event.buttons) return; // 가운데 끌기 = 지도 이동 그대로
|
||||
event.stopImmediatePropagation();
|
||||
const [px, py] = at(event);
|
||||
hoverAt = { px, py, shift: event.shiftKey };
|
||||
if (stroke) rehover();
|
||||
},
|
||||
true,
|
||||
);
|
||||
for (const type of ["pointerup", "click", "dblclick", "contextmenu"])
|
||||
canvas.addEventListener(
|
||||
type,
|
||||
(event) => {
|
||||
if (open && (event as MouseEvent).button !== 1) claim(event);
|
||||
},
|
||||
true,
|
||||
);
|
||||
canvas.addEventListener("pointerleave", () => {
|
||||
hoverAt = null;
|
||||
if (preview) ((preview = null), params.redraw());
|
||||
});
|
||||
|
||||
const onKey = (event: KeyboardEvent): void => {
|
||||
if (!open || (event.target as HTMLElement | null)?.closest?.("input, select, textarea")) return;
|
||||
if (!stroke) return;
|
||||
if (event.key === "Shift") {
|
||||
if (hoverAt) ((hoverAt.shift = event.type === "keydown"), rehover());
|
||||
return;
|
||||
}
|
||||
if (event.type !== "keydown") return;
|
||||
if (keyIs(event, "plan.drawKind")) {
|
||||
kind = kind === "both" ? "free" : "both"; // 단독 고정이면 길이·기울기로(625-1)
|
||||
kindFields.set({ kind });
|
||||
rehover();
|
||||
} else if (keyIs(event, "plan.drawFinish")) finish();
|
||||
else if (event.key === "Escape") stop();
|
||||
else if (keyIs(event, "plan.drawBack")) {
|
||||
const points = stroke.mode === "new" ? stroke.points : stroke.through;
|
||||
if (!points.pop() && stroke.mode === "respan") stroke.start = null;
|
||||
rehover();
|
||||
} else return;
|
||||
claim(event);
|
||||
};
|
||||
listenWhileAlive(window, "keydown", canvas, onKey, true);
|
||||
listenWhileAlive(window, "keyup", canvas, onKey, true);
|
||||
|
||||
// ── 그림 ────────────────────────────────────────────────
|
||||
const map = {
|
||||
toScreen: params.toScreen,
|
||||
pxPerMeter: 1,
|
||||
upright(context: CanvasRenderingContext2D, x: number, y: number, paint: () => void): void {
|
||||
const radians = params.uprightRad();
|
||||
if (!radians) return paint();
|
||||
context.save();
|
||||
context.translate(x, y);
|
||||
context.rotate(radians);
|
||||
context.translate(-x, -y);
|
||||
paint();
|
||||
context.restore();
|
||||
},
|
||||
};
|
||||
const path = (context: CanvasRenderingContext2D, points: ReadonlyArray<Vertex>): void => {
|
||||
context.beginPath();
|
||||
points.forEach((point, i) => {
|
||||
const [sx, sy] = params.toScreen(point);
|
||||
if (i) context.lineTo(sx, sy);
|
||||
else context.moveTo(sx, sy);
|
||||
});
|
||||
};
|
||||
|
||||
function paint(context: CanvasRenderingContext2D): void {
|
||||
if (!open) return;
|
||||
const colors = planColors();
|
||||
map.pxPerMeter = params.pxPerMeter();
|
||||
context.save();
|
||||
context.lineCap = "round";
|
||||
context.lineJoin = "round";
|
||||
path(context, line()); // 고치는 예정노선 = 맨 위(D9)
|
||||
strokeCased(context, colors.measure, 2.5, [8, 5]);
|
||||
const points = strokePoints();
|
||||
if (points.length > 1) {
|
||||
path(context, points);
|
||||
strokeCased(context, colors.active, 3);
|
||||
}
|
||||
points.forEach((point, i) => {
|
||||
const [sx, sy] = params.toScreen(point);
|
||||
context.beginPath();
|
||||
context.arc(sx, sy, 3.5, 0, Math.PI * 2);
|
||||
context.fillStyle = i ? slopeColor(groundSlopePct(points[i - 1], point, z)) : colors.active;
|
||||
context.fill();
|
||||
});
|
||||
const from = points[points.length - 1];
|
||||
if (from && preview) paintPreview(context, map, from, preview, fan ? { lengthM, fan } : null);
|
||||
context.restore();
|
||||
}
|
||||
|
||||
return {
|
||||
load(nextOriginal, nextSaved) {
|
||||
original = nextOriginal;
|
||||
saved = nextSaved;
|
||||
},
|
||||
edit: () => edit,
|
||||
setEdit(next) {
|
||||
edit = next;
|
||||
},
|
||||
line,
|
||||
paint,
|
||||
state: () => ({
|
||||
open,
|
||||
stroke: stroke?.mode ?? null,
|
||||
points: strokePoints().length,
|
||||
preview,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
type ViewState,
|
||||
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
|
||||
import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import {
|
||||
bindMinorContourToggle,
|
||||
createSheetFollow,
|
||||
@@ -41,12 +40,13 @@ import {
|
||||
import { bindRouteApply } from "./B05_Profile_UI_RouteEdit_Apply";
|
||||
import type { EditedCurve, EditedNode } from "./logic/B05_Profile_UI_RouteEdit_Curve";
|
||||
import { drawRouteEditScene, routeHeadText, watchTheme } from "./B05_Profile_UI_RouteEdit_Render";
|
||||
import { bindNodeSplice, bindRouteEditNavigation } from "./B05_Profile_UI_RouteEdit_Input";
|
||||
import { bindRouteEditMouse } from "./B05_Profile_UI_RouteEdit_Mouse";
|
||||
import { bindRouteEditPointer } from "./B05_Profile_UI_RouteEdit_Pointer";
|
||||
import { createCrossPreview } from "./B05_Profile_UI_RouteEdit_Cross";
|
||||
import { createMapRotation } from "./B05_Profile_UI_RouteEdit_Rotate";
|
||||
import { createRouteEditChrome } from "./B05_Profile_UI_RouteEdit_Chrome";
|
||||
import { bindMeasureControls, createMeasureTool } from "./B05_Profile_UI_RouteEdit_Measure";
|
||||
import { createExpectedDraw } from "./B05_Profile_UI_ExpectedDraw";
|
||||
import { createRouteMeasure } from "./B05_Profile_UI_RouteEdit_Measure";
|
||||
import { createSlopeLabels } from "./B05_Profile_UI_RouteEdit_Slope";
|
||||
import {
|
||||
captureView,
|
||||
@@ -118,6 +118,26 @@ export async function mountRouteEditor(
|
||||
const { minorContourButton, stationSnapButton } = chrome;
|
||||
chrome.onRedraw(() => draw()); // 겹 켬끔 · 추천 보기 = 장면 새로(624-3)
|
||||
const context = canvas.getContext("2d")!;
|
||||
/** 예정노선 그리기(626-3) — 고친 예정노선 따라 평면 다시 추적 = 편집기 한 걸음 · 배선은 `_ExpectedDraw` 몫. */
|
||||
const expectedDraw = createExpectedDraw({
|
||||
projectId,
|
||||
canvas,
|
||||
button: chrome.expectedDrawButton,
|
||||
host: overlay.querySelector<HTMLElement>(".b05-routeedit__tools")!,
|
||||
toScreen: (vertex) => toScreen(vertex),
|
||||
toMetric: (px, py) => toMetric(px, py),
|
||||
unrotate: (px, py) => rotation.unrotate(px, py),
|
||||
pxPerMeter: () => pxPerMeter(),
|
||||
uprightRad: () => rotation.uprightRad(),
|
||||
redraw: () => draw(),
|
||||
onOpen: () => ((picked = -1), syncCurveBar()),
|
||||
onFollow: (flat, line) =>
|
||||
restoreSnapshot(
|
||||
{ ...flat, apexLock: [], picked: -1, rec: initial.tag(), expected: line },
|
||||
"예정노선을 따라 평면을 다시 추적했습니다.",
|
||||
true,
|
||||
),
|
||||
});
|
||||
|
||||
let expected: Vertex[] = [];
|
||||
/** 그려 보이는 계획노선 — 원호가 섞인 폴리라인. **잡는 대상이 아니다.** */
|
||||
@@ -191,22 +211,16 @@ export async function mountRouteEditor(
|
||||
// 횡단이 뜨면 그 측점 반폭 선을 그린다 — 새로 고침 뒤 고른 측점은 만드는 도중 불려 아래 선언이 아직이라 한 박자 뒤에(505-4).
|
||||
onShown: () => queueMicrotask(draw),
|
||||
});
|
||||
/** 구간 재기 — 계획·예상 노선 위 두 점. 셈·서버 묻기는 `_Measure` 몫(계획서 0-9 ⑤). */
|
||||
const measure = createMeasureTool({
|
||||
/** 구간 재기 — 계획·예상 노선 위 두 점 · 재기 단추·작은 창. 셈·서버 묻기 · 배선은 `_Measure` 몫(계획서 0-9 ⑤). */
|
||||
const { measure, measureControls } = createRouteMeasure({
|
||||
projectId,
|
||||
stationIntervalM,
|
||||
lines: () => ({ planned: plannedLine.length ? plannedLine : planned, expected }),
|
||||
lines: () => ({
|
||||
planned: plannedLine.length ? plannedLine : planned,
|
||||
expected: expectedDraw.line(),
|
||||
}),
|
||||
toScreen: (vertex) => toScreen(vertex),
|
||||
isClosed: () => closed,
|
||||
onChange: () => {
|
||||
measureControls.sync();
|
||||
draw();
|
||||
},
|
||||
onHint: () => measureControls.sync(),
|
||||
});
|
||||
/** 재기 단추·작은 창 — 배선은 `_Measure` 몫. */
|
||||
const measureControls = bindMeasureControls({
|
||||
measure,
|
||||
box: measureBox,
|
||||
text: measureText,
|
||||
button: measureButton,
|
||||
@@ -332,7 +346,8 @@ export async function mountRouteEditor(
|
||||
}),
|
||||
measure: measure.marks(),
|
||||
crossWidth: crossPreview.watched(),
|
||||
expected,
|
||||
expected: expectedDraw.line(),
|
||||
overAll: (ctx) => expectedDraw.paint(ctx), // 예정노선 그리기 맨 위(626-3)
|
||||
plannedLine,
|
||||
planned,
|
||||
nodeInfo,
|
||||
@@ -367,7 +382,7 @@ export async function mountRouteEditor(
|
||||
|
||||
/** 상태줄 머리 · 고른 등고선 글 — 짓기는 `_Render` · `_Contour` 몫. */
|
||||
const routeHead = (): string =>
|
||||
routeHeadText(expected, plannedLine.length ? plannedLine : planned, planned.length);
|
||||
routeHeadText(expectedDraw.line(), plannedLine.length ? plannedLine : planned, planned.length);
|
||||
const contourHint = (): string => contourHintText(contours, pickedContour);
|
||||
|
||||
const offStation = (): number =>
|
||||
@@ -468,6 +483,7 @@ export async function mountRouteEditor(
|
||||
apexLock,
|
||||
picked,
|
||||
rec: initial.tag(),
|
||||
expected: expectedDraw.edit(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -476,6 +492,7 @@ export async function mountRouteEditor(
|
||||
// 초기노선 싣기는 새 노선이다 — 고정은 좌표로 다시 짚는다. 되돌리기는 그 사진의 고정 그대로.
|
||||
const locks = record ? routeLocksOf(planned, apexLock) : null;
|
||||
({ planned, curveOn, curveRadius, curveLock, curveArc, apexLock, picked } = snapshot);
|
||||
expectedDraw.setEdit(snapshot.expected ?? null); // 고친 예정노선도 그 걸음 것(626-3)
|
||||
if (locks) apexLock = lockColumn(planned, locks);
|
||||
applyEdit(message, record, false); // 되돌리기 · 추천 [적용] · 초안 되살림은 안 맞춤(366-3)
|
||||
}
|
||||
@@ -547,37 +564,18 @@ export async function mountRouteEditor(
|
||||
offStation,
|
||||
});
|
||||
|
||||
bindNodeSplice({
|
||||
canvas,
|
||||
unrotate: rotation.unrotate,
|
||||
segmentAt: pointer.segmentAt,
|
||||
nodeAt: pointer.nodeAt,
|
||||
insert: (segment, px, py) => {
|
||||
edits.spliceNode(snapshotNow(), segment + 1, toMetric(px, py)); // 편집값도 같은 자리에 끼운다.
|
||||
picked = segment + 1;
|
||||
applyEdit("새 노드를 넣었습니다(직선 추가).");
|
||||
},
|
||||
remove: (index) => {
|
||||
if (planned.length <= 2) return showToast("노선은 노드가 2개 이상이어야 합니다.", "error");
|
||||
if (apexLock[index])
|
||||
return showToast("고정한 점은 못 지웁니다 — 고정을 먼저 푸세요.", "error");
|
||||
edits.spliceNode(snapshotNow(), index);
|
||||
picked = -1;
|
||||
applyEdit("노드를 지웠습니다(직선 삭제).");
|
||||
},
|
||||
});
|
||||
|
||||
bindRouteEditNavigation({
|
||||
// 두 번 클릭 삽입 · 오른쪽 클릭 삭제 · 지도 이동 · 확대 · 돌리기 — 배선은 `_Mouse` 몫.
|
||||
bindRouteEditMouse({
|
||||
canvas,
|
||||
rotation,
|
||||
pointer,
|
||||
toMetric,
|
||||
getView: () => view,
|
||||
setView: (next) => {
|
||||
view = next;
|
||||
},
|
||||
setView: (next) => (view = next),
|
||||
getMeta: () => meta,
|
||||
// 돌린 지도에서는 손이 민 방향과 그림이 움직일 방향이 다르다 — 거꾸로 돌려 넘긴다.
|
||||
unrotateDelta: rotation.unrotateDelta,
|
||||
rotateWheel: rotation.wheel, // Ctrl+휠 = 5° 돌리기(390-3)
|
||||
claimed: pointer.claimed, // 왼쪽 옮기기 = 노드 · 손잡이 · 마커 · 재기 아닌 누름만(588-4)
|
||||
snapshot: snapshotNow,
|
||||
setPicked: (index) => (picked = index),
|
||||
applyEdit: (message) => applyEdit(message),
|
||||
draw,
|
||||
});
|
||||
|
||||
@@ -586,6 +584,7 @@ export async function mountRouteEditor(
|
||||
nodes: () => ({ planned, curveOn, curveRadius, curveLock, curveArc }),
|
||||
changed: () => (!!history && lineChanged(history)) || otherChanged(),
|
||||
snap: snap.draft,
|
||||
expected: () => expectedDraw.edit(),
|
||||
initial,
|
||||
onApplied,
|
||||
});
|
||||
@@ -605,6 +604,7 @@ export async function mountRouteEditor(
|
||||
]);
|
||||
if (closed) return;
|
||||
expected = plan.expected as Vertex[];
|
||||
expectedDraw.load(expected, (plan.expected_edited ?? []) as Vertex[]); // 고친 예정노선 저장본(626-3)
|
||||
plannedLine = (plan.planned as Vertex[]).map((vertex) => [vertex[0], vertex[1]]);
|
||||
// 잡는 것은 **노드**다 — 폴리라인 정점에는 원호 위 점이 섞여 있어 편집 대상이 아니다
|
||||
// (2026-09-06 사용자 지시: 노드를 제어해 계획노선을 고친다).
|
||||
|
||||
@@ -56,6 +56,8 @@ export interface RouteApplyParams {
|
||||
changed: () => boolean;
|
||||
/** [측점 스냅] 켬끔(요구 224-2) · 간격(390-5) — 곡선표에 적혀 측점 셈이 읽음. */
|
||||
snap: () => SnapSetting;
|
||||
/** 고친 예정노선(626-3) — null = 그대로(안 실음) · [] = 지움 · 점 묶음 = `expected_route_edit.csv`. */
|
||||
expected?: () => Vertex[] | null;
|
||||
/** 초기노선 갈래 — 저장에 지금 추천을 싣고, 결과 캐시를 비운 뒤 다시 적는다. */
|
||||
initial: {
|
||||
current: () => unknown;
|
||||
@@ -98,8 +100,9 @@ export function bindRouteApply(params: RouteApplyParams): () => void {
|
||||
pending: async () => {
|
||||
const nodes = params.nodes();
|
||||
if (nodes.planned.length < 2 || !params.changed()) return null;
|
||||
const expected = params.expected?.();
|
||||
return {
|
||||
draft: routeDraftOf(nodes, settings()),
|
||||
draft: { ...routeDraftOf(nodes, settings()), ...(expected ? { expected } : {}) },
|
||||
initial: params.initial.current(),
|
||||
// 손으로 고친 노선 = 계획고 옮김만(344-2 · 349-0v) — 균형 종단은 서버 추천 [적용](`as_initial`) 길만.
|
||||
};
|
||||
|
||||
@@ -21,7 +21,7 @@ import { bottomRowNarrow, infoLiftPx } from "./logic/B05_Profile_RouteEdit_InfoL
|
||||
* (지도 왼쪽 위 · ☰ 비킴), [?] 조작 설명은 도구줄 아래 2열, 상태·범례는 왼쪽 아래, 잰 값은 오른쪽
|
||||
* 아래. 횡단 두 판은 지도 오른쪽을 덮는 판(아래 종단 판이 그 위).
|
||||
* 도구줄 차례 = 공용 표(624-3 · `A00_Common/map/map_toolbar` 「b05」) — [?] | ↶ ↷ | 3D ⟲ ⟳ | 1m 등고선 · 측점 · 기울기 ·
|
||||
* 절/성토선 · 거리 재기 | 측점 스냅 · 간격 | 추천 노선 보기(626-1) | 설계 지형 칩(626-1). 아래 줄 = 공용 겹 줄(`_Layers` ·
|
||||
* 절/성토선 · 거리 재기 | 측점 스냅 · 간격 | 예정노선 그리기(626-3) | 추천 노선 보기(626-1) | 설계 지형 칩(626-1). 아래 줄 = 공용 겹 줄(`_Layers` ·
|
||||
* 끝 = 그래픽 카드 단추) · 범례 = 상태 글판 위. 단추는 공용 지도 도구줄(568-2 · `ui_template_map_tools`) 아이콘 — 긴 설명은 마우스 글.
|
||||
*
|
||||
* 자리(345-3) — 모달이 아니라 종단 보기 가운데 위 본문(옛 3D 자리)에 붙는다. 덮개 · 전체 화면 ·
|
||||
@@ -64,6 +64,8 @@ export interface RouteEditChrome {
|
||||
minorContourButton: HTMLButtonElement;
|
||||
/** [측점 스냅] 켬끔(요구 224-2) — `aria-pressed` · `is-active` 는 본체가 맞춤. */
|
||||
stationSnapButton: HTMLButtonElement;
|
||||
/** 예정노선 그리기 켬끔(626-3 · 배선 = `B05_Profile_UI_ExpectedDraw`). */
|
||||
expectedDrawButton: HTMLButtonElement;
|
||||
/** 설계 지형 칩(626-1) — 지형 읽은 뒤 `load`. */
|
||||
terrain: RouteTerrain;
|
||||
/** 추천 노선 미리보기(626-1) — 그리기 안에서 `draw`. */
|
||||
@@ -288,6 +290,7 @@ export function createRouteEditChrome(
|
||||
measureButton,
|
||||
minorContourButton,
|
||||
stationSnapButton,
|
||||
expectedDrawButton: mapTools.button("expected-draw")!,
|
||||
terrain,
|
||||
preview,
|
||||
layers,
|
||||
|
||||
@@ -42,6 +42,9 @@ export function draftOfSnapshot(snapshot: RouteEditSnapshot, snap: SnapSetting):
|
||||
curveLock: [...snapshot.curveLock],
|
||||
curveArc: [...snapshot.curveArc],
|
||||
...snap,
|
||||
...(snapshot.expected
|
||||
? { expected: snapshot.expected.map(([x, y]): [number, number] => [x, y]) }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -55,6 +58,7 @@ export function snapshotOfDraft(draft: RouteDraft): RouteEditSnapshot {
|
||||
curveArc: [...draft.curveArc],
|
||||
apexLock: draft.nodes.map(() => false),
|
||||
picked: -1,
|
||||
expected: draft.expected ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,8 @@ export interface RouteEditSnapshot {
|
||||
/** 교각점(꺾임점) 자리를 못 박았나 — 켜진 자리는 끌어도 안 움직인다(2026-09-12 지시 ①). */
|
||||
apexLock: boolean[];
|
||||
picked: number;
|
||||
/** 고친 예정노선(626-3) — 없음 · null = 연 때 그대로 · [] = 지움(파일입력 예정노선) · 점 묶음 = 새로 그은 선. */
|
||||
expected?: Vertex[] | null;
|
||||
/** 이 걸음의 「현재 추천」 — `_Initial` 이 적고 되돌릴 때 되살린다(뜻은 그쪽만 안다). 노선과 짝. */
|
||||
rec?: unknown;
|
||||
}
|
||||
@@ -68,12 +70,17 @@ function clone(snapshot: RouteEditSnapshot): RouteEditSnapshot {
|
||||
apexLock: [...snapshot.apexLock],
|
||||
picked: snapshot.picked,
|
||||
rec: snapshot.rec,
|
||||
expected: snapshot.expected?.map(([x, y]): Vertex => [x, y]) ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** 고친 예정노선이 같은가(626-3 · 없음 = null). */
|
||||
const sameExpected = (a: RouteEditSnapshot, b: RouteEditSnapshot): boolean =>
|
||||
JSON.stringify(a.expected ?? null) === JSON.stringify(b.expected ?? null);
|
||||
|
||||
/** 두 사진이 같은 선(노드 · 곡선)인가 — 고정 · 고른 자리는 안 본다. */
|
||||
export function sameLine(a: RouteEditSnapshot, b: RouteEditSnapshot): boolean {
|
||||
if (a.planned.length !== b.planned.length) return false;
|
||||
if (a.planned.length !== b.planned.length || !sameExpected(a, b)) return false;
|
||||
for (let index = 0; index < a.planned.length; index += 1) {
|
||||
if (a.planned[index][0] !== b.planned[index][0]) return false;
|
||||
if (a.planned[index][1] !== b.planned[index][1]) return false;
|
||||
|
||||
@@ -183,6 +183,22 @@ export function bindMeasureControls(params: MeasureControlsParams): {
|
||||
return { sync, mode: () => mode };
|
||||
}
|
||||
|
||||
/** 재기 도구 + 단추 · 작은 창 한 벌(626-3 · `_RouteEdit` 에서 뗌) — 상태 바뀜 = 창 맞춤 · 다시 그림 · 미리보기 = 창 글만. */
|
||||
export function createRouteMeasure(
|
||||
params: Omit<MeasureToolParams, "onChange" | "onHint"> & Omit<MeasureControlsParams, "measure">,
|
||||
): { measure: MeasureTool; measureControls: ReturnType<typeof bindMeasureControls> } {
|
||||
const measure = createMeasureTool({
|
||||
...params,
|
||||
onChange: () => {
|
||||
measureControls.sync();
|
||||
params.redraw();
|
||||
},
|
||||
onHint: () => measureControls.sync(),
|
||||
});
|
||||
const measureControls = bindMeasureControls({ ...params, measure });
|
||||
return { measure, measureControls };
|
||||
}
|
||||
|
||||
export function createMeasureTool(params: MeasureToolParams): MeasureTool {
|
||||
/** 찍은 두 점. 셋째를 찍으면 새 구간의 시작이 된다. */
|
||||
let picked: MeasurePoint[] = [];
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_RouteEdit_Mouse.ts
|
||||
* 계획노선 편집(평면) 마우스 배선 — 선 두 번 클릭 = 노드 넣기 · 노드 오른쪽 클릭 = 지움 · 가운데(휠) 버튼 끌기 =
|
||||
* 지도 이동 · 휠 = 확대 · Ctrl+휠 = 돌리기. `B05_Profile_UI_RouteEdit.ts` 에서 뗌(626-3 · 동작 그대로).
|
||||
* 노드 끌기 · 집기는 `_Pointer` 몫 · 집기 셈 · 듣기는 `_Input` 몫.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { ViewState } from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
|
||||
import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { bindNodeSplice, bindRouteEditNavigation } from "./B05_Profile_UI_RouteEdit_Input";
|
||||
import type { RouteEditSnapshot } from "./B05_Profile_UI_RouteEdit_History";
|
||||
import type { MapRotation } from "./B05_Profile_UI_RouteEdit_Rotate";
|
||||
import * as edits from "./logic/B05_Profile_UI_RouteEdit_Edits";
|
||||
|
||||
type Vertex = [number, number];
|
||||
|
||||
export interface RouteEditMouseParams {
|
||||
canvas: HTMLCanvasElement;
|
||||
rotation: Pick<MapRotation, "unrotate" | "unrotateDelta" | "wheel">;
|
||||
pointer: {
|
||||
segmentAt: (px: number, py: number) => number;
|
||||
nodeAt: (px: number, py: number) => number;
|
||||
claimed: () => boolean;
|
||||
};
|
||||
toMetric: (px: number, py: number) => Vertex;
|
||||
getView: () => ViewState;
|
||||
setView: (next: ViewState) => void;
|
||||
getMeta: () => VWorldMeta | null;
|
||||
/** 지금 편집값(같은 배열 — 넣고 빼면 그대로 바뀜). */
|
||||
snapshot: () => RouteEditSnapshot;
|
||||
setPicked: (index: number) => void;
|
||||
applyEdit: (message: string) => void;
|
||||
draw: () => void;
|
||||
}
|
||||
|
||||
export function bindRouteEditMouse(params: RouteEditMouseParams): void {
|
||||
const { canvas, rotation, pointer } = params;
|
||||
bindNodeSplice({
|
||||
canvas,
|
||||
unrotate: rotation.unrotate,
|
||||
segmentAt: pointer.segmentAt,
|
||||
nodeAt: pointer.nodeAt,
|
||||
insert: (segment, px, py) => {
|
||||
edits.spliceNode(params.snapshot(), segment + 1, params.toMetric(px, py)); // 편집값도 같은 자리에 끼운다.
|
||||
params.setPicked(segment + 1);
|
||||
params.applyEdit("새 노드를 넣었습니다(직선 추가).");
|
||||
},
|
||||
remove: (index) => {
|
||||
const snapshot = params.snapshot();
|
||||
if (snapshot.planned.length <= 2)
|
||||
return showToast("노선은 노드가 2개 이상이어야 합니다.", "error");
|
||||
if (snapshot.apexLock[index])
|
||||
return showToast("고정한 점은 못 지웁니다 — 고정을 먼저 푸세요.", "error");
|
||||
edits.spliceNode(snapshot, index);
|
||||
params.setPicked(-1);
|
||||
params.applyEdit("노드를 지웠습니다(직선 삭제).");
|
||||
},
|
||||
});
|
||||
|
||||
bindRouteEditNavigation({
|
||||
canvas,
|
||||
getView: params.getView,
|
||||
setView: params.setView,
|
||||
getMeta: params.getMeta,
|
||||
// 돌린 지도에서는 손이 민 방향과 그림이 움직일 방향이 다르다 — 거꾸로 돌려 넘긴다.
|
||||
unrotateDelta: rotation.unrotateDelta,
|
||||
rotateWheel: rotation.wheel, // Ctrl+휠 = 5° 돌리기(390-3)
|
||||
claimed: pointer.claimed, // 왼쪽 옮기기 = 노드 · 손잡이 · 마커 · 재기 아닌 누름만(588-4)
|
||||
draw: params.draw,
|
||||
});
|
||||
}
|
||||
@@ -127,6 +127,8 @@ export interface RouteEditScene {
|
||||
overStations?: (context: CanvasRenderingContext2D) => void;
|
||||
/** 예상노선 위 · 계획노선 아래에 얹을 것(추천 노선 보기 626-1). 회전 안에서 부른다. */
|
||||
underRoute?: (context: CanvasRenderingContext2D) => void;
|
||||
/** 그림 맨 위(예정노선 그리기 626-3 — 고치는 예정노선 · 그은 선 · 미리보기). 회전 안에서 부른다. */
|
||||
overAll?: (context: CanvasRenderingContext2D) => void;
|
||||
}
|
||||
|
||||
/** 글자 자리는 그대로 두고 **글자만** 되돌려 세운 채로 그린다. */
|
||||
@@ -349,6 +351,7 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou
|
||||
drawPickedCurve(context, scene, style);
|
||||
drawPickHover(context, scene, line, style, NODE_R);
|
||||
drawStructureMarks(context, scene, line, style); // 구조물 측점 마커 — 맨 위(409 · 교각점 · 손잡이 · 맴돔 위)
|
||||
scene.overAll?.(context); // 예정노선 그리기(626-3) — 구조물 마커보다도 위
|
||||
context.restore(); // 회전 끝
|
||||
}
|
||||
|
||||
|
||||
@@ -487,3 +487,21 @@ html.is-bm-route .b05-routeedit__tools {
|
||||
.b05-routeedit__info.is-collapsed > .b05-routeedit__maplegend {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 626-3 예정노선 그리기 판 — 도구 줄 아래(떠 있는 판) · 단추 줄 */
|
||||
.b05-routeedit__expected-draw {
|
||||
pointer-events: auto;
|
||||
width: 300px;
|
||||
}
|
||||
|
||||
.b05-routeedit__expected-draw .ui-float-panel__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.b05-routeedit__expected-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_ExpectedDraw.ts
|
||||
* B05 평면 예정노선 그리기 셈(PLAN 626-3 · 요구 530-11 · 결정 D10) — 다음 점 고르기 · 구간 다시 긋기 갈음 · 선 위 자리.
|
||||
* 다음 점 = D03 긋기와 같은 방식 넷(자유지정 · 길이 고정 · 기울기 고정 · 길이·기울기 고정 부채꼴 · Shift 사이 기울기) —
|
||||
* 셈은 D03 `logic/D03_PlanAnalysis_Draw` · `logic/D03_PlanAnalysis_Fan` 그대로(파이썬 짝 없음 · 화면 고르기 전용).
|
||||
* 구간 다시 긋기 = 선 위 두 자리(m) 사이를 그은 점으로 갈음(거꾸로 그었으면 뒤집어 끼움).
|
||||
* 서버는 고친 예정노선을 다시 추적(`POST /route/follow`) · [저장] 때 `expected_route_edit.csv`.
|
||||
* DOM · fetch · sessionStorage 없음.
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
distanceAlong,
|
||||
fixedLengthPoint,
|
||||
fixedSlopePoint,
|
||||
lineLength,
|
||||
pointAtDistance,
|
||||
segmentLength,
|
||||
sliceLine,
|
||||
type HeightAt,
|
||||
type Vertex,
|
||||
} from "../../D03_PlanAnalysis/logic/D03_PlanAnalysis_Draw";
|
||||
import {
|
||||
compassFan,
|
||||
fanBetween,
|
||||
fanPick,
|
||||
type Fan,
|
||||
} from "../../D03_PlanAnalysis/logic/D03_PlanAnalysis_Fan";
|
||||
|
||||
/** 긋기 방식 — 공용 방식 칸(`A00_Common/map/map_draw_kind` `DrawConstraint`)과 같은 꼴(logic 은 화면 파일을 안 부름). */
|
||||
export type DrawConstraint = "free" | "length" | "slope" | "both";
|
||||
|
||||
/** 같은 점으로 볼 거리(m). */
|
||||
const SAME_M = 1e-6;
|
||||
/** 구간 다시 긋기 첫 구간 정방향 — 시작 자리 앞 이만큼(m) 선 위 점을 앞 구간 시작으로 봄. */
|
||||
const LEAD_M = 5;
|
||||
|
||||
export interface ExpectedPick {
|
||||
/** 찍을 점 — 없으면 null(자리 없음). */
|
||||
point: Vertex | null;
|
||||
/** 길이·기울기 고정 부채꼴(그 방식일 때만). */
|
||||
fan: Fan | null;
|
||||
note: string | null;
|
||||
/** 길이만 지킨 점(Shift 사이 고르기 · 완만한 땅). */
|
||||
lengthOnly: boolean;
|
||||
}
|
||||
|
||||
const one = (value: number): string => value.toFixed(1);
|
||||
|
||||
/** 다음 점 — `prev` = 앞 구간 시작(정방향 · 없으면 null) · `shift` = 부채꼴 사이 고르기. */
|
||||
export function pickNext(
|
||||
kind: DrawConstraint,
|
||||
from: Vertex,
|
||||
cursor: Vertex,
|
||||
lengthM: number,
|
||||
slopePct: number,
|
||||
heightAt: HeightAt,
|
||||
prev: Vertex | null,
|
||||
shift = false,
|
||||
): ExpectedPick {
|
||||
if (kind === "length")
|
||||
return {
|
||||
point: fixedLengthPoint(from, cursor, lengthM),
|
||||
fan: null,
|
||||
note: null,
|
||||
lengthOnly: false,
|
||||
};
|
||||
if (kind === "slope") {
|
||||
const point = fixedSlopePoint(from, cursor, slopePct, heightAt);
|
||||
const note = point ? null : `이 방향엔 ±${one(Math.abs(slopePct))} % 자리 없음`;
|
||||
return { point, fan: null, note, lengthOnly: false };
|
||||
}
|
||||
if (kind === "both") {
|
||||
const fan = compassFan(from, lengthM, slopePct, heightAt, prev, cursor);
|
||||
const point = shift
|
||||
? fanBetween(fan, from, cursor, lengthM)
|
||||
: fanPick(fan, from, cursor, lengthM);
|
||||
const note = point
|
||||
? null
|
||||
: `반지름 ${one(lengthM)} m 원 위에 ±${one(Math.abs(slopePct))} % 자리 없음`;
|
||||
return { point, fan, note, lengthOnly: shift || fan.flat };
|
||||
}
|
||||
return { point: cursor, fan: null, note: null, lengthOnly: false };
|
||||
}
|
||||
|
||||
/** 선 위 가장 가까운 자리(누가 m · 점). */
|
||||
export function nearestOnLine(
|
||||
line: ReadonlyArray<Vertex>,
|
||||
point: Vertex,
|
||||
): { atM: number; point: Vertex } {
|
||||
const atM = distanceAlong(line, point);
|
||||
return { atM, point: pointAtDistance(line, atM) };
|
||||
}
|
||||
|
||||
/** 구간 다시 긋기 첫 구간의 앞 구간 시작 — 선 위 시작 자리 조금 앞(시점이면 null). */
|
||||
export function leadBefore(line: ReadonlyArray<Vertex>, atM: number): Vertex | null {
|
||||
return atM > SAME_M ? pointAtDistance(line, Math.max(0, atM - LEAD_M)) : null;
|
||||
}
|
||||
|
||||
/** 이웃한 같은 점 뺌. */
|
||||
function dedupe(points: Vertex[]): Vertex[] {
|
||||
return points.filter((point, i) => !i || segmentLength(points[i - 1], point) > SAME_M);
|
||||
}
|
||||
|
||||
/** 구간 다시 긋기 — 선 `fromM` ~ `toM` 사이를 `through`(그은 속 점 · 시작 · 끝 자리 뺌)로 갈음. */
|
||||
export function respliceLine(
|
||||
line: ReadonlyArray<Vertex>,
|
||||
fromM: number,
|
||||
toM: number,
|
||||
through: ReadonlyArray<Vertex>,
|
||||
): Vertex[] {
|
||||
const total = lineLength(line);
|
||||
const forward = fromM <= toM;
|
||||
const [lo, hi] = forward ? [fromM, toM] : [toM, fromM];
|
||||
const middle = forward ? [...through] : [...through].reverse();
|
||||
return dedupe([...sliceLine(line, 0, lo), ...middle, ...sliceLine(line, hi, total)]);
|
||||
}
|
||||
@@ -217,7 +217,7 @@ def bundle_layer(
|
||||
path, meta = _paths(project_root, bundle)
|
||||
stamp = file_stamp(path)
|
||||
if stamp is None:
|
||||
raise FileNotFoundError(f"올린 임도망도 묶음 {bundle} 이(가) 없습니다.")
|
||||
raise FileNotFoundError(f"임도(업로드) 묶음 {bundle} 이(가) 없습니다.")
|
||||
at = [round(v, 1) for v in origin] if origin else None
|
||||
key = derived_key(
|
||||
ROAD_KIND, ROAD_VERSION, bundle, stamp, int(epsg), [round(v, 1) for v in box], at
|
||||
@@ -238,7 +238,7 @@ def road_layer(project_root: Path, box: Box, epsg: int, origin: XY | None = None
|
||||
ids = bundle_ids(project_root)
|
||||
if not ids:
|
||||
raise FileNotFoundError(
|
||||
"올린 임도망도가 없습니다 — [임도망 올리기]로 시군구 SHP 를 올리세요."
|
||||
"임도(업로드)가 없습니다 — 「위치」 칸 [임도(업로드)]로 시군구 SHP 를 올리세요."
|
||||
)
|
||||
rows, features, crs, edges, gaps = [], [], set(), [], []
|
||||
for bundle in ids:
|
||||
@@ -335,9 +335,9 @@ def save_roads(
|
||||
def delete_bundle(project_root: Path, bundle: str) -> None:
|
||||
"""묶음 하나 지움(저장본은 열쇠가 달라져 저절로 안 쓰임) — 없으면 `FileNotFoundError`."""
|
||||
if bundle != LEGACY and not re.fullmatch(r"[0-9a-f]{12}", bundle):
|
||||
raise FileNotFoundError(f"올린 임도망도 묶음 {bundle} 이(가) 없습니다.")
|
||||
raise FileNotFoundError(f"임도(업로드) 묶음 {bundle} 이(가) 없습니다.")
|
||||
zip_path, meta_path = _paths(project_root, bundle)
|
||||
if not zip_path.is_file():
|
||||
raise FileNotFoundError(f"올린 임도망도 묶음 {bundle} 이(가) 없습니다.")
|
||||
raise FileNotFoundError(f"임도(업로드) 묶음 {bundle} 이(가) 없습니다.")
|
||||
zip_path.unlink()
|
||||
meta_path.unlink(missing_ok=True)
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* 미리보기(거리 · 가상 종단 기울기 % · °) · 집기 = 화면 px 둘레 선 위 자리.
|
||||
* 길이·기울기 고정 부채꼴(625-2) — 정방향 = 채움 · 역방향 = 테두리 · 노드 · 놓인 꺾임점 · 미리보기 고리 =
|
||||
* 기울기 부호 색(+ 오르막 `--draw-map-slope-up` 따뜻한 색 · − 내리막 `--draw-map-slope-down` 찬 색 · 막힘 = 경고 빨강).
|
||||
* `paintFan` = ScreenMap 의 `toScreen` 만 씀 — B05 평면(626-3)이 그대로 씀.
|
||||
* `paintFan` · `paintPreview` = ScreenMap 의 `toScreen` · `pxPerMeter` · `upright` 만 씀 — B05 평면(626-3)이 그대로 씀.
|
||||
* 지점 핀(585-1) = 따로 겹(`pointLayer` · 겹 목록 맨 끝 = 늘 맨 위) · 색 = 목록 줄과 같은 `pointColor(i)` · 끌기 · 메뉴 = `D03_PlanAnalysis_UI_Points`.
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -330,7 +330,7 @@ export function paintMark(
|
||||
* 고리 = 고른 점 기울기 부호 색(놓인 뒤 꺾임점과 같은 색). */
|
||||
export function paintPreview(
|
||||
context: CanvasRenderingContext2D,
|
||||
map: ScreenMap,
|
||||
map: Pick<ScreenMap, "toScreen" | "pxPerMeter" | "upright">,
|
||||
from: Vertex,
|
||||
preview: DrawPreview,
|
||||
compass: { lengthM: number; fan: Fan } | null,
|
||||
@@ -368,7 +368,7 @@ export function paintPreview(
|
||||
|
||||
export function planLabel(
|
||||
context: CanvasRenderingContext2D,
|
||||
map: ScreenMap,
|
||||
map: Pick<ScreenMap, "upright">,
|
||||
x: number,
|
||||
y: number,
|
||||
text: string,
|
||||
|
||||
@@ -2,150 +2,26 @@
|
||||
* D03_PlanAnalysis_UI_DrawPanel.ts
|
||||
* D03 좌측 「그리기」 칸 몸(PLAN 599-1 · 599-4 · 요구 463-10 · 22) — [긋기] · 연도 구역 마커 · 긋기 방식 칸.
|
||||
*
|
||||
* 방식 칸 `createDrawKindFields` = 마디 고름 2 × 2(자유지정 · 길이·기울기 고정 / 길이 고정 · 기울기 고정 ·
|
||||
* 625-1) + 길이 · 기울기 두 열(기울기 아래 ≈ °) — 방식에 안 쓰는 칸은 숨김(막힘 = 권한 없음만) ·
|
||||
* 그리기 칸 · 노드 판(598-4)이 같은 부품을 씀.
|
||||
* 방식 칸 `createDrawKindFields` = 공용 `A00_Common/map/map_draw_kind`(626-3) — 그리기 칸 · 노드 판(598-4) ·
|
||||
* B05 평면 예정노선 그리기가 같은 부품을 씀(여기서 다시 내보냄).
|
||||
* 연도 구역 마커 = 끌어 선 위에 놓기(`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";
|
||||
import type { DrawKindFields } from "../A00_Common/map/map_draw_kind";
|
||||
|
||||
/** 제약 칸 처음 값 — 길이 = 측점 간격(지식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: "자유지정",
|
||||
both: "길이·기울기 고정",
|
||||
length: "길이 고정",
|
||||
slope: "기울기 고정",
|
||||
};
|
||||
|
||||
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 = slopeField.input.disabled = disabled;
|
||||
fields.hidden = value.kind === "free";
|
||||
lengthField.root.hidden = value.kind === "slope";
|
||||
slopeColumn.hidden = 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
// 방식 칸 = 공용(`A00_Common/map/map_draw_kind` · 626-3 · B05 평면 예정노선 그리기도 씀)
|
||||
export {
|
||||
createDrawKindFields,
|
||||
DRAW_DEFAULT_LENGTH_M,
|
||||
DRAW_DEFAULT_SLOPE_PCT,
|
||||
DRAW_KIND_LABELS,
|
||||
type DrawConstraint,
|
||||
type DrawKindFields,
|
||||
type DrawKindValue,
|
||||
} from "../A00_Common/map/map_draw_kind";
|
||||
|
||||
export interface DrawPanel {
|
||||
root: HTMLElement;
|
||||
|
||||
@@ -34,9 +34,9 @@ export type Prepared = Record<LineLayerKey, PreparedLines> & {
|
||||
/** 세류 · 하천(소하천 · 지방하천 · 연결선). */
|
||||
stream: [PreparedLayer, PreparedLayer];
|
||||
cadastral: PreparedLayer;
|
||||
/** 전체 · 그중 임도 짐작(소로 · 미분류) — `road` · `forestRoad` 가 함께 씀. */
|
||||
/** 전체 · 그중 소로 · 미분류(옛 임도 짐작 · 겹 걷음 · 수만 셈). */
|
||||
road: { all: PreparedLayer; small: PreparedLayer };
|
||||
/** 올린 임도망도(584-3). */
|
||||
/** 임도(업로드)(584-3). */
|
||||
forestRoadFile: { layer: PreparedLayer; info: PlanForestRoads };
|
||||
/** 급경사(603) — 값 그림 칸 · 고른 범위 · 칠한 그림(blob 주소) · 칠하기 ms. */
|
||||
slope: {
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
* (공용 겹 줄 `A00_Common/map/map_layer_bar` · 넘치면 가로 밀기 571-1 · 오른쪽 평면 도구줄과 따로).
|
||||
*
|
||||
* 겹 = 바탕(위성 · 음영기복 · 도엽 등고선(계곡선 25 m 굵게 · 수치) · 급경사 색) ·
|
||||
* 물 · 길(세류선 · 하천 · 물 면(실폭하천 · 저수지) · 기존 도로 · 기존 임도 짐작(소로 · 미분류) ·
|
||||
* 올린 임도망도(시군구 SHP · 올리기 = 왼쪽 「위치」 칸 `uploadRoads`)) ·
|
||||
* 물 · 길(세류선 · 하천 · 물 면(실폭하천 · 저수지) · 기존 도로 · 임도망도(전국 국가임도 · 조각) ·
|
||||
* 임도(업로드)(시군구 SHP · 올리기 = 왼쪽 「위치」 칸 `uploadRoads`)) ·
|
||||
* 구역(지적도 · 보호 · 규제 구역 여덟 종 · 산림입지도(토양형) · 산사태위험지도 · 산사태취약지역(시도 표 점 · 지번 필지 테두리) ·
|
||||
* 선 겹(단층 · 지질구조선 · 0차곡) ·
|
||||
* 계류 100 m · 도로 300 m 띠 · 건물 · 지명).
|
||||
@@ -14,14 +14,14 @@
|
||||
* 옛 차례 답은 버림(475) · 도엽 선 겹(등고 · 세류 · 물 면 · 도로 · 건물 · 지명) = 보이는 도엽 조각(606 · `UI_SheetTiles`) · 지형을 안 받았으면 단추를 막음 ·
|
||||
* 받기 실패 = 그 겹만 꺼짐 + 알림 · 받는 일은 페이지 서클에 셈(`track`).
|
||||
* 그리기 차례(아래 → 위) = 위성(GL 조각만 · 옛 캔버스 = 위성 없음 · 590-2) → 음영기복 → 급경사 → 산사태위험지도 → 산림입지도 → 보호구역 → 띠 → 지적 → 물 면 →
|
||||
* 건물 → 등고선 → 도로 → 임도 짐작 → 올린 임도망도 → 세류 → 선 겹 → 산사태취약지역 → 지명 → 그은 선.
|
||||
* 건물 → 등고선 → 도로 → 임도(업로드) → 세류 → 선 겹 → 산사태취약지역 → 지명 → 그은 선.
|
||||
* 겹 모두 = 바탕 캔버스(`base` · 582) · 등고선 = 줌 단계 색인 · 글 폭 기억.
|
||||
* 범례 = 켠 겹의 받은 범위 안 수 · 넓이(왼쪽 아래 · 칸 = 공용 `map_legend` · 줄 = `D03_PlanAnalysis_UI_LayerLegend`).
|
||||
* 급경사 = 서버 값 그림을 고른 범위대로 칠함 · 범례 ⚙ → 범위 모달(603 · `D03_PlanAnalysis_UI_SlopeBands`).
|
||||
* GL 바탕(586-2 · 처음 = 켬 · 끝 단추 끔 = 옛 캔버스 · `aislo:pref:d03.canvas`) — 겹 스물 · 등고 글 · 지명을
|
||||
* MapLibre 가 그림(`D03_PlanAnalysis_UI_MapGl`) · 캔버스 바탕 겹은 쉼(등고 간격 셈만) · 받기 · 범례는 같음 ·
|
||||
* WebGL 못 씀 · 잃음 = 저절로 옛 캔버스 + 알림(취향은 안 바꿈).
|
||||
* 받은 산림 · 환경 자료(622-2) = 국유림 임도 · 경제림 · 생태자연도(조각만 · 받기 없음) · 산림입지도 · 산사태위험(GL = 조각 ·
|
||||
* 받은 산림 · 환경 자료(622-2) = 임도망도 · 경제림 · 생태자연도(조각만 · 받기 없음) · 산림입지도 · 산사태위험(GL = 조각 ·
|
||||
* 옛 캔버스 = 지금 받기 그대로) — 꼴 · 범례 = 공용 `A00_Common/map/map_forest_layers`.
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -102,7 +102,7 @@ export interface PlanLayers {
|
||||
layers: PlanMapLayer[];
|
||||
/** 지형(위성 틀)을 다시 읽음 — 처음 · 지형 받은 뒤 · 받은 범위(`have` · 지형면을 다 읽은 뒤 값)가 바뀌면 켠 겹 모두 새로. */
|
||||
load: (have: [number, number, number, number] | null) => Promise<void>;
|
||||
/** 임도망도 올리기(왼쪽 「위치」 칸) — 받으면 그 겹을 켬 · 못 받으면 알림 + null. */
|
||||
/** 임도(업로드) 올리기(왼쪽 「위치」 칸) — 받으면 그 겹을 켬 · 못 받으면 알림 + null. */
|
||||
uploadRoads: (files: File[]) => Promise<PlanForestRoads | null>;
|
||||
/** 올린 묶음 하나 지움(605-1) — 지도에서 바로 빠짐 · 못 지우면 알림 + 거짓. */
|
||||
deleteRoad: (bundle: string) => Promise<boolean>;
|
||||
@@ -285,7 +285,7 @@ export function createPlanLayers(opts: {
|
||||
if (meta !== at) return info;
|
||||
applyRoads(info);
|
||||
showToast(
|
||||
`임도망도 받음 — 범위 안 ${info.count}가닥 ${(info.length_m / 1000).toFixed(2)} km (파일 ${info.total}가닥)`,
|
||||
`임도(업로드) 받음 — 범위 안 ${info.count}가닥 ${(info.length_m / 1000).toFixed(2)} km (파일 ${info.total}가닥)`,
|
||||
"success",
|
||||
);
|
||||
return info;
|
||||
@@ -302,7 +302,7 @@ export function createPlanLayers(opts: {
|
||||
return await job;
|
||||
} catch (error) {
|
||||
showToast(
|
||||
`임도망도 올리지 못함 — ${error instanceof Error ? error.message : String(error)}`,
|
||||
`임도(업로드) 올리지 못함 — ${error instanceof Error ? error.message : String(error)}`,
|
||||
"error",
|
||||
);
|
||||
return null;
|
||||
@@ -339,14 +339,14 @@ export function createPlanLayers(opts: {
|
||||
applyRoads(info);
|
||||
showToast(
|
||||
info
|
||||
? `임도망도 묶음 지움 — 남은 ${info.bundles.length}묶음`
|
||||
: "임도망도 묶음 지움 — 남은 것 없음",
|
||||
? `임도(업로드) 묶음 지움 — 남은 ${info.bundles.length}묶음`
|
||||
: "임도(업로드) 묶음 지움 — 남은 것 없음",
|
||||
"success",
|
||||
);
|
||||
return true;
|
||||
} catch (error) {
|
||||
showToast(
|
||||
`임도망도 묶음을 지우지 못함 — ${error instanceof Error ? error.message : String(error)}`,
|
||||
`임도(업로드) 묶음을 지우지 못함 — ${error instanceof Error ? error.message : String(error)}`,
|
||||
"error",
|
||||
);
|
||||
return false;
|
||||
|
||||
@@ -463,7 +463,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
fitTo(around(site));
|
||||
}
|
||||
|
||||
/** 임도망 올리기 — 지도 맞춤 = 주소지 둘레 ∪ 잘린 임도 상자(없으면 주소지). */
|
||||
/** 임도(업로드) 올리기 — 지도 맞춤 = 주소지 둘레 ∪ 잘린 임도 상자(없으면 주소지). */
|
||||
async function uploadRoads(files: File[]): Promise<void> {
|
||||
const info = await backdrop.uploadRoads(files);
|
||||
if (info) fitTo([...around(site), ...boxOfLonLat(epsg, info.bounds)]);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* =============================================================================
|
||||
* D03_PlanAnalysis_UI_RoadList.ts
|
||||
* D03 「위치」 칸 올린 임도망 목록(PLAN 605-2 · 요구 476-1) — 묶음 줄(파일 이름 · 가닥 수 · 길이 · 지움) ·
|
||||
* D03 「위치」 칸 임도(업로드) 묶음 목록(PLAN 605-2 · 요구 476-1) — 묶음 줄(파일 이름 · 가닥 수 · 길이 · 지움) ·
|
||||
* 펼치면 선 줄 · 줄 고름 = 지도 강조 + 맞춤(페이지가 함) · 지움 = 물음 뒤 그 묶음만 · 자료는 서버 한 번 읽음.
|
||||
* 부르는 곳 = `D03_PlanAnalysis_UI_Site.ts`.
|
||||
* ========================================================================== */
|
||||
@@ -111,7 +111,7 @@ export function createRoadList(params: RoadListParams): RoadList {
|
||||
const bundles = info?.bundles ?? [];
|
||||
root.hidden = !bundles.length;
|
||||
if (!bundles.length) return void root.replaceChildren();
|
||||
const rows: HTMLElement[] = [createText(`올린 임도망 ${bundles.length}묶음`, "muted", 12)];
|
||||
const rows: HTMLElement[] = [createText(`임도(업로드) ${bundles.length}묶음`, "muted", 12)];
|
||||
for (const bundle of bundles) {
|
||||
const shown = open.has(bundle.id);
|
||||
const fold = createIconButton({
|
||||
@@ -130,7 +130,7 @@ export function createRoadList(params: RoadListParams): RoadList {
|
||||
size: 24,
|
||||
onClick: async () => {
|
||||
const ok = await askConfirm({
|
||||
message: `올린 임도망 「${bundle.name}」 묶음을 지울까요? 지도에서도 빠집니다.`,
|
||||
message: `임도(업로드) 「${bundle.name}」 묶음을 지울까요? 지도에서도 빠집니다.`,
|
||||
okLabel: "지움",
|
||||
danger: true,
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* D03_PlanAnalysis_UI_Site.ts
|
||||
* D03 「위치」 칸(PLAN 596-2 · 요구 463-3~6 · 19) — 지금 기준점 주소 한 줄 · 주소 · 장소 찾기
|
||||
* (서버 VWorld `searchPlaces` · 부품 `createFindList` · Enter = 찾기 · ↑ ↓ Enter = 고름) ·
|
||||
* 알림 한 줄(받기 실패 · 끊김 · 기준점 없음 · 못 불러옴만) + [다시 받기] · [임도망 올리기] + 올린 임도망 목록
|
||||
* 알림 한 줄(받기 실패 · 끊김 · 기준점 없음 · 못 불러옴만) + [다시 받기] · [임도(업로드)] + 올린 묶음 목록
|
||||
* (`D03_PlanAnalysis_UI_RoadList.ts` · 605-2).
|
||||
* 고름 = 페이지가 받기 물음(멂 · 다른 TM 띠) → 기준점 캐시 → 지도 이동 → 둘레 받기 · [저장] 때 정본(등록과 한 칸).
|
||||
* ========================================================================== */
|
||||
@@ -24,7 +24,7 @@ export interface PlanSiteParams {
|
||||
onRetry: () => void;
|
||||
/** 올림(끝나면 목록을 다시 읽음). */
|
||||
onUpload: (files: File[]) => Promise<unknown> | void;
|
||||
/** 올린 임도망 목록(605-2) — 고름 · 지움은 페이지. */
|
||||
/** 임도(업로드) 묶음 목록(605-2) — 고름 · 지움은 페이지. */
|
||||
roads: RoadListParams;
|
||||
}
|
||||
|
||||
@@ -111,7 +111,7 @@ export function createPlanSite(params: PlanSiteParams): PlanSite {
|
||||
});
|
||||
const roads = createRoadList(params.roads);
|
||||
const upload = createButton({
|
||||
label: "임도망 올리기",
|
||||
label: "임도(업로드)",
|
||||
variant: "ghost",
|
||||
size: 28,
|
||||
onClick: () =>
|
||||
@@ -119,7 +119,8 @@ export function createPlanSite(params: PlanSiteParams): PlanSite {
|
||||
Promise.resolve(params.onUpload(files)).then(() => roads.reload()),
|
||||
),
|
||||
});
|
||||
upload.title = "임도망도 SHP(시군구 zip 하나 · 또는 shp · shx · dbf · prj 함께 · 100 MB 까지)";
|
||||
upload.title =
|
||||
"임도(업로드) — 산림청 시군구 SHP 등 지방 · 민간임도(zip 하나 · 또는 shp · shx · dbf · prj 함께 · 100 MB 까지)";
|
||||
return {
|
||||
parts: [address, find.root, note, retry, upload, roads.root],
|
||||
render(state) {
|
||||
|
||||
@@ -185,34 +185,7 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 599 그리기 칸 — 방식 칸(마디 고름 2 × 2 · 길이 · 기울기 두 열) */
|
||||
.d03-plan__draw-kind {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.d03-plan__draw-fields {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--spacing-8);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.d03-plan__draw-fields[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.d03-plan__draw-fields .ui-field {
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.d03-plan__draw-fields .ui-input-unit,
|
||||
.d03-plan__draw-fields .ui-input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
/* 599 그리기 칸 방식 칸 = 공용 `A00_Common/map/map_draw_kind.css`(626-3) */
|
||||
|
||||
/* 597 구역 · 연도 목록 — 구역 줄(이름 칸 · 연도 고름 · 지움) · 구간 줄(측점 글 작게) */
|
||||
.d03-plan__line-tail {
|
||||
|
||||
@@ -79,9 +79,8 @@ def test_d03_parts():
|
||||
("stream", "true"),
|
||||
("water", "false"),
|
||||
("road", "false"),
|
||||
("forestRoad", "false"),
|
||||
("forestRoadFile", "false"),
|
||||
("nationalRoad", "false"),
|
||||
("forestRoadFile", "false"),
|
||||
("cadastral", "false"),
|
||||
("zones", "false"),
|
||||
("soils", "false"),
|
||||
@@ -98,10 +97,17 @@ def test_d03_parts():
|
||||
], keys
|
||||
assert ".d03-plan__layers" in css and ".d03-plan__legend" in css
|
||||
# 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
|
||||
# 626-3 — 방식 칸은 공용 `A00_Common/map/map_draw_kind`(B05 평면도 씀) · D03 칸은 다시 내보냄
|
||||
kind = _src("A00_Common/map/map_draw_kind.ts")
|
||||
kind_css = _src("A00_Common/map/map_draw_kind.css")
|
||||
assert "createSegment({" in kind and kind.count("createInputField(") == 2
|
||||
assert "columns: 2" in kind and "export function createDrawKindFields(" in kind
|
||||
assert (
|
||||
"createDrawKindFields," in draw_panel
|
||||
and 'from "../A00_Common/map/map_draw_kind"' in draw_panel
|
||||
)
|
||||
assert ".map-draw-kind__fields" in kind_css and "createDrawKindFields(" in draw
|
||||
assert '"길이·기울기 고정"' in kind and '"둘 다"' not in kind
|
||||
assert "d03-plan__hint" not in draw # 칸 설명 글 걷음(요구 463-6)
|
||||
# 599-4 — 연도 구역 마커 = 그리기 칸 단추(끌어 놓기) · 지도 도구줄에서 뺌
|
||||
assert "YEAR_MARK_DRAG" in draw_panel and '"year-mark"' not in tools
|
||||
|
||||
@@ -360,7 +360,8 @@ def test_screen_defs() -> None:
|
||||
)
|
||||
assert "createRoadList(" in site and "roads.reload()" in site
|
||||
assert len((folder / "D03_PlanAnalysis_UI_RoadList.ts").read_text("utf-8").splitlines()) <= 700
|
||||
assert "임도 짐작" in legend and "올린 임도망도" in legend # 범례 구분
|
||||
shared = (folder.parent / "A00_Common" / "map" / "map_legend.ts").read_text("utf-8")
|
||||
assert "임도 짐작" not in shared and "임도(업로드) ${" in shared # 짐작 걷음(요구 532)
|
||||
assert "이 시도 자료 없음" in legend
|
||||
for name in (
|
||||
"D03_PlanAnalysis_UI_Layers.ts",
|
||||
|
||||
@@ -40,6 +40,14 @@ def test_layer_list_five_off_by_default() -> None:
|
||||
entry = re.search(rf'key: "{key}",\s*icon: "(\w+)",[^}}]*on: (\w+)', defs)
|
||||
assert entry and entry.group(2) == "false", key
|
||||
assert re.search(rf"^\s+{entry.group(1)}:", icons, re.M), entry.group(1)
|
||||
# 630 — 임도망도 = 짐작 자리 · route 아이콘 · 짐작 겹 걷음
|
||||
assert re.search(r'key: "nationalRoad",\s*icon: "route",\s*title:\s*"임도망도\(', defs)
|
||||
assert 'key: "forestRoad",' not in defs
|
||||
assert "item(`임도망도 ${label}`" in _read(MAP / "map_forest_layers.ts")
|
||||
forest = _read(MAP / "map_forest_layers.ts")
|
||||
assert '"nationalRoad-label"' in forest and "minzoom: 14" in forest # 534 줌인 글
|
||||
assert '["get", "year"]' in forest and '"symbol-placement": "line"' in forest
|
||||
assert "forestRoadLabelSpec()," in _read(MAP / "map_layer_specs.ts")
|
||||
|
||||
|
||||
def test_colors_are_draw_map_tokens() -> None:
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
"""626-3 B05 평면 예정노선 그리기 → 다시 추적 → 편집기 한 걸음 → [저장](요구 530 · 531 · 결정 D3 · D4 · D9 · D10).
|
||||
|
||||
- 셈(TS `logic/B05_Profile_ExpectedDraw.ts` · tsc 로 옮겨 Node) — 구간 다시 긋기 갈음(앞뒤 그대로 · 거꾸로 그음 뒤집음 ·
|
||||
시점 · 종점 자리) · 다음 점 방식 넷(D03 셈 그대로) · 선 위 자리 · 첫 구간 정방향 앞 점.
|
||||
- 지킴(글 대조) — 다시 추적 = `/route/follow` 만(저장 0) · `/route/replan` · `as_initial` · `expected_route.csv` 안 부름 ·
|
||||
결과 = 편집기 `restoreSnapshot(…, true)` 한 걸음 · [저장] = 손 편집 같은 길(`bindRouteApply`) + 초안 `expected` ·
|
||||
걸음 사진 · 노선 초안에 고친 예정노선 실림(되돌리기 · 세션 되살림) · 같은 선 판정에 예정노선 · 긋기 방식 칸 공용 ·
|
||||
판 켜면 캔버스 누름을 먼저 받음(재기 · 계획선 고르기 막음) · RouteEdit 700 줄 안.
|
||||
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_626_3_expected_edit.py -q
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
B05 = ROOT / "B05_Profile"
|
||||
TS_FILES = [
|
||||
ROOT / "D03_PlanAnalysis" / "logic" / "D03_PlanAnalysis_Draw.ts",
|
||||
ROOT / "D03_PlanAnalysis" / "logic" / "D03_PlanAnalysis_Fan.ts",
|
||||
B05 / "logic" / "B05_Profile_ExpectedDraw.ts",
|
||||
]
|
||||
TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
|
||||
|
||||
_RUNNER = """
|
||||
import { writeFileSync } from "node:fs";
|
||||
import * as d from "./B05_Profile/logic/B05_Profile_ExpectedDraw.js";
|
||||
const line = [[0, 0], [10, 0], [20, 0], [30, 0], [40, 0]];
|
||||
const plane = (x, y) => 0.65 * x;
|
||||
const out = {};
|
||||
out.forward = d.respliceLine(line, 12, 28, [[15, 5], [25, 5]]);
|
||||
out.backward = d.respliceLine(line, 28, 12, [[25, 5], [15, 5]]);
|
||||
out.head = d.respliceLine(line, 0, 20, [[10, 3]]);
|
||||
out.tail = d.respliceLine(line, 30, 40, []);
|
||||
out.near = d.nearestOnLine(line, [17, 4]);
|
||||
out.leadStart = d.leadBefore(line, 0);
|
||||
out.lead = d.leadBefore(line, 12);
|
||||
out.free = d.pickNext("free", [0, 0], [3, 4], 20, 9, plane, null);
|
||||
out.length = d.pickNext("length", [0, 0], [3, 4], 20, 9, plane, null);
|
||||
const both = d.pickNext("both", [0, 0], [0, 50], 20, 9, plane, [0, -20]);
|
||||
out.both = { point: both.point, nodes: both.fan.nodes.length, arcs: both.fan.arcs.length, lengthOnly: both.lengthOnly };
|
||||
const between = d.pickNext("both", [0, 0], [-3, 50], 20, 9, plane, [0, -20], true);
|
||||
out.between = { point: between.point, lengthOnly: between.lengthOnly };
|
||||
out.none = d.pickNext("both", [0, 0], [0, 50], 20, 9, () => NaN, null);
|
||||
out.slopeNone = d.pickNext("slope", [0, 0], [0, 50], 20, 9, () => 0, null);
|
||||
writeFileSync(process.argv[2], JSON.stringify(out));
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def r(tmp_path_factory: pytest.TempPathFactory) -> dict:
|
||||
tmp = tmp_path_factory.mktemp("expected_draw")
|
||||
out = tmp / "js"
|
||||
subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
["node", str(TSC), *map(str, TS_FILES), "--outDir", str(out), "--rootDir", str(ROOT)]
|
||||
+ ["--module", "esnext", "--target", "es2022", "--moduleResolution", "bundler"]
|
||||
+ ["--ignoreConfig"],
|
||||
cwd=str(ROOT),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
for emitted in out.rglob("*.js"):
|
||||
emitted.write_text(
|
||||
re.sub(r'(from\s+"\.\.?/[^"]+)"', r'\1.js"', emitted.read_text(encoding="utf-8")),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(out / "runner.mjs").write_text(_RUNNER, encoding="utf-8")
|
||||
result = tmp / "out.json"
|
||||
subprocess.run( # noqa: S603
|
||||
["node", str(out / "runner.mjs"), str(result)],
|
||||
cwd=str(ROOT),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
return json.loads(result.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _src(path: Path) -> str:
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_구간_다시_긋기_앞뒤_그대로_가운데만_갈음(r):
|
||||
assert r["forward"] == [[0, 0], [10, 0], [12, 0], [15, 5], [25, 5], [28, 0], [30, 0], [40, 0]]
|
||||
assert r["backward"] == r["forward"] # 거꾸로 그어도 같은 선
|
||||
assert r["head"] == [[0, 0], [10, 3], [20, 0], [30, 0], [40, 0]] # 시점부터 · 같은 점 하나
|
||||
assert r["tail"] == [[0, 0], [10, 0], [20, 0], [30, 0], [40, 0]] # 속 점 없음 = 곧은 갈음
|
||||
|
||||
|
||||
def test_선_위_자리_첫_구간_앞_점(r):
|
||||
assert r["near"] == {"atM": 17, "point": [17, 0]}
|
||||
assert r["leadStart"] is None and r["lead"] == [7, 0]
|
||||
|
||||
|
||||
def test_다음_점_방식_넷_D03_셈_그대로(r):
|
||||
assert r["free"]["point"] == [3, 4] and r["free"]["fan"] is None
|
||||
assert [round(v, 6) for v in r["length"]["point"]] == [12, 16]
|
||||
both = r["both"]
|
||||
assert both["nodes"] == 4 and both["arcs"] == 2 and not both["lengthOnly"]
|
||||
assert abs((both["point"][0] ** 2 + both["point"][1] ** 2) ** 0.5 - 20) < 1e-6
|
||||
between = r["between"]
|
||||
assert between["lengthOnly"] and abs(sum(v * v for v in between["point"]) ** 0.5 - 20) < 1e-6
|
||||
assert r["none"]["point"] is None and "자리 없음" in r["none"]["note"]
|
||||
assert r["slopeNone"]["point"] is None and "자리 없음" in r["slopeNone"]["note"]
|
||||
|
||||
|
||||
def test_다시_추적_follow_만_저장_0_초기값_추천_안_건드림():
|
||||
ui = _src(B05 / "B05_Profile_UI_ExpectedDraw.ts")
|
||||
assert "/route/follow" in ui and "askConfirm(" in ui and "flattenServerPlan(" in ui
|
||||
for banned in (
|
||||
"/route/replan",
|
||||
"as_initial",
|
||||
"expected_route.csv",
|
||||
"/route/initial",
|
||||
"replanRoute",
|
||||
):
|
||||
assert banned not in ui, banned
|
||||
# B05 화면 어디서도 as_initial: true 를 안 보냄(D8)
|
||||
for path in B05.glob("B05_Profile_UI_*.ts"):
|
||||
assert not re.search(r"as_initial:\s*true", _src(path)), path.name
|
||||
|
||||
|
||||
def test_결과는_편집기_한_걸음_저장은_손_편집_같은_길():
|
||||
main = _src(B05 / "B05_Profile_UI_RouteEdit.ts")
|
||||
assert "createExpectedDraw({" in main and "restoreSnapshot(" in main
|
||||
assert re.search(r"onFollow: \(flat, line\) =>\s+restoreSnapshot\(", main)
|
||||
assert (
|
||||
'"예정노선을 따라 평면을 다시 추적했습니다.",\n true,' in main
|
||||
) # 새 걸음(되돌리기 됨)
|
||||
assert "expected: expectedDraw.edit()," in main # 걸음 사진 칸
|
||||
assert "expectedDraw.setEdit(snapshot.expected ?? null);" in main # 되돌리기 = 예정노선도
|
||||
assert "expected: () => expectedDraw.edit()," in main # [저장] 초안 칸
|
||||
assert "plan.expected_edited ?? []" in main # 다시 열면 고친 예정노선
|
||||
assert len(main.splitlines()) <= 700
|
||||
apply = _src(B05 / "B05_Profile_UI_RouteEdit_Apply.ts")
|
||||
assert "...(expected ? { expected } : {})" in apply # null = 안 실음(그대로) · [] = 지움
|
||||
|
||||
|
||||
def test_걸음_사진_초안_같은_선에_예정노선():
|
||||
history = _src(B05 / "B05_Profile_UI_RouteEdit_History.ts")
|
||||
assert "expected?: Vertex[] | null;" in history and "!sameExpected(a, b)" in history
|
||||
draft = _src(B05 / "B05_Profile_UI_RouteEdit_Draft.ts")
|
||||
assert "...(snapshot.expected" in draft and "expected: draft.expected ?? null," in draft
|
||||
types = _src(ROOT / "A00_Common" / "design" / "logic" / "design_pipeline_types.ts")
|
||||
assert "expected?: Vertex[] | null;" in types
|
||||
server = _src(B05 / "B05_Profile_Route_Save.py")
|
||||
assert "expected: list[tuple[float, float]] | None = None" in server
|
||||
|
||||
|
||||
def test_방식_칸_공용_판_켜면_캔버스_누름_먼저_받음():
|
||||
ui = _src(B05 / "B05_Profile_UI_ExpectedDraw.ts")
|
||||
assert 'from "../A00_Common/map/map_draw_kind"' in ui and "createDrawKindFields(" in ui
|
||||
assert "paintPreview(" in ui and "pickNext(" in ui # D03 그림 · 셈 그대로
|
||||
assert '"pointerdown",' in ui and "event.stopImmediatePropagation()" in ui
|
||||
assert re.search(
|
||||
r"\n true,\n \);", ui
|
||||
) # 잡기 단계(capture) — 재기 · 계획선 고르기보다 먼저
|
||||
assert 'keyIs(event, "plan.drawKind")' in ui and 'keyIs(event, "plan.drawFinish")' in ui
|
||||
assert "createFloatPanel(" in ui and 'createElement("button")' not in ui
|
||||
tools = _src(ROOT / "A00_Common" / "map" / "map_toolbar.ts")
|
||||
assert re.search(r'key: "expected-draw",\s+does: "[^"]+",\s+pages: B05', tools)
|
||||
render = _src(B05 / "B05_Profile_UI_RouteEdit_Render.ts")
|
||||
# 맨 위 = 구조물 마커 뒤 · 회전 끝 앞
|
||||
assert render.index("scene.overAll?.(context);") < render.index("context.restore(); // 회전 끝")
|
||||
@@ -31,6 +31,12 @@ export const MAP_TOOLS = {
|
||||
footprint: { icon: "road", title: "절/성토선", toggle: true },
|
||||
measure: { icon: "ruler", title: "거리 재기", toggle: true },
|
||||
"station-snap": { icon: "magnet", title: "측점 스냅", toggle: true },
|
||||
"expected-draw": {
|
||||
icon: "pencil",
|
||||
title:
|
||||
"예정노선 그리기 — 예정노선을 새로 긋기 · 구간 다시 긋기 · 지움 → 평면선을 예정노선 따라 통째 다시 추적 (긋는 동안 계획선은 안 집힘)",
|
||||
toggle: true,
|
||||
},
|
||||
} satisfies Record<string, { icon: IconName; title: string; toggle?: boolean }>;
|
||||
|
||||
export type MapToolAct = keyof typeof MAP_TOOLS;
|
||||
|
||||
Reference in New Issue
Block a user