Merge remote-tracking branch 'origin/dev' into sub_laptop_5 — 598 합치기 풂(PLAN 598 · 요구 463)
- UI_Points: 우클릭 메뉴 = UI_Menu 그대로 · sub4 597-2 칸 몫([추가] · 펼침 이름 · 경위도 · nameError · 칸 설명 글 걷음) 살림 · 핀 이름 그림(pointLayer names) · dev 쪽 openMenu · mount 걷음(지점 놓기 노드 붙음 = UI_Menu place) - UI_Pieces: 구역 지움 = edit.dropRegion(line.id)(598 이 dropLine 을 dropRegion 으로 바꿈) - UI_Page: 지점 한 걸음 stepPoints — 위치(site) 같이 실음(596-2 Snap) · 700 줄 - UI_Edit: 고름 강조 선 = strokeCased 검은 테(600-1 · 밝은 바탕 대비) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@
|
||||
* · 사업연도 끝 · 임도 종류 · 측점은 안 받음(끝 연도 = D03 [저장] · 나머지 = 서버 기본) · 설계속도 없음(591-1).
|
||||
* 기준점(591-1 · 요구 452 · 453) = 카카오 지도(공용 editMap · 마우스 설정 따름) 누름 · 끌기로 한 점 →
|
||||
* GET /api/map/address(VWorld · 출처 표기) → 주소 글 · 사업 지역 「시군구 읍면」 채움(손으로 고친 뒤엔 안 덮음)
|
||||
* 지도 머리 [위성] 오른쪽 = 주소 · 장소 찾기(596-5 · 예정노선 위치 칸과 같은 `createPlaceFind` · 서버 VWorld) → 고름 = 그 자리로 기준점.
|
||||
* [등록] = POST /api/d01/project(site = 경위도 · 주소는 서버가 다시 찾음) → 현재 프로젝트 · D03 으로.
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -20,6 +21,7 @@ import { readByKey, writeByKey } from "../A00_Common/b_page_state";
|
||||
import { createGroupProject } from "../A00_Common/group_workflow";
|
||||
import { navigateTo } from "../A00_Common/router";
|
||||
import { createNameComposer } from "../B02_ProjRegister/B02_ProjRegister_UI_Name";
|
||||
import { createPlaceFind } from "../D03_PlanAnalysis/D03_PlanAnalysis_UI_Site";
|
||||
|
||||
const KOREA_CENTER: [number, number] = [127.8, 36.3];
|
||||
const DIGITS = 6; // 경위도 소수 자리(약 0.1 m)
|
||||
@@ -85,12 +87,17 @@ function sitePicker(onRegion: (region: string) => void): {
|
||||
}
|
||||
|
||||
draw();
|
||||
// 찾기 = 칸은 머리 줄([위성] 오른쪽 · 좁으면 줄바꿈) · 알림 · 후보 줄은 머리 아래
|
||||
const find = createPlaceFind((hit) => void moveTo(hit.lon, hit.lat));
|
||||
const field = find.search.parentElement!;
|
||||
field.style.flex = "1 1 12rem";
|
||||
const head = document.createElement("div");
|
||||
head.className = "ui-general-block__actions";
|
||||
head.append(createText(t("DE_Reg_Site"), "muted", 14), typePick.root);
|
||||
head.style.flexWrap = "wrap";
|
||||
head.append(createText(t("DE_Reg_Site"), "muted", 14), typePick.root, field);
|
||||
const root = document.createElement("div");
|
||||
root.dataset.d01Site = "";
|
||||
root.append(head, map.root, where);
|
||||
root.append(head, find.root, map.root, where);
|
||||
return { root, value: () => site };
|
||||
}
|
||||
|
||||
|
||||
@@ -179,13 +179,14 @@ export async function fetchPlanSurface(projectId: string): Promise<PlanSurface |
|
||||
return { modelId: model.id, grid: parseDtmGrid(await fetchCachedBytes(projectId, url)) };
|
||||
}
|
||||
|
||||
/** 지도 겹 그림(569-1) — 작업 좌표계 m 상자(칸 가장자리) · data URL(위 = 북) · 단계 색 · 넓이 비율. */
|
||||
/** 지도 겹 그림(569-1) — 작업 좌표계 m 상자(칸 가장자리) · data URL(위 = 북). */
|
||||
export interface PlanRaster {
|
||||
box_m: [number, number, number, number];
|
||||
/** 경위도 네 귀퉁이(왼쪽 아래 · 오른쪽 아래 · 오른쪽 위 · 왼쪽 위) — GL 바탕(586-1). */
|
||||
box?: Array<[number, number]>;
|
||||
image: string;
|
||||
bands: Array<{ min_deg: number; max_deg: number; color: string; ratio: number }>;
|
||||
/** 급경사만(603) — `counts[v]` = 기울기 % 내림 v(0~250) 칸 수 · 그림 = 회색 칸 값(255 = 빈 칸). */
|
||||
counts?: number[];
|
||||
}
|
||||
|
||||
export const fetchPlanCadastral = (projectId: string): Promise<GeoJsonCollection> =>
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
한 번 1,000 필지 한도 → 꽉 차면 넷으로 다시 쪼갬 · `pnu` 로 겹친 필지 거름 ·
|
||||
저장 = `processed/연속지적도_bounds.geojson`(B04 `/geojson?layer=지적도` 자리) + 곁 열쇠
|
||||
- 급경사 = 평활 안 한 사업 DEM(`models_plan/dtm_sheet_<방법>.npz`) · E07 `slope_rad` ·
|
||||
15° · 35° · 40° 단계 PNG · 파생 저장본(열쇠 = 격자 지문)
|
||||
기울기 % 값 그림 한 장(1 % 단계 · 회색) + 1 % 칸 수 · 범위 색칠 · 비율 = 화면(PLAN 603) ·
|
||||
파생 저장본(열쇠 = 격자 지문)
|
||||
- 음영기복 = 같은 DEM · B07 `hillshade_png`(PLAN 581-1)
|
||||
- 보호 · 규제 구역 = 브이월드 WFS 여덟 종(겹 이름 = `_분석_440-C` 표 1) ·
|
||||
받은 범위로 잘라 종류 · 이름 · 넓이만 · 저장 = `processed/보호구역_bounds.geojson` + 곁 열쇠 ·
|
||||
@@ -64,13 +65,9 @@ SLOPE_KIND = "plan_slope"
|
||||
HILLSHADE_KIND = "plan_hillshade"
|
||||
HILLSHADE_VERSION = 2 # 빈 칸 투명
|
||||
RASTER_KINDS = ("slope", "hillshade")
|
||||
SLOPE_VERSION = 1
|
||||
# (아래 °, 위 °, 색 RGBA) — 임도망_편성 완 · 중 · 급경사지 · 타당성평가 35° · 노선_배치 40° 연속
|
||||
SLOPE_BANDS = (
|
||||
(15.0, 35.0, (250, 204, 21, 110)),
|
||||
(35.0, 40.0, (249, 115, 22, 140)),
|
||||
(40.0, 90.0, (220, 38, 38, 160)),
|
||||
)
|
||||
SLOPE_VERSION = 2 # 값 그림 + 1 % 칸 수(PLAN 603)
|
||||
SLOPE_MAX_PCT = 250 # 칸 값 0~250 = 기울기 % 내림(250 = 250 % 이상)
|
||||
SLOPE_EMPTY = 255 # 빈 칸
|
||||
MAX_SIDE_PX = 4096 # ponytail: 한 변 넘으면 솎음 · 넓은 사업은 조각 그림으로
|
||||
|
||||
|
||||
@@ -215,39 +212,28 @@ def ensure_cadastral(processed: Path, box: Box, epsg: int) -> dict[str, Any]:
|
||||
# ── 급경사 ──────────────────────────────────────────────
|
||||
|
||||
|
||||
def slope_bands_png(z: np.ndarray, valid: np.ndarray, cell_m: float) -> dict[str, Any]:
|
||||
"""격자(행 = 남 → 북) → 단계 PNG(위 = 북 · 15° 미만 · 빈 칸 투명) + 단계 칸 비율."""
|
||||
def slope_values_png(z: np.ndarray, valid: np.ndarray, cell_m: float) -> dict[str, Any]:
|
||||
"""격자(행 = 남 → 북) → 기울기 % 값 그림(회색 · 위 = 북 · 칸 값 = % 내림 0~250 · 빈 칸 255)
|
||||
+ 1 % 칸 수(`counts[v]` = 값 v 칸 수 · 합 = 유효 칸)."""
|
||||
from PIL import Image
|
||||
|
||||
from E07_StabArea.E07_StabArea_Engine import slope_rad
|
||||
|
||||
stride = max(1, math.ceil(max(z.shape) / MAX_SIDE_PX))
|
||||
grid = np.where(valid, np.asarray(z, float), np.nan)[::stride, ::stride]
|
||||
deg = np.degrees(slope_rad(grid, cell_m * stride))
|
||||
known = np.isfinite(deg)
|
||||
index = np.zeros(deg.shape, np.uint8)
|
||||
for i, (low, high, _) in enumerate(SLOPE_BANDS, start=1):
|
||||
index[known & (deg >= low) & (deg < high)] = i
|
||||
total = max(1, int(known.sum()))
|
||||
image = Image.fromarray(np.flipud(index), mode="P")
|
||||
palette = [0, 0, 0] + [c for _, _, rgba in SLOPE_BANDS for c in rgba[:3]]
|
||||
image.putpalette(palette + [0] * (768 - len(palette)))
|
||||
alpha = bytes([0] + [rgba[3] for _, _, rgba in SLOPE_BANDS])
|
||||
pct = np.tan(slope_rad(grid, cell_m * stride)) * 100.0
|
||||
known = np.isfinite(pct)
|
||||
value = np.full(pct.shape, SLOPE_EMPTY, np.uint8)
|
||||
value[known] = np.minimum(np.floor(pct[known]), SLOPE_MAX_PCT).astype(np.uint8)
|
||||
image = Image.fromarray(np.flipud(value), mode="L")
|
||||
buffer = io.BytesIO()
|
||||
image.save(buffer, format="PNG", optimize=True, transparency=alpha)
|
||||
image.save(buffer, format="PNG", optimize=True)
|
||||
counts = np.bincount(value[known], minlength=SLOPE_MAX_PCT + 1)
|
||||
return {
|
||||
"width": image.width,
|
||||
"height": image.height,
|
||||
"image": "data:image/png;base64," + base64.b64encode(buffer.getvalue()).decode("ascii"),
|
||||
"bands": [
|
||||
{
|
||||
"min_deg": low,
|
||||
"max_deg": high,
|
||||
"color": "#{:02x}{:02x}{:02x}".format(*rgba[:3]),
|
||||
"ratio": round(int((index == i).sum()) / total, 4),
|
||||
}
|
||||
for i, (low, high, rgba) in enumerate(SLOPE_BANDS, start=1)
|
||||
],
|
||||
"counts": [int(n) for n in counts],
|
||||
}
|
||||
|
||||
|
||||
@@ -269,17 +255,17 @@ def hillshade_layer_png(z: np.ndarray, valid: np.ndarray, cell_m: float) -> dict
|
||||
def slope_layer(
|
||||
project_root: Path, models_dir: Path, epsg: int, kind: str = "slope"
|
||||
) -> dict[str, Any]:
|
||||
"""급경사(`slope`) · 음영기복(`hillshade`) — 지문(격자 파일 · 단계 · 판) 같으면 저장본."""
|
||||
"""급경사(`slope`) · 음영기복(`hillshade`) — 지문(격자 파일 · 판) 같으면 저장본."""
|
||||
npz = models_dir / f"dtm_sheet_{SHEET_SURFACE_DEFAULT_METHOD}.npz"
|
||||
stamp = file_stamp(npz)
|
||||
if stamp is None:
|
||||
raise FileNotFoundError("사업 지형면이 없습니다 — 지형 받기 뒤 보입니다.")
|
||||
store_kind, version, build = (
|
||||
(SLOPE_KIND, SLOPE_VERSION, slope_bands_png)
|
||||
(SLOPE_KIND, SLOPE_VERSION, slope_values_png)
|
||||
if kind == "slope"
|
||||
else (HILLSHADE_KIND, HILLSHADE_VERSION, hillshade_layer_png)
|
||||
)
|
||||
key = derived_key(store_kind, version, npz.name, stamp, int(epsg), SLOPE_BANDS, MAX_SIDE_PX)
|
||||
key = derived_key(store_kind, version, npz.name, stamp, int(epsg), MAX_SIDE_PX)
|
||||
saved = read_derived(project_root, store_kind, key)
|
||||
if saved is not None:
|
||||
return saved["value"]
|
||||
|
||||
@@ -320,7 +320,7 @@ async def plan_layer_cadastral(project_id: UUID, request: Request) -> Response:
|
||||
|
||||
@router.get("/{project_id}/plan/layers/raster", response_model=None)
|
||||
async def plan_layer_raster(project_id: UUID, kind: str = "slope") -> dict[str, Any]:
|
||||
"""급경사 단계 그림(15° · 35° · 40°) · 음영기복(`hillshade`) — 격자 지문 같으면 저장본 ·
|
||||
"""급경사 기울기 % 값 그림 + 1 % 칸 수(603) · 음영기복(`hillshade`) — 격자 지문 같으면 저장본 ·
|
||||
산사태위험지도(`landslide` · 행안부 WMS) — 받은 범위를 덮으면 저장본."""
|
||||
if kind == "landslide":
|
||||
root, epsg, box = await _layer_root(project_id)
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* 지점 핀(585-1) = 따로 겹(`pointLayer` · 겹 목록 맨 끝 = 늘 맨 위) · 색 = 목록 줄과 같은 `pointColor(i)` · 끌기 · 메뉴 = `D03_PlanAnalysis_UI_Points`.
|
||||
* ========================================================================== */
|
||||
|
||||
import { pointColor } from "@ui/ui_template_palette";
|
||||
import { pointColor, routeColor } from "@ui/ui_template_palette";
|
||||
import { drawStationTicks } from "../B04_PreProcess/B04_PreProcess_UI_MapOverlays";
|
||||
import {
|
||||
distanceAlong,
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
type HeightAt,
|
||||
} from "./logic/D03_PlanAnalysis_Draw";
|
||||
import type { PlanNetwork, Vertex } from "./logic/D03_PlanAnalysis_Plan";
|
||||
import { pointLabel } from "./logic/D03_PlanAnalysis_PointEdit";
|
||||
import { nodeOff } from "./logic/D03_PlanAnalysis_Span";
|
||||
import {
|
||||
planColors,
|
||||
@@ -128,7 +129,7 @@ export function paintNetwork(
|
||||
context.lineJoin = "round";
|
||||
network.lines.forEach((line, index) => {
|
||||
if (line.vertices.length < 2) return;
|
||||
context.strokeStyle = index === active ? colors.active : colors.line;
|
||||
context.strokeStyle = index === active ? colors.active : routeColor(index);
|
||||
context.lineWidth = line.branch ? 2.5 : 3;
|
||||
const path = (vertices: ReadonlyArray<Vertex>): void => {
|
||||
context.beginPath();
|
||||
@@ -181,7 +182,7 @@ export function paintNetwork(
|
||||
}
|
||||
|
||||
/** 지점 핀 겹(585-1) — 꼭지 = 지점 · 둥근 머리 · 「지점 N」 · 화면 px 고정 크기 · 돌린 지도에서도 섬. */
|
||||
export function pointLayer(points: () => Vertex[]): PlanMapLayer {
|
||||
export function pointLayer(points: () => Vertex[], names: () => string[] = () => []): PlanMapLayer {
|
||||
return {
|
||||
draw(context, map) {
|
||||
points().forEach((point, i) => {
|
||||
@@ -206,7 +207,7 @@ export function pointLayer(points: () => Vertex[]): PlanMapLayer {
|
||||
context.fillStyle = "#ffffff";
|
||||
context.fill();
|
||||
});
|
||||
planLabel(context, map, sx, sy - 38, `지점 ${i + 1}`, "bold 12px");
|
||||
planLabel(context, map, sx, sy - 38, pointLabel(names(), i), "bold 12px");
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -44,7 +44,14 @@ import {
|
||||
type PlanNode,
|
||||
type PlanSegment,
|
||||
} from "./logic/D03_PlanAnalysis_Select";
|
||||
import { lineAt, paintMark, planLabel, screenGap, SNAP_PX } from "./D03_PlanAnalysis_UI_DrawPaint";
|
||||
import {
|
||||
lineAt,
|
||||
paintMark,
|
||||
planLabel,
|
||||
screenGap,
|
||||
SNAP_PX,
|
||||
strokeCased,
|
||||
} from "./D03_PlanAnalysis_UI_DrawPaint";
|
||||
import { planColors, type PlanMapLayer, type ScreenMap } from "./D03_PlanAnalysis_UI_Map";
|
||||
import { createPlanSelect, type PlanScope, type PlanSelection } from "./D03_PlanAnalysis_UI_Select";
|
||||
|
||||
@@ -305,9 +312,6 @@ export function createPlanEdit(params: PlanEditParams): PlanEdit {
|
||||
const picked = sel.get();
|
||||
context.lineCap = "round";
|
||||
context.lineJoin = "round";
|
||||
context.strokeStyle = colors.active;
|
||||
context.globalAlpha = 0.8;
|
||||
context.lineWidth = 8;
|
||||
for (const points of sel.paths()) {
|
||||
context.beginPath();
|
||||
points.forEach((point, i) => {
|
||||
@@ -315,9 +319,8 @@ export function createPlanEdit(params: PlanEditParams): PlanEdit {
|
||||
if (i) context.lineTo(sx, sy);
|
||||
else context.moveTo(sx, sy);
|
||||
});
|
||||
context.stroke();
|
||||
strokeCased(context, colors.active, 8); // 밝은 바탕 대비 = 검은 테(600-1)
|
||||
}
|
||||
context.globalAlpha = 1;
|
||||
// 꺾임점 손잡이(고른 것이 든 선)
|
||||
const lines = pickedLines(network);
|
||||
context.lineWidth = 2;
|
||||
|
||||
@@ -55,7 +55,7 @@ export const GROUPS: readonly (readonly LayerDef[])[] = [
|
||||
{ key: "satellite", icon: "satellite", title: "위성사진", on: true },
|
||||
{ key: "hillshade", icon: "sun", title: "음영기복(2 m 지형 · 산줄기 · 골)", on: false },
|
||||
{ key: "contour", icon: "mountain", title: "도엽 등고선(굵은 선 = 계곡선 25 m)", on: true },
|
||||
{ key: "slope", icon: "angle", title: "급경사 색(15° · 35° · 40° 이상)", on: false },
|
||||
{ key: "slope", icon: "angle", title: "급경사 색(기울기 % 범위 · 범례 ⚙ 로 바꿈)", on: false },
|
||||
],
|
||||
[
|
||||
{ key: "stream", icon: "droplet", title: "세류선(가는 파랑) · 하천(굵은 파랑)", on: true },
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* =============================================================================
|
||||
* D03_PlanAnalysis_UI_LayerLegend.ts
|
||||
* D03 바탕 겹 받은 자료 꼴 · 범례 줄 · 겹별 수(PLAN 569-2 · 581 · 584-1 · 584-3) — 켠 겹의 받은 범위 안 수 · 넓이.
|
||||
* 급경사 = 고른 범위(603)마다 % · ° · 비율(1 % 칸 수로 바로 셈).
|
||||
* 부르는 곳 = `D03_PlanAnalysis_UI_Layers.ts`(받기 · 그리기).
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -23,6 +24,12 @@ import {
|
||||
type LineLayerKey,
|
||||
type PlanLayerKey,
|
||||
} from "./D03_PlanAnalysis_UI_LayerDefs";
|
||||
import {
|
||||
SLOPE_MAX_PCT,
|
||||
bandRatios,
|
||||
pctToDeg,
|
||||
type SlopeBand,
|
||||
} from "./logic/D03_PlanAnalysis_SlopeBands";
|
||||
|
||||
/** 선 겹 — 그릴 선 · 받은 범위 안 가닥 · 길이 m. */
|
||||
export interface PreparedLines {
|
||||
@@ -39,7 +46,15 @@ export type Prepared = Record<LineLayerKey, PreparedLines> & {
|
||||
road: { all: PreparedLayer; small: PreparedLayer };
|
||||
/** 올린 임도망도(584-3). */
|
||||
forestRoadFile: { layer: PreparedLayer; info: PlanForestRoads };
|
||||
slope: { raster: PlanRaster; image: HTMLImageElement };
|
||||
/** 급경사(603) — 값 그림 칸 · 고른 범위 · 칠한 그림(blob 주소) · 칠하기 ms. */
|
||||
slope: {
|
||||
raster: PlanRaster;
|
||||
image: HTMLImageElement;
|
||||
values: ImageData;
|
||||
bands: SlopeBand[];
|
||||
url: string;
|
||||
paintMs: number;
|
||||
};
|
||||
hillshade: { raster: PlanRaster; image: HTMLImageElement };
|
||||
landslide: { raster: PlanRaster; image: HTMLImageElement };
|
||||
/** 산사태취약지역(584-3) — 점(경위도) · 지번 맞는 필지 테두리. */
|
||||
@@ -55,6 +70,19 @@ export type Prepared = Record<LineLayerKey, PreparedLines> & {
|
||||
|
||||
const km = (m: number): string => `${(m / 1000).toFixed(2)} km`;
|
||||
|
||||
/** 급경사 범례 — 범위마다 「아래~위 %(°) · 비율」(위 = 끝 = 「↑」). */
|
||||
function slopeRow({ raster, bands }: Prepared["slope"]): LegendItem[] {
|
||||
const ratios = bandRatios(raster.counts ?? [], bands);
|
||||
const deg = (pct: number): string => pctToDeg(pct).toFixed(0);
|
||||
return bands.map((band, i) => {
|
||||
const open = band.high >= SLOPE_MAX_PCT;
|
||||
const range = open
|
||||
? `${band.low} %↑(${deg(band.low)}°↑)`
|
||||
: `${band.low}~${band.high} %(${deg(band.low)}~${deg(band.high)}°)`;
|
||||
return { label: `${range} · ${(ratios[i] * 100).toFixed(1)} %`, color: `var(${band.color})` };
|
||||
});
|
||||
}
|
||||
|
||||
/** 산사태취약지역 범례 — 점 · 필지 · 위치 없는 시도 · 표 없는 시도 · 지적 못 받음. */
|
||||
function landslideZoneRow({ info }: Prepared["landslideZones"]): LegendItem[] {
|
||||
const color = "var(--draw-map-landslide-zone)";
|
||||
@@ -86,13 +114,7 @@ export function legendRows(
|
||||
data: Partial<Prepared>,
|
||||
): LegendItem[][] {
|
||||
const rows: LegendItem[][] = [];
|
||||
if (on.slope && data.slope)
|
||||
rows.push(
|
||||
data.slope.raster.bands.map((band) => ({
|
||||
label: `${band.min_deg}${band.max_deg < 90 ? `~${band.max_deg}°` : "°↑"} ${(band.ratio * 100).toFixed(1)} %`,
|
||||
color: band.color,
|
||||
})),
|
||||
);
|
||||
if (on.slope && data.slope) rows.push(slopeRow(data.slope));
|
||||
if (on.landslide && data.landslide) rows.push(LANDSLIDE_LEGEND);
|
||||
if (on.landslideZones && data.landslideZones) rows.push(landslideZoneRow(data.landslideZones));
|
||||
if (on.zones && data.zones) {
|
||||
@@ -211,6 +233,17 @@ export const layerCounts = (data: Partial<Prepared>): Record<string, number> =>
|
||||
band_road_m2: data.bands.info.road.area_m2,
|
||||
}),
|
||||
...(data.hillshade && { hillshade_px: data.hillshade.image.naturalWidth }),
|
||||
...(data.slope && {
|
||||
slope_px: data.slope.image.naturalWidth,
|
||||
slope_cells: (data.slope.raster.counts ?? []).reduce((sum, n) => sum + n, 0),
|
||||
slope_paint_ms: data.slope.paintMs,
|
||||
...Object.fromEntries(
|
||||
bandRatios(data.slope.raster.counts ?? [], data.slope.bands).map((r, i) => [
|
||||
`slope_band_${i}`,
|
||||
r,
|
||||
]),
|
||||
),
|
||||
}),
|
||||
...(data.landslide && { landslide_px: data.landslide.image.naturalWidth }),
|
||||
...Object.fromEntries(
|
||||
LINE_KEYS.flatMap((key) => {
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
* 건물 → 등고선 → 도로 → 임도 짐작 → 올린 임도망도 → 세류 → 선 겹 → 산사태취약지역 → 지명 → 그은 선.
|
||||
* 겹 모두 = 바탕 캔버스(`base` · 582) · 등고선 = 줌 단계 색인 · 글 폭 기억.
|
||||
* 범례 = 켠 겹의 받은 범위 안 수 · 넓이(왼쪽 아래 · `D03_PlanAnalysis_UI_LayerLegend`).
|
||||
* 급경사 = 서버 값 그림을 고른 범위대로 칠함 · 범례 ⚙ → 범위 모달(603 · `D03_PlanAnalysis_UI_SlopeBands`).
|
||||
* GL 바탕(586-2 · 처음 = 켬 · 끝 단추 끔 = 옛 캔버스 · `aislo:pref:d03.canvas`) — 겹 스물 · 등고 글 · 지명을
|
||||
* MapLibre 가 그림(`D03_PlanAnalysis_UI_MapGl`) · 캔버스 바탕 겹은 쉼(등고 간격 셈만) · 받기 · 범례는 같음 ·
|
||||
* WebGL 못 씀 · 잃음 = 저절로 옛 캔버스 + 알림(취향은 안 바꿈).
|
||||
@@ -72,6 +73,14 @@ import {
|
||||
import { layerCounts, legendRows, type Prepared } from "./D03_PlanAnalysis_UI_LayerLegend";
|
||||
import { createCanvasLayers } from "./D03_PlanAnalysis_UI_LayerPaint";
|
||||
import { createGlBase, GL_FONT, type GlImage } from "./D03_PlanAnalysis_UI_MapGl";
|
||||
import {
|
||||
openSlopeBands,
|
||||
paintSlope,
|
||||
readSlopeBands,
|
||||
slopeGear,
|
||||
slopeValues,
|
||||
writeSlopeBands,
|
||||
} from "./D03_PlanAnalysis_UI_SlopeBands";
|
||||
import type { PlanMap, PlanMapLayer } from "./D03_PlanAnalysis_UI_Map";
|
||||
import type { Vertex } from "./logic/D03_PlanAnalysis_Plan";
|
||||
|
||||
@@ -200,9 +209,38 @@ export function createPlanLayers(opts: {
|
||||
gl.setData("hillshade", hillshade && (on.satellite ? hillshade.shade : hillshade.plain));
|
||||
const rows = meta ? legendRows(on, data) : [];
|
||||
legend.replaceChildren(...rows.map((row) => createLegend(row)));
|
||||
if (meta && on.slope && data.slope) legend.firstElementChild?.append(gear);
|
||||
legend.hidden = !rows.length;
|
||||
}
|
||||
|
||||
/** 급경사 범위(603) — 바꾸면 범례 바로 · 그림은 다시 칠함(늦은 칠하기는 버림). */
|
||||
let slopeBands = readSlopeBands();
|
||||
let slopeTurn = 0;
|
||||
const gear = slopeGear(() =>
|
||||
openSlopeBands(slopeBands, (bands) => {
|
||||
slopeBands = bands;
|
||||
writeSlopeBands(bands);
|
||||
if (data.slope) data.slope.bands = bands;
|
||||
sync();
|
||||
void repaintSlope();
|
||||
}),
|
||||
);
|
||||
async function repaintSlope(): Promise<void> {
|
||||
const got = data.slope;
|
||||
if (!got) return;
|
||||
const turn = ++slopeTurn;
|
||||
const started = performance.now();
|
||||
const painted = await paintSlope(got.values, got.bands);
|
||||
if (turn !== slopeTurn || data.slope !== got) return URL.revokeObjectURL(painted.url);
|
||||
const old = got.url;
|
||||
Object.assign(got, { image: painted.image, url: painted.url });
|
||||
got.paintMs = Math.round(performance.now() - started);
|
||||
gl.setData("slope", glImage(got.raster, painted.url));
|
||||
if (old) URL.revokeObjectURL(old);
|
||||
sync();
|
||||
map.redrawBase();
|
||||
}
|
||||
|
||||
sync();
|
||||
useGl(glOn);
|
||||
|
||||
@@ -353,7 +391,22 @@ export function createPlanLayers(opts: {
|
||||
} as GeoJsonCollection);
|
||||
if (info.missing.length)
|
||||
showToast(`산사태취약지역 — ${info.missing.join(" · ")} 자료 없음`, "info");
|
||||
} else if (key === "slope" || key === "hillshade" || key === "landslide") {
|
||||
} else if (key === "slope") {
|
||||
const raster = await fetchPlanRaster(projectId, key);
|
||||
const values = slopeValues(await loadImage(raster.image));
|
||||
const bands = slopeBands;
|
||||
const started = performance.now();
|
||||
const painted = await paintSlope(values, bands);
|
||||
if (data.slope?.url) URL.revokeObjectURL(data.slope.url);
|
||||
data.slope = { raster, values, bands, ...painted, paintMs: 0 };
|
||||
data.slope.paintMs = Math.round(performance.now() - started);
|
||||
gl.setData("slope", glImage(raster, painted.url));
|
||||
// 칠하는 동안 범위가 바뀜 = 새 범위로 다시
|
||||
if (bands !== slopeBands) {
|
||||
data.slope.bands = slopeBands;
|
||||
void repaintSlope();
|
||||
}
|
||||
} else if (key === "hillshade" || key === "landslide") {
|
||||
const raster = await fetchPlanRaster(projectId, key);
|
||||
const image = await loadImage(raster.image);
|
||||
data[key] = { raster, image };
|
||||
|
||||
@@ -19,10 +19,8 @@
|
||||
import "./D03_PlanAnalysis_UI_Style.css";
|
||||
import { ROUTES } from "@config/config_frontend";
|
||||
import { attachCollapsible, createPanelCard } from "@ui/ui_template_collapsible";
|
||||
import { pickFiles } from "@ui/ui_template_drop";
|
||||
import { askConfirm } from "@ui/ui_template_modal";
|
||||
import { createProgressCircle } from "@ui/ui_template_progress";
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
import {
|
||||
createButton,
|
||||
hideLoadingOverlay,
|
||||
@@ -42,9 +40,12 @@ import {
|
||||
fetchTerrainExtent,
|
||||
savePlanNetwork,
|
||||
startTerrain,
|
||||
type PlaceHit,
|
||||
type PlanJob,
|
||||
type PlanNetworkView,
|
||||
type PlanSurface,
|
||||
type TerrainAim,
|
||||
type TerrainStart,
|
||||
} from "./D03_PlanAnalysis_Api";
|
||||
import { openPlan3d } from "./D03_PlanAnalysis_UI_3D";
|
||||
import { createPlanDraw } from "./D03_PlanAnalysis_UI_Draw";
|
||||
@@ -60,6 +61,7 @@ import { createPlanMenu } from "./D03_PlanAnalysis_UI_Menu";
|
||||
import { createPlanPoints } from "./D03_PlanAnalysis_UI_Points";
|
||||
import { createPlanPieces } from "./D03_PlanAnalysis_UI_Pieces";
|
||||
import { createPlanProps } from "./D03_PlanAnalysis_UI_Props";
|
||||
import { createPlanSite } from "./D03_PlanAnalysis_UI_Site";
|
||||
import {
|
||||
emptyNetwork,
|
||||
fromServer,
|
||||
@@ -76,16 +78,26 @@ const POLL_MS = 1000;
|
||||
const INTERRUPTED = "끊김(서버 다시 켜짐 등)";
|
||||
/** 처음 맞춤 = 기준점 둘레(서버 `SITE_RADIUS_M`). */
|
||||
const SITE_RADIUS_M = 1000;
|
||||
/** 조용히 받기가 덮는 거리 — 기준점에서 이보다 먼 지점 · 선은 안 받음(위치를 멀리 옮긴 뒤 옛 선 = 거대 받기 막음). */
|
||||
const COVER_MAX_M = 10000;
|
||||
/** 되돌리기 쌓는 수. */
|
||||
const UNDO_MAX = 100;
|
||||
/** 캐시 꼴 판 — 옛 꼴(지점 없음 · 갈래 고름)은 안 읽음. */
|
||||
const DRAFT_VERSION = 4;
|
||||
/** 캐시 꼴 판 — 옛 꼴(지점 없음 · 갈래 고름 · 위치 없음)은 안 읽음. */
|
||||
const DRAFT_VERSION = 5;
|
||||
|
||||
/** 한 걸음 = 지점(+ 이름 · 차례 같음 · 빈 값 = 「지점 N」) + 노선(되돌리기 · 캐시 · 고침 판정). */
|
||||
/** 기준점(위치 칸 · 등록과 한 칸 `projects.site_*`). */
|
||||
interface Site {
|
||||
lon: number;
|
||||
lat: number;
|
||||
address: string | null;
|
||||
}
|
||||
|
||||
/** 한 걸음 = 지점(+ 이름 · 차례 같음 · 빈 값 = 「지점 N」) + 노선 + 위치(되돌리기 · 캐시 · 고침 판정). */
|
||||
interface Snap {
|
||||
network: PlanNetwork;
|
||||
points: Vertex[];
|
||||
pointNames: string[];
|
||||
site: Site | null;
|
||||
}
|
||||
|
||||
interface Draft extends Snap {
|
||||
@@ -101,10 +113,12 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
let editable = false;
|
||||
let points: Vertex[] = []; // 지점(긋기에 붙는 점 · 591-2)
|
||||
let pointNames: string[] = []; // 지점 이름(596 · 597-2)
|
||||
let site: Site | null = null; // 위치(캐시 · [저장] 때 정본 596-2)
|
||||
let savedSite: Site | null = null;
|
||||
let epsg = 0;
|
||||
let yearStart = new Date().getFullYear(); // 새 선 처음 해
|
||||
let network: PlanNetwork = emptyNetwork();
|
||||
let savedJson = JSON.stringify({ network, points, pointNames }); // 못 읽은 채 떠남 = 고침 없음
|
||||
let savedJson = JSON.stringify({ network, points, pointNames, site }); // 못 읽은 채 떠남 = 고침 없음
|
||||
let undoStack: Snap[] = [];
|
||||
let redoStack: Snap[] = [];
|
||||
let surface: PlanSurface | null = null;
|
||||
@@ -114,6 +128,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
let ready = false; // 들어올 때 받기 판단이 끝남(그 앞 보기 바뀜은 안 받음)
|
||||
let busy = false;
|
||||
let failed: string | null = null;
|
||||
let note = ""; // 위치 칸 알림(기준점 없음 · 못 불러옴)
|
||||
let measuring = false;
|
||||
let lastSaveMs: number | null = null;
|
||||
|
||||
@@ -128,6 +143,8 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
refresh();
|
||||
},
|
||||
});
|
||||
const stepPoints = (nextPoints: Vertex[], next: PlanNetwork, nextNames = pointNames): void =>
|
||||
step({ network: next, points: nextPoints, pointNames: nextNames, site }); // 지점 한 걸음 — 위치 그대로
|
||||
const edit = createPlanEdit({
|
||||
network: () => network,
|
||||
base: () => points,
|
||||
@@ -136,7 +153,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
network = normalizePieces(next, yearStart);
|
||||
map.redraw();
|
||||
},
|
||||
commitPoints: (nextPoints, next) => step({ network: next, points: nextPoints, pointNames }),
|
||||
commitPoints: stepPoints,
|
||||
showPoints: (nextPoints, next) => {
|
||||
[points, network] = [nextPoints, next];
|
||||
map.redraw();
|
||||
@@ -154,11 +171,17 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
heightAt: () => z,
|
||||
epsg: () => epsg,
|
||||
points: () => points,
|
||||
addPoint: (at) => step({ network, points: [...points, at], pointNames: [...pointNames, ""] }),
|
||||
commitPoints: (nextPoints, next) => step({ network: next, points: nextPoints, pointNames }),
|
||||
addPoint: (at) => stepPoints([...points, at], network, [...pointNames, ""]),
|
||||
commitPoints: stepPoints,
|
||||
respan: draw.respan,
|
||||
});
|
||||
const pieces = createPlanPieces({ network: () => network, edit });
|
||||
const pieces = createPlanPieces({
|
||||
network: () => network,
|
||||
edit,
|
||||
editable: () => editable,
|
||||
yearOptions: props.yearOptions,
|
||||
commit,
|
||||
});
|
||||
const measure = createPlanMeasure(
|
||||
() => z,
|
||||
() => network,
|
||||
@@ -168,8 +191,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
network: () => network,
|
||||
names: () => pointNames,
|
||||
epsg: () => epsg,
|
||||
commit: (nextPoints, nextNames, next) =>
|
||||
step({ network: next, points: nextPoints, pointNames: nextNames }),
|
||||
commit: (nextPoints, nextNames, next) => stepPoints(nextPoints, next, nextNames),
|
||||
center: () => viewMid,
|
||||
show: (nextPoints, next) => {
|
||||
[points, network] = [nextPoints, next];
|
||||
@@ -214,30 +236,16 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
main.className = "d03-plan";
|
||||
main.append(map.root);
|
||||
|
||||
const terrainText = createText("", "muted");
|
||||
terrainText.classList.add("d03-plan__hint");
|
||||
const retryButton = createButton({
|
||||
label: "다시 받기",
|
||||
variant: "filled",
|
||||
size: 28,
|
||||
onClick: () => {
|
||||
const siteBox = createPlanSite({
|
||||
onPick: (hit) => void pickSite(hit),
|
||||
onRetry: () => {
|
||||
[failed, lastTried, ready] = [null, "", true];
|
||||
void ensureCovered();
|
||||
void ensureCovered(true);
|
||||
},
|
||||
onUpload: (files) => void uploadRoads(files),
|
||||
});
|
||||
const terrainCard = createPanelCard("지형", false);
|
||||
const roadButton = createButton({
|
||||
label: "임도망 올리기",
|
||||
variant: "ghost",
|
||||
size: 28,
|
||||
onClick: () =>
|
||||
pickFiles({ accept: ".zip,.shp,.shx,.dbf,.prj,.cpg", multiple: true }, (files) => {
|
||||
void backdrop.uploadRoads(files);
|
||||
}),
|
||||
});
|
||||
roadButton.title =
|
||||
"임도망도 SHP(시군구 zip 하나 · 또는 shp · shx · dbf · prj 함께 · 100 MB 까지)";
|
||||
terrainCard.append(terrainText, retryButton, roadButton);
|
||||
const siteCard = createPanelCard("위치", false);
|
||||
siteCard.append(...siteBox.parts);
|
||||
const pointCard = createPanelCard("지점", false);
|
||||
pointCard.append(marks.panel);
|
||||
const drawCard = createPanelCard("그리기", false);
|
||||
@@ -255,7 +263,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
actions.append(saveButton, resetButton);
|
||||
const panel = document.createElement("div");
|
||||
panel.className = "ui-sidebar-panel d03-plan__panel";
|
||||
panel.append(terrainCard, pointCard, drawCard, pieceCard, actions);
|
||||
panel.append(siteCard, pointCard, drawCard, pieceCard, actions);
|
||||
attachCollapsible(panel);
|
||||
// 떠날 때(단계 아이콘 · [<] [>] · 머리 메뉴 · 뒤로) 고친 선이 있으면 공용 물음 [취소][폐기][저장](572-3)
|
||||
mountGroupStep(root, ROUTES.D03_PLAN_ANALYSIS, {
|
||||
@@ -268,7 +276,27 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
root.querySelector(".ui-workflow-layout")?.classList.add("d03-plan-layout");
|
||||
|
||||
const allVertices = (): Vertex[] => network.lines.flatMap((line) => line.vertices);
|
||||
const snap = (): Snap => ({ network, points, pointNames });
|
||||
const snap = (): Snap => ({ network, points, pointNames, site });
|
||||
/** 위치를 옮기고 [저장] 전 — 받기 · 범위 물음에 새 기준점을 실음. */
|
||||
const moved = (): TerrainAim["center"] =>
|
||||
site && JSON.stringify(site) !== JSON.stringify(savedSite)
|
||||
? { lon: site.lon, lat: site.lat }
|
||||
: null;
|
||||
/** 기준점 둘레 ±1 km(작업 좌표) — 처음 맞춤 · 위치 옮김 · 임도망 맞춤. */
|
||||
const around = (at: Site | null): Vertex[] => {
|
||||
const tm = tmParams(epsg);
|
||||
if (!at || !tm) return [];
|
||||
const [x, y] = lonLatToTm(tm, at.lon, at.lat);
|
||||
return [
|
||||
[x - SITE_RADIUS_M, y - SITE_RADIUS_M],
|
||||
[x + SITE_RADIUS_M, y + SITE_RADIUS_M],
|
||||
];
|
||||
};
|
||||
const fitTo = (box: Vertex[]): void => {
|
||||
if (!box.length) return;
|
||||
map.update({ fit: box });
|
||||
map.fit();
|
||||
};
|
||||
const dirty = (): boolean => JSON.stringify(snap()) !== savedJson;
|
||||
const keepDraft = (): void =>
|
||||
dirty()
|
||||
@@ -279,10 +307,11 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
function step(next: Snap): void {
|
||||
undoStack = [...undoStack.slice(1 - UNDO_MAX), snap()];
|
||||
redoStack = [];
|
||||
[network, points, pointNames] = [
|
||||
[network, points, pointNames, site] = [
|
||||
normalizePieces(next.network, yearStart),
|
||||
next.points,
|
||||
next.pointNames,
|
||||
next.site,
|
||||
];
|
||||
keepDraft();
|
||||
refresh();
|
||||
@@ -290,14 +319,14 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
}
|
||||
|
||||
function commit(next: PlanNetwork): void {
|
||||
step({ network: next, points, pointNames });
|
||||
step({ network: next, points, pointNames, site });
|
||||
}
|
||||
|
||||
function undo(): void {
|
||||
const last = undoStack.pop();
|
||||
if (!last) return;
|
||||
redoStack.push(snap());
|
||||
({ network, points, pointNames } = last);
|
||||
({ network, points, pointNames, site } = last);
|
||||
keepDraft();
|
||||
refresh();
|
||||
}
|
||||
@@ -306,7 +335,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
const next = redoStack.pop();
|
||||
if (!next) return;
|
||||
undoStack.push(snap());
|
||||
({ network, points, pointNames } = next);
|
||||
({ network, points, pointNames, site } = next);
|
||||
keepDraft();
|
||||
refresh();
|
||||
}
|
||||
@@ -336,14 +365,16 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
const inside = (list: Vertex[]): boolean =>
|
||||
!!have &&
|
||||
list.every(([x, y]) => x >= have![0] && x <= have![2] && y >= have![1] && y <= have![3]);
|
||||
const outside = (): boolean => !!have && !inside([...points, ...allVertices()]);
|
||||
|
||||
/** 조용히 받기(요구 453) — 지점 · 선이 받은 범위 밖이면 이어 받음(지도 보기만 옮김 = 안 받음 596-4) ·
|
||||
* 단추 · 막는 창 없음 · 실패 · 끊김 = [다시 받기] · 같은 요청은 한 번만. */
|
||||
async function ensureCovered(): Promise<void> {
|
||||
async function ensureCovered(again = false): Promise<void> {
|
||||
if (!ready || !editable || busy || failed || !epsg || !root.isConnected) return;
|
||||
const extra = [...points, ...allVertices()];
|
||||
if (inside(extra)) return; // 받을 때 서버가 여유를 더함
|
||||
const [lo, hi] = around(site);
|
||||
const near = ([x, y]: Vertex): boolean =>
|
||||
!lo || Math.hypot(x - (lo[0] + hi[0]) / 2, y - (lo[1] + hi[1]) / 2) <= COVER_MAX_M;
|
||||
const extra = [...points, ...allVertices()].filter(near);
|
||||
if (!again && inside(extra)) return; // 받을 때 서버가 여유를 더함
|
||||
const key = JSON.stringify([have, extra]);
|
||||
if (key === lastTried) return;
|
||||
lastTried = key;
|
||||
@@ -364,34 +395,89 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
props.render();
|
||||
pieces.render();
|
||||
marks.render();
|
||||
retryButton.hidden = !editable || busy || !failed;
|
||||
roadButton.hidden = !editable;
|
||||
if (!busy)
|
||||
terrainText.textContent = failed
|
||||
? `지형 받기 실패 — ${failed}`
|
||||
: !surface
|
||||
? editable
|
||||
? "받은 지형 없음 — 기준점 둘레를 받습니다(길이 · 기울기는 지형을 받은 뒤)."
|
||||
: "받은 지형 없음"
|
||||
: outside()
|
||||
? "지점 또는 그은 선이 받은 지형 밖 — 이어 받습니다."
|
||||
: "받은 지형 안";
|
||||
siteBox.render({
|
||||
address: site?.address ?? null,
|
||||
note: failed ? `지형 받기 실패 — ${failed}` : note,
|
||||
retry: !busy && !!failed,
|
||||
editable,
|
||||
});
|
||||
saveButton.hidden = resetButton.hidden = !editable;
|
||||
saveButton.disabled = busy || !dirty();
|
||||
}
|
||||
|
||||
async function download(extra: Vertex[]): Promise<void> {
|
||||
/** 받기 — 멂(받은 범위에서 5 km 넘음) · 다른 TM 띠 = 물음 한 번 뒤 옛 범위 버리고 새로(`reset`) ·
|
||||
* 아니오 = 안 받음(거짓). 이미 도는 일(다른 창)이면 그것을 지켜봄. */
|
||||
async function download(extra: Vertex[]): Promise<boolean> {
|
||||
busy = true; // 요청이 가는 동안 다른 받기를 막음(지켜보기가 이어 받음)
|
||||
const where: TerrainAim = { center: moved() };
|
||||
try {
|
||||
await startTerrain(projectId, null, extra);
|
||||
let answer: TerrainStart = await startTerrain(projectId, null, extra, where);
|
||||
if (answer.status === "far" || answer.status === "band") {
|
||||
const band = answer.status === "band";
|
||||
const ok = await askConfirm({
|
||||
title: band ? "다른 좌표 띠로 옮김" : "멀리 옮김",
|
||||
message: band
|
||||
? `${answer.message} 그은 선 · 지점을 지우고 새 자리 둘레를 받습니다.`
|
||||
: `${answer.message} 받은 지형을 버리고 새 자리 둘레를 받습니다.`,
|
||||
okLabel: "새로 받기",
|
||||
danger: band,
|
||||
});
|
||||
if (!ok) {
|
||||
busy = false;
|
||||
refresh();
|
||||
return false;
|
||||
}
|
||||
if (answer.status === "band") clearForBand(answer.center_epsg);
|
||||
answer = await startTerrain(projectId, null, [], { ...where, reset: true });
|
||||
}
|
||||
} catch (error) {
|
||||
// 이미 도는 일(다른 창)이면 그것을 지켜봄
|
||||
if (!/이미 돌고/.test(errorText(error))) {
|
||||
busy = false;
|
||||
return void showToast(errorText(error), "error");
|
||||
refresh();
|
||||
showToast(errorText(error), "error");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
await follow();
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 다른 TM 띠 — 작업 좌표가 바뀜 · 선 · 지점을 걷고 되돌리기를 비움(옛 좌표라 못 되돌림). */
|
||||
function clearForBand(next: number): void {
|
||||
epsg = next;
|
||||
[network, points, pointNames] = [emptyNetwork(), [], []];
|
||||
[undoStack, redoStack, have] = [[], [], null];
|
||||
map.update({ epsg });
|
||||
keepDraft();
|
||||
}
|
||||
|
||||
/** 위치 칸 고름 — 기준점 캐시(한 걸음 · [저장] 때 정본) → 지도 이동 → 둘레 받기(멂 · 띠 = 물음 · 아니오 = 되돌림). */
|
||||
async function pickSite(hit: PlaceHit): Promise<void> {
|
||||
if (busy) return void showToast("지형을 받는 중 — 끝난 뒤 다시 고르세요.", "info");
|
||||
epsg ||= hit.lon < 126 ? 5185 : hit.lon < 128 ? 5186 : 5187; // 기준점 없던 프로젝트(서버 띠 규칙)
|
||||
[note, failed, lastTried, ready] = ["", null, "", false]; // 걸음의 조용히 받기는 막고 아래에서 한 번
|
||||
step({ ...snap(), site: { lon: hit.lon, lat: hit.lat, address: hit.address || hit.title } });
|
||||
ready = true;
|
||||
fitTo(around(site));
|
||||
if (await download([])) return; // 새 자리 둘레(center) — 지점 · 선은 끝난 뒤 조용히 받기가
|
||||
undo(); // 물음에 아니오 · 받기 못 함 = 위치 그대로
|
||||
redoStack.pop();
|
||||
fitTo(around(site));
|
||||
}
|
||||
|
||||
/** 임도망 올리기 — 지도 맞춤 = 주소지 둘레 ∪ 잘린 임도 상자(없으면 주소지). */
|
||||
async function uploadRoads(files: File[]): Promise<void> {
|
||||
const info = await backdrop.uploadRoads(files);
|
||||
const tm = tmParams(epsg);
|
||||
if (!info) return;
|
||||
const roads: Vertex[] =
|
||||
tm && info.bounds
|
||||
? [
|
||||
lonLatToTm(tm, info.bounds[0], info.bounds[1]),
|
||||
lonLatToTm(tm, info.bounds[2], info.bounds[3]),
|
||||
]
|
||||
: [];
|
||||
fitTo([...around(site), ...roads]);
|
||||
}
|
||||
|
||||
/** 도는 받기를 지켜보고 끝나면 지형 · 바탕을 다시 읽음. */
|
||||
@@ -453,14 +539,16 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
? fromServer(server.lines, server.marks, server.pieces, yearStart)
|
||||
: emptyNetwork();
|
||||
points = view.base.points.map((p): Vertex => [p.x, p.y]);
|
||||
pointNames = view.base.points.map(() => ""); // 594-1 이름 칸이 들어오면 `p.name ?? ""`
|
||||
pointNames = view.base.points.map((p) => p.name ?? "");
|
||||
site = savedSite = view.base.site;
|
||||
if (view.base.epsg) epsg = view.base.epsg;
|
||||
savedJson = JSON.stringify(snap());
|
||||
}
|
||||
|
||||
function serverBody(): Parameters<typeof savePlanNetwork>[1] {
|
||||
const { lines, pieces } = network;
|
||||
return { epsg, points, lines, marks: marksOf(network), pieces };
|
||||
const body = { epsg, points, point_names: pointNames, lines, marks: marksOf(network), pieces };
|
||||
return moved() && site ? { ...body, site } : body;
|
||||
}
|
||||
|
||||
/** [저장] — 서버가 다시 셈 · 실패면 그대로(캐시 남음). 참 = 저장됨. */
|
||||
@@ -497,7 +585,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
draw.setMode("idle");
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
const body = { epsg, points, lines: [], marks: [], pieces: [] };
|
||||
const body = { epsg, points, point_names: pointNames, lines: [], marks: [], pieces: [] };
|
||||
takeServer(await savePlanNetwork(projectId, body));
|
||||
clearState("d03-plan", projectId);
|
||||
undoStack = [];
|
||||
@@ -530,6 +618,8 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
base: () => points,
|
||||
have: () => have,
|
||||
busy: () => busy,
|
||||
site: () => site,
|
||||
pickSite,
|
||||
lineNames: () => lineNames(network.lines),
|
||||
screenOf: map.screenOf,
|
||||
screenOfLonLat: map.screenOfLonLat,
|
||||
@@ -576,26 +666,18 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
editable = !!(await fetchSessionUser())?.is_forest_service;
|
||||
draw.setEditable(editable);
|
||||
const view = await fetchPlanNetwork(projectId);
|
||||
const { epsg: workEpsg, site } = view.base;
|
||||
if (!workEpsg) {
|
||||
terrainText.textContent = "기준점이 없습니다 — 예정노선 등록에서 정하세요.";
|
||||
return;
|
||||
if (!view.base.epsg) {
|
||||
note = "기준점이 없습니다 — 주소를 찾아 정하세요.";
|
||||
ready = true;
|
||||
return refresh();
|
||||
}
|
||||
epsg = workEpsg;
|
||||
epsg = view.base.epsg;
|
||||
takeServer(view);
|
||||
const draft = readState<Draft>("d03-plan", projectId);
|
||||
if (draft?.version === DRAFT_VERSION && draft.network && Array.isArray(draft.points))
|
||||
({ network, points, pointNames } = draft);
|
||||
const tm = tmParams(epsg);
|
||||
const siteXy = site && tm ? lonLatToTm(tm, site.lon, site.lat) : null;
|
||||
const around: Vertex[] = siteXy
|
||||
? [
|
||||
[siteXy[0] - SITE_RADIUS_M, siteXy[1] - SITE_RADIUS_M],
|
||||
[siteXy[0] + SITE_RADIUS_M, siteXy[1] + SITE_RADIUS_M],
|
||||
]
|
||||
: [];
|
||||
({ network, points, pointNames, site } = draft);
|
||||
const drawn = [...points, ...allVertices()];
|
||||
map.update({ fit: drawn.length ? drawn : around, epsg });
|
||||
map.update({ fit: drawn.length && !moved() ? drawn : around(site), epsg });
|
||||
refresh();
|
||||
await loadSurface();
|
||||
refresh();
|
||||
@@ -606,12 +688,13 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
await follow();
|
||||
} else if (editable && !job.error) {
|
||||
// 들어올 때 받은 범위가 기준점 둘레 · 지점을 못 덮으면 바로 받기(서버가 셈 · 실패 뒤엔 [다시 받기])
|
||||
const extent = await fetchTerrainExtent(projectId, null, []);
|
||||
const extent = await fetchTerrainExtent(projectId, null, [], moved());
|
||||
ready = true;
|
||||
if (!extent.covered) await download([]);
|
||||
else await ensureCovered();
|
||||
}
|
||||
} catch (error) {
|
||||
terrainText.textContent = `불러오지 못했습니다 — ${errorText(error)}`;
|
||||
note = `불러오지 못했습니다 — ${errorText(error)}`;
|
||||
refresh();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,26 @@
|
||||
/* =============================================================================
|
||||
* D03_PlanAnalysis_UI_Pieces.ts
|
||||
* D03 좌측 「구역 · 연도」 목록 몸(PLAN 597) — 해 줄(같은 해) · 선 머리 줄(선) · 구간 줄(구간).
|
||||
* 속성 판(`UI_Props.ts`)에서 떼어냄 — 목록 = 보기 + 고르기.
|
||||
* D03 좌측 「구역 · 연도」 목록 몸(PLAN 597 · 요구 463 14) — 구역 = 그은 꺾은선 하나(본선 · 갈래).
|
||||
* 해 줄(같은 해) · 구역 줄(색 띠 = 지도 선과 같은 `routeColor(차례)` · 이름 칸 · 연도 고름 · 지움) ·
|
||||
* 구간 줄(측점 글 작게 · 연도 고름). 누름 = 고름 · 해 고침 · 이름 · 지움은 한 걸음(되돌리기 · 캐시 = 페이지).
|
||||
* 연도 고름은 속성 판과 같은 셈(`setLineYear` · `setPiecesYear`) · 보기만 계정 = 값만.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
import { createIconButton } from "@ui/ui_template_button";
|
||||
import { createInputField, createSelectField } from "@ui/ui_template_input";
|
||||
import { createListRow } from "@ui/ui_template_list_row";
|
||||
import { askConfirm } from "@ui/ui_template_modal";
|
||||
import { routeColor } from "@ui/ui_template_palette";
|
||||
import { stationLabel } from "../common_util/common_util_station";
|
||||
import { lineLength } from "./logic/D03_PlanAnalysis_Draw";
|
||||
import { lineNames, type PlanNetwork } from "./logic/D03_PlanAnalysis_Plan";
|
||||
import {
|
||||
lineNames,
|
||||
nameError,
|
||||
setLineYear,
|
||||
setPiecesYear,
|
||||
type PlanNetwork,
|
||||
} from "./logic/D03_PlanAnalysis_Plan";
|
||||
import { setLineName } from "./logic/D03_PlanAnalysis_PointEdit";
|
||||
import type { PlanEdit } from "./D03_PlanAnalysis_UI_Edit";
|
||||
import { PLAN_STATION_INTERVAL_M } from "./D03_PlanAnalysis_UI_Map";
|
||||
|
||||
@@ -17,6 +30,10 @@ const meters = (m: number): string => `${m.toFixed(1)} m`;
|
||||
export interface PlanPiecesParams {
|
||||
network: () => PlanNetwork;
|
||||
edit: PlanEdit;
|
||||
editable: () => boolean;
|
||||
/** 고를 해 — 사업연도 시작부터 열 해 + 조각에 있는 해. */
|
||||
yearOptions: () => Array<{ value: string; text: string }>;
|
||||
commit: (next: PlanNetwork) => void;
|
||||
}
|
||||
|
||||
export interface PlanPieces {
|
||||
@@ -25,79 +42,190 @@ export interface PlanPieces {
|
||||
render: () => void;
|
||||
}
|
||||
|
||||
/** 좌측 줄 — 누름 = 고름(div · 열쇠 Enter 도). */
|
||||
const pickRow = (row: HTMLElement, onPick: () => void): HTMLElement => {
|
||||
row.tabIndex = 0;
|
||||
row.setAttribute("role", "button");
|
||||
row.addEventListener("click", onPick);
|
||||
row.addEventListener("keydown", (event) => event.key === "Enter" && onPick());
|
||||
return row;
|
||||
};
|
||||
|
||||
export function createPlanPieces(params: PlanPiecesParams): PlanPieces {
|
||||
const { edit } = params;
|
||||
const list = document.createElement("div");
|
||||
list.className = "d03-plan__pieces";
|
||||
let shown = ""; // 지금 그린 틀 열쇠 — 같으면 값만 갈아 끼움(칸 초점 지키기)
|
||||
let syncs: Array<() => void> = [];
|
||||
|
||||
/** 줄 안 칸 눌림은 줄 고름으로 안 번지게. */
|
||||
const isControl = (event: Event): boolean =>
|
||||
!!(event.target as Element).closest("input, select, button, .ui-list-row__trailing");
|
||||
|
||||
/** 줄 하나 — 누름 = 고름(열쇠 Enter 도) · 줄 안 칸은 제외. */
|
||||
const pickable = (row: HTMLElement, onPick: () => void): HTMLElement => {
|
||||
row.tabIndex = 0;
|
||||
row.addEventListener("click", (event) => isControl(event) || onPick());
|
||||
row.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter" && event.target === row) onPick();
|
||||
});
|
||||
return row;
|
||||
};
|
||||
|
||||
/** 연도 고름 칸 — 값이 섞이면 「여러 해」. */
|
||||
function yearSelect(
|
||||
years: () => number[],
|
||||
label: string,
|
||||
pick: (year: number) => void,
|
||||
): { root: HTMLElement; sync: () => void } {
|
||||
const field = createSelectField({
|
||||
options: [],
|
||||
size: 24,
|
||||
ariaLabel: label,
|
||||
disabled: !params.editable(),
|
||||
onChange: (value) => value && pick(Number(value)),
|
||||
});
|
||||
const sync = (): void => {
|
||||
const set = new Set(years());
|
||||
const options = params.yearOptions();
|
||||
field.setOptions(
|
||||
set.size > 1 ? [{ value: "", text: "여러 해" }, ...options] : options,
|
||||
set.size > 1 ? "" : String([...set][0]),
|
||||
);
|
||||
};
|
||||
sync();
|
||||
return { root: field.root, sync };
|
||||
}
|
||||
|
||||
function render(): void {
|
||||
const network = params.network();
|
||||
const names = lineNames(network.lines);
|
||||
const picked = new Set(edit.selected()?.pieces ?? []);
|
||||
const scope = edit.selected()?.scope;
|
||||
const editable = params.editable();
|
||||
const key = JSON.stringify([
|
||||
network.lines.map((line) => line.id),
|
||||
network.pieces.map((piece) => [piece.id, piece.line]),
|
||||
[...picked],
|
||||
scope,
|
||||
editable,
|
||||
params.yearOptions().length,
|
||||
]);
|
||||
if (key === shown) {
|
||||
syncs.forEach((sync) => sync());
|
||||
return;
|
||||
}
|
||||
shown = key;
|
||||
syncs = [];
|
||||
const rows: HTMLElement[] = [];
|
||||
|
||||
const byYear = new Map<number, typeof network.pieces>();
|
||||
for (const piece of network.pieces)
|
||||
byYear.set(piece.year, [...(byYear.get(piece.year) ?? []), piece]);
|
||||
for (const [year, pieces] of [...byYear].sort(([a], [b]) => a - b)) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "d03-plan__piece d03-plan__piece-year";
|
||||
row.dataset.year = String(year);
|
||||
row.classList.toggle(
|
||||
"is-selected",
|
||||
scope === "year" && pieces.every((p) => picked.has(p.id)),
|
||||
);
|
||||
const length = pieces.reduce((sum, p) => sum + p.to_m - p.from_m, 0);
|
||||
row.append(createText(`${year}년 · 구간 ${pieces.length} · ${meters(length)}`, "muted"));
|
||||
rows.push(
|
||||
pickRow(row, () => {
|
||||
const row = createListRow({
|
||||
label: `${year}년`,
|
||||
sub: `구간 ${pieces.length} · ${meters(length)}`,
|
||||
size: 28,
|
||||
active: scope === "year" && pieces.every((p) => picked.has(p.id)),
|
||||
onClick: () => {
|
||||
edit.select([pieces[0].id]);
|
||||
edit.setScope("year");
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
row.dataset.year = String(year);
|
||||
rows.push(row);
|
||||
}
|
||||
|
||||
network.lines.forEach((line, index) => {
|
||||
const pieces = network.pieces.filter((piece) => piece.line === line.id);
|
||||
if (!pieces.length) return;
|
||||
const head = document.createElement("div");
|
||||
head.className = "d03-plan__piece d03-plan__piece-head";
|
||||
head.dataset.line = line.id;
|
||||
head.classList.toggle(
|
||||
"is-selected",
|
||||
scope === "line" && pieces.every((p) => picked.has(p.id)),
|
||||
const lineNow = (): (typeof network.lines)[number] =>
|
||||
params.network().lines.find((entry) => entry.id === line.id) ?? line;
|
||||
const piecesNow = (): typeof pieces =>
|
||||
params.network().pieces.filter((piece) => piece.line === line.id);
|
||||
|
||||
const nameField = createInputField({ size: 24, placeholder: names[index] });
|
||||
nameField.input.value = line.name ?? "";
|
||||
nameField.input.setAttribute("aria-label", "구역 이름");
|
||||
nameField.input.dataset.role = "line-name";
|
||||
nameField.input.disabled = !editable;
|
||||
nameField.input.addEventListener("input", () =>
|
||||
nameField.setError(nameError(nameField.input.value) ?? undefined),
|
||||
);
|
||||
head.append(createText(`${names[index]} · ${meters(lineLength(line.vertices))}`, "muted"));
|
||||
nameField.input.addEventListener("change", () => {
|
||||
const error = nameError(nameField.input.value);
|
||||
nameField.setError(error ?? undefined);
|
||||
const text = nameField.input.value.trim();
|
||||
if (!error && text !== (lineNow().name ?? ""))
|
||||
params.commit(setLineName(params.network(), line.id, text));
|
||||
});
|
||||
|
||||
const year = yearSelect(
|
||||
() => piecesNow().map((piece) => piece.year),
|
||||
"구역 연도",
|
||||
(value) => params.commit(setLineYear(params.network(), line.id, value)),
|
||||
);
|
||||
year.root.dataset.role = "line-year";
|
||||
const drop = createIconButton({
|
||||
icon: "trash",
|
||||
title: "구역 지움",
|
||||
size: 24,
|
||||
onClick: async () => {
|
||||
const ok = await askConfirm({
|
||||
message: `「${lineNames(params.network().lines)[index]}」 구역을 지울까요?`,
|
||||
okLabel: "지움",
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
edit.dropRegion(line.id);
|
||||
},
|
||||
});
|
||||
drop.dataset.role = "line-drop";
|
||||
drop.hidden = !editable;
|
||||
const tail = document.createElement("span");
|
||||
tail.className = "d03-plan__line-tail";
|
||||
tail.append(year.root, drop);
|
||||
|
||||
const head = createListRow({
|
||||
label: "",
|
||||
sub: `구간 ${pieces.length} · ${meters(lineLength(line.vertices))}`,
|
||||
stripe: routeColor(index),
|
||||
size: 28,
|
||||
trailing: tail,
|
||||
active: scope === "line" && pieces.every((p) => picked.has(p.id)),
|
||||
});
|
||||
head.querySelector(".ui-list-row__label")?.replaceChildren(nameField.root);
|
||||
head.dataset.line = line.id;
|
||||
rows.push(
|
||||
pickRow(head, () => {
|
||||
pickable(head, () => {
|
||||
edit.select([pieces[0].id]);
|
||||
edit.setScope("line");
|
||||
}),
|
||||
);
|
||||
syncs.push(() => {
|
||||
year.sync();
|
||||
nameField.input.placeholder = lineNames(params.network().lines)[index] ?? "";
|
||||
if (document.activeElement !== nameField.input)
|
||||
nameField.input.value = lineNow().name ?? "";
|
||||
const sub = head.querySelector(".ui-list-row__sub");
|
||||
if (sub)
|
||||
sub.textContent = `구간 ${piecesNow().length} · ${meters(lineLength(lineNow().vertices))}`;
|
||||
});
|
||||
|
||||
for (const piece of pieces) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "d03-plan__piece";
|
||||
row.classList.toggle("is-selected", picked.has(piece.id));
|
||||
row.dataset.piece = piece.id;
|
||||
row.append(
|
||||
createText(
|
||||
`${station(piece.from_m)} ~ ${station(piece.to_m)} (${meters(piece.to_m - piece.from_m)})`,
|
||||
"muted",
|
||||
),
|
||||
createText(`${piece.year}년`, "muted"),
|
||||
const pieceNow = (): (typeof pieces)[number] =>
|
||||
params.network().pieces.find((entry) => entry.id === piece.id) ?? piece;
|
||||
const pieceYear = yearSelect(
|
||||
() => [pieceNow().year],
|
||||
"구간 연도",
|
||||
(value) => params.commit(setPiecesYear(params.network(), [piece.id], value)),
|
||||
);
|
||||
rows.push(pickRow(row, () => edit.select([piece.id])));
|
||||
const row = createListRow({
|
||||
label: `${station(piece.from_m)} ~ ${station(piece.to_m)}`,
|
||||
sub: meters(piece.to_m - piece.from_m),
|
||||
size: 28,
|
||||
trailing: pieceYear.root,
|
||||
active: picked.has(piece.id),
|
||||
});
|
||||
row.classList.add("d03-plan__piece-row");
|
||||
row.dataset.piece = piece.id;
|
||||
rows.push(pickable(row, () => edit.select([piece.id])));
|
||||
syncs.push(pieceYear.sync);
|
||||
}
|
||||
});
|
||||
if (!rows.length) rows.push(createText("선을 그으면 구역이 생깁니다.", "muted"));
|
||||
list.replaceChildren(...rows);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/* =============================================================================
|
||||
* D03_PlanAnalysis_UI_Points.ts
|
||||
* D03 지점(PLAN 591-2 · 요구 452 · 453) — 옛 기준 지정(D02) 지점 마킹을 노선분석으로.
|
||||
* 지점 = 긋기에 붙는 점 · 목록 하나(줄 왼 띠 = 지점 색 `pointColor` · 「지점 N」 · 🗑) · 핀 = 늘 맨 위(`pointLayer`).
|
||||
* 지점 = 긋기에 붙는 점 · 목록 하나(줄 왼 띠 = 지점 색 `pointColor` · 이름 · 🗑) · 핀 = 늘 맨 위(`pointLayer`).
|
||||
* [추가] = 지도 보기 가운데에 바로 놓음(597-2) · 줄 누름 = 그 줄만 펼침(이름 · 경도 · 위도 — 칸 떠남 · Enter 가 한 걸음).
|
||||
* 핀 끌기 = 지점 + 그 자리 노선 노드 같이(`moveNodesAt`) · 노드 12 px 안에 놓으면 그 노드에 붙음(598-3) ·
|
||||
* 놓을 때 한 걸음(되돌리기 · 캐시 = 페이지).
|
||||
* 지도 우클릭 메뉴 = `D03_PlanAnalysis_UI_Menu`(핀 집기 · 놓기 · 지우기를 여기서 빌림).
|
||||
@@ -9,21 +10,30 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import { createIconButton } from "@ui/ui_template_button";
|
||||
import { createButton } from "@ui/ui_template_elements";
|
||||
import { createInputField } from "@ui/ui_template_input";
|
||||
import { createListRow } from "@ui/ui_template_list_row";
|
||||
import { pointColor } from "@ui/ui_template_palette";
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
import {
|
||||
moveNodesAt,
|
||||
nameError,
|
||||
samePoint,
|
||||
type PlanNetwork,
|
||||
type Vertex,
|
||||
} from "./logic/D03_PlanAnalysis_Plan";
|
||||
import {
|
||||
addPoint,
|
||||
MAX_POINTS,
|
||||
movePointTo,
|
||||
parseLonLat,
|
||||
pointLabel,
|
||||
renamePoint,
|
||||
type PointsState,
|
||||
} from "./logic/D03_PlanAnalysis_PointEdit";
|
||||
import { tmParams, tmToLonLat } from "./logic/D03_PlanAnalysis_Tm";
|
||||
import { pointLayer, screenGap, SNAP_PX } from "./D03_PlanAnalysis_UI_DrawPaint";
|
||||
import type { PlanMapLayer, ScreenMap } from "./D03_PlanAnalysis_UI_Map";
|
||||
|
||||
/** 지점 상한 — 서버 `MAX_POINTS` 와 같음. */
|
||||
const MAX_POINTS = 40;
|
||||
/** 핀 머리 = 꼭지 위 px(`pointLayer` 그림과 같음). */
|
||||
const PIN_HEAD_PX = 18;
|
||||
|
||||
@@ -61,10 +71,22 @@ export interface PlanPoints {
|
||||
export function createPlanPoints(params: PlanPointsParams): PlanPoints {
|
||||
const list = document.createElement("div");
|
||||
list.className = "d03-plan__points";
|
||||
const hint = createText("", "muted");
|
||||
hint.classList.add("d03-plan__hint");
|
||||
const addButton = createButton({
|
||||
label: "추가",
|
||||
iconStart: "plus",
|
||||
variant: "ghost",
|
||||
size: 24,
|
||||
onClick: addAtCenter,
|
||||
});
|
||||
addButton.dataset.role = "points-add";
|
||||
const head = document.createElement("div");
|
||||
head.className = "d03-plan__points-head";
|
||||
head.append(addButton);
|
||||
const panel = document.createElement("div");
|
||||
panel.append(list, hint);
|
||||
panel.append(head, list);
|
||||
let openIndex: number | null = null; // 펼친 줄(하나만)
|
||||
let syncEditor: (() => void) | null = null;
|
||||
let shown = ""; // 지금 그린 틀 열쇠 — 같으면 값만 갈아 끼움(칸 초점 지키기)
|
||||
let drag: {
|
||||
index: number;
|
||||
points: Vertex[];
|
||||
@@ -72,7 +94,14 @@ export function createPlanPoints(params: PlanPointsParams): PlanPoints {
|
||||
last: [Vertex[], PlanNetwork] | null;
|
||||
} | null = null;
|
||||
|
||||
const name = (i: number): string => `지점 ${i + 1}`;
|
||||
const name = (i: number): string => pointLabel(params.names(), i);
|
||||
const state = (): PointsState => ({
|
||||
points: params.points(),
|
||||
names: params.names(),
|
||||
network: params.network(),
|
||||
});
|
||||
const commitState = (next: PointsState): void =>
|
||||
params.commit(next.points, next.names, next.network);
|
||||
|
||||
/** 핀 집기 — 꼭지 · 머리(보이는 화면 위쪽) 둘레 · 뒤 지점(위에 그려짐) 먼저. */
|
||||
function pinAt(px: number, py: number, map: ScreenMap): number | null {
|
||||
@@ -91,6 +120,7 @@ export function createPlanPoints(params: PlanPointsParams): PlanPoints {
|
||||
}
|
||||
|
||||
function remove(i: number): void {
|
||||
openIndex = null;
|
||||
params.commit(
|
||||
params.points().filter((_, k) => k !== i),
|
||||
params.names().filter((_, k) => k !== i),
|
||||
@@ -117,7 +147,7 @@ export function createPlanPoints(params: PlanPointsParams): PlanPoints {
|
||||
params.commit([...params.points(), [at[0], at[1]]], [...params.names(), ""], params.network());
|
||||
}
|
||||
|
||||
const pins = pointLayer(params.points);
|
||||
const pins = pointLayer(params.points, params.names);
|
||||
|
||||
const input: PlanMapLayer = {
|
||||
draw: () => undefined,
|
||||
@@ -145,12 +175,101 @@ export function createPlanPoints(params: PlanPointsParams): PlanPoints {
|
||||
},
|
||||
};
|
||||
|
||||
/** [추가] — 지도 보기 가운데에 바로 놓고 그 줄을 펼침. */
|
||||
function addAtCenter(): void {
|
||||
const at = params.center();
|
||||
if (!at || !params.editable() || params.points().length >= MAX_POINTS) return;
|
||||
openIndex = params.points().length;
|
||||
commitState(addPoint(state(), at));
|
||||
}
|
||||
|
||||
/** 펼친 줄 — 이름 · 경도 · 위도 칸(Enter · 칸 떠남 = 한 걸음 · 잘못 쓴 값은 까닭만 보이고 안 받음). */
|
||||
function editorOf(i: number): { root: HTMLElement; sync: () => void } {
|
||||
const tm = tmParams(params.epsg());
|
||||
const editable = params.editable();
|
||||
const field = (label: string, role: string, placeholder?: string) => {
|
||||
const f = createInputField({ label, size: 24, placeholder });
|
||||
f.input.dataset.role = role;
|
||||
f.input.disabled = !editable || (role !== "point-name" && !tm);
|
||||
return f;
|
||||
};
|
||||
const nameField = field("이름", "point-name", `지점 ${i + 1}`);
|
||||
const lonField = field("경도", "point-lon");
|
||||
const latField = field("위도", "point-lat");
|
||||
const lonLatNow = (): [number, number] | null => {
|
||||
const p = params.points()[i];
|
||||
return tm && p ? tmToLonLat(tm, p[0], p[1]) : null;
|
||||
};
|
||||
const sync = (): void => {
|
||||
const set = (f: ReturnType<typeof field>, text: string): void => {
|
||||
if (document.activeElement !== f.input) f.input.value = text;
|
||||
};
|
||||
set(nameField, params.names()[i] ?? "");
|
||||
const ll = lonLatNow();
|
||||
set(lonField, ll ? ll[0].toFixed(6) : "");
|
||||
set(latField, ll ? ll[1].toFixed(6) : "");
|
||||
};
|
||||
nameField.input.addEventListener("input", () =>
|
||||
nameField.setError(nameError(nameField.input.value.trim()) ?? undefined),
|
||||
);
|
||||
nameField.input.addEventListener("change", () => {
|
||||
const text = nameField.input.value.trim();
|
||||
const error = nameError(text);
|
||||
nameField.setError(error ?? undefined);
|
||||
if (!error && text !== (params.names()[i] ?? "")) commitState(renamePoint(state(), i, text));
|
||||
});
|
||||
const moved = (): void => {
|
||||
const parsed = parseLonLat(lonField.input.value, latField.input.value);
|
||||
const bad = typeof parsed === "string";
|
||||
lonField.setError(bad && parsed.startsWith("경도") ? parsed : undefined);
|
||||
latField.setError(bad && parsed.startsWith("위도") ? parsed : undefined);
|
||||
if (bad || !tm) return;
|
||||
const now = lonLatNow();
|
||||
if (
|
||||
now &&
|
||||
now[0].toFixed(6) === parsed[0].toFixed(6) &&
|
||||
now[1].toFixed(6) === parsed[1].toFixed(6)
|
||||
)
|
||||
return;
|
||||
commitState(movePointTo(state(), i, tm, parsed[0], parsed[1]));
|
||||
};
|
||||
lonField.input.addEventListener("change", moved);
|
||||
latField.input.addEventListener("change", moved);
|
||||
const root = document.createElement("div");
|
||||
root.className = "d03-plan__point-edit";
|
||||
root.append(nameField.root, lonField.root, latField.root);
|
||||
sync();
|
||||
return { root, sync };
|
||||
}
|
||||
|
||||
function render(): void {
|
||||
const tm = tmParams(params.epsg());
|
||||
const editable = params.editable();
|
||||
const points = params.points();
|
||||
if (openIndex !== null && openIndex >= points.length) openIndex = null;
|
||||
addButton.hidden = !editable;
|
||||
addButton.disabled = points.length >= MAX_POINTS || !params.center();
|
||||
const key = `${points.length}|${openIndex}|${editable}|${tm ? 1 : 0}`;
|
||||
const lonLatOf = (i: number): string | undefined => {
|
||||
const ll = tm ? tmToLonLat(tm, points[i][0], points[i][1]) : null;
|
||||
return ll ? `${ll[0].toFixed(6)}, ${ll[1].toFixed(6)}` : undefined;
|
||||
};
|
||||
if (key === shown) {
|
||||
// 틀 그대로 — 이름 · 좌표 글과 펼친 칸 값만 갈아 끼움
|
||||
points.forEach((_, i) => {
|
||||
const row = list.children[i]?.querySelector(".ui-list-row");
|
||||
const label = row?.querySelector(".ui-list-row__label");
|
||||
const sub = row?.querySelector(".ui-list-row__sub");
|
||||
if (label) label.textContent = name(i);
|
||||
if (sub) sub.textContent = lonLatOf(i) ?? "";
|
||||
});
|
||||
syncEditor?.();
|
||||
return;
|
||||
}
|
||||
shown = key;
|
||||
syncEditor = null;
|
||||
list.replaceChildren(
|
||||
...params.points().map((point, i) => {
|
||||
const lonLat = tm ? tmToLonLat(tm, point[0], point[1]) : null;
|
||||
...points.map((_, i) => {
|
||||
const drop = createIconButton({
|
||||
icon: "trash",
|
||||
title: `${name(i)} 지우기`,
|
||||
@@ -158,22 +277,36 @@ export function createPlanPoints(params: PlanPointsParams): PlanPoints {
|
||||
onClick: () => remove(i),
|
||||
});
|
||||
drop.hidden = !editable;
|
||||
return createListRow({
|
||||
const row = createListRow({
|
||||
label: name(i),
|
||||
sub: lonLat ? `${lonLat[0].toFixed(6)}, ${lonLat[1].toFixed(6)}` : undefined,
|
||||
sub: lonLatOf(i),
|
||||
stripe: pointColor(i),
|
||||
size: 28,
|
||||
trailing: drop,
|
||||
active: i === openIndex,
|
||||
});
|
||||
row.dataset.point = String(i);
|
||||
row.tabIndex = 0;
|
||||
const toggle = (): void => {
|
||||
openIndex = openIndex === i ? null : i;
|
||||
render();
|
||||
};
|
||||
row.addEventListener("click", (event) => {
|
||||
if (!(event.target as Element).closest(".ui-list-row__trailing")) toggle();
|
||||
});
|
||||
row.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter" && event.target === row) toggle();
|
||||
});
|
||||
const item = document.createElement("div");
|
||||
item.append(row);
|
||||
if (i === openIndex) {
|
||||
const editor = editorOf(i);
|
||||
syncEditor = editor.sync;
|
||||
item.append(editor.root);
|
||||
}
|
||||
return item;
|
||||
}),
|
||||
);
|
||||
hint.textContent = !editable
|
||||
? params.points().length
|
||||
? "보기만 — 지점은 산림청이 놓습니다."
|
||||
: "지점 없음"
|
||||
: params.points().length
|
||||
? "핀을 끌면 그 자리 노선 점도 같이 옮깁니다 · 지도 우클릭 「지점 놓기」로 더함."
|
||||
: "지도 우클릭 「지점 놓기」 — 긋기가 붙는 점(없어도 우클릭으로 긋기 시작).";
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
/* =============================================================================
|
||||
* D03_PlanAnalysis_UI_Site.ts
|
||||
* D03 「위치」 칸(PLAN 596-2 · 요구 463-3~6 · 19) — 지금 기준점 주소 한 줄 · 주소 · 장소 찾기
|
||||
* (서버 VWorld `searchPlaces` · 부품 `createFindList` · Enter = 찾기 · ↑ ↓ Enter = 고름) ·
|
||||
* 알림 한 줄(받기 실패 · 끊김 · 기준점 없음 · 못 불러옴만) + [다시 받기] · [임도망 올리기].
|
||||
* 고름 = 페이지가 받기 물음(멂 · 다른 TM 띠) → 기준점 캐시 → 지도 이동 → 둘레 받기 · [저장] 때 정본(등록과 한 칸).
|
||||
* ========================================================================== */
|
||||
|
||||
import { pickFiles } from "@ui/ui_template_drop";
|
||||
import { createButton } from "@ui/ui_template_elements";
|
||||
import { createFindList, type FindList } from "@ui/ui_template_find_list";
|
||||
import { t } from "@ui/ui_template_locale";
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
import { searchPlaces, type PlaceHit } from "./D03_PlanAnalysis_Api";
|
||||
|
||||
/** 찾기 글 최소 길이(서버 VWorld 한 글자 = 쓸모없는 후보). */
|
||||
const MIN_QUERY = 2;
|
||||
|
||||
export interface PlanSiteParams {
|
||||
/** 후보 고름 — 받기 · 캐시 · 이동은 페이지. */
|
||||
onPick: (hit: PlaceHit) => void;
|
||||
onRetry: () => void;
|
||||
onUpload: (files: File[]) => void;
|
||||
}
|
||||
|
||||
export interface PlanSiteState {
|
||||
address: string | null;
|
||||
/** 알림(실패 · 끊김 · 기준점 없음 · 오류) — 빈 값 = 숨김. */
|
||||
note: string;
|
||||
retry: boolean;
|
||||
editable: boolean;
|
||||
}
|
||||
|
||||
export interface PlanSite {
|
||||
/** 칸 몸(카드에 바로 붙임 · 카드 간격을 그대로 씀). */
|
||||
parts: HTMLElement[];
|
||||
render: (state: PlanSiteState) => void;
|
||||
}
|
||||
|
||||
/** 주소 · 장소 찾기 칸(부품 `createFindList`) — Enter = 찾기 · ↑ ↓ Enter · 누름 = 고름 ·
|
||||
* 등록(D01) 지도 머리 · 이 「위치」 칸이 같이 씀. */
|
||||
export function createPlaceFind(onPick: (hit: PlaceHit) => void): FindList {
|
||||
const find = createFindList({ placeholder: t("DE_Reg_Find") });
|
||||
let asked = 0; // 옛 찾기 답 버림
|
||||
|
||||
async function run(): Promise<void> {
|
||||
const query = find.search.value.trim();
|
||||
find.setRows([]);
|
||||
if (query.length < MIN_QUERY) return void (find.hint.textContent = t("DE_Reg_FindShort"));
|
||||
const at = ++asked;
|
||||
find.hint.textContent = t("DE_Reg_FindBusy");
|
||||
try {
|
||||
const hits = await searchPlaces(query);
|
||||
if (at !== asked) return;
|
||||
find.hint.textContent = hits.length ? "" : t("DE_Reg_FindNone");
|
||||
find.setRows(
|
||||
hits.map((hit, i) => ({
|
||||
key: String(i),
|
||||
tag: t(hit.kind === "place" ? "DE_Reg_FindPlace" : "DE_Reg_FindAddress"),
|
||||
name: hit.title,
|
||||
sub: hit.address !== hit.title ? hit.address : undefined,
|
||||
onPick: () => {
|
||||
find.setRows([]);
|
||||
find.search.value = hit.title;
|
||||
onPick(hit);
|
||||
},
|
||||
})),
|
||||
);
|
||||
} catch (error) {
|
||||
if (at === asked)
|
||||
find.hint.textContent = `${t("DE_Reg_FindFail")} — ${error instanceof Error ? error.message : error}`;
|
||||
}
|
||||
}
|
||||
// 고를 줄이 있으면 부품이 Enter 를 먼저 씀(고름) · 없으면 찾기 · 글을 고치면 옛 줄 걷음
|
||||
find.search.addEventListener("input", () => find.setRows([]));
|
||||
find.search.addEventListener("keydown", (event) => {
|
||||
if (event.key !== "Enter" || event.defaultPrevented || event.isComposing) return;
|
||||
event.preventDefault();
|
||||
void run();
|
||||
});
|
||||
return find;
|
||||
}
|
||||
|
||||
export function createPlanSite(params: PlanSiteParams): PlanSite {
|
||||
const address = createText("", "muted", 14);
|
||||
const note = createText("", "error");
|
||||
const find = createPlaceFind(params.onPick);
|
||||
|
||||
const retry = createButton({
|
||||
label: "다시 받기",
|
||||
variant: "filled",
|
||||
size: 28,
|
||||
onClick: params.onRetry,
|
||||
});
|
||||
const upload = createButton({
|
||||
label: "임도망 올리기",
|
||||
variant: "ghost",
|
||||
size: 28,
|
||||
onClick: () =>
|
||||
pickFiles({ accept: ".zip,.shp,.shx,.dbf,.prj,.cpg", multiple: true }, params.onUpload),
|
||||
});
|
||||
upload.title = "임도망도 SHP(시군구 zip 하나 · 또는 shp · shx · dbf · prj 함께 · 100 MB 까지)";
|
||||
return {
|
||||
parts: [address, find.root, note, retry, upload],
|
||||
render(state) {
|
||||
address.textContent = state.address ?? "";
|
||||
address.hidden = !state.address;
|
||||
note.textContent = state.note;
|
||||
note.hidden = !state.note;
|
||||
find.root.hidden = upload.hidden = !state.editable;
|
||||
retry.hidden = !state.editable || !state.retry;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
/* =============================================================================
|
||||
* D03_PlanAnalysis_UI_SlopeBands.ts
|
||||
* D03 급경사 범위 옵션(PLAN 603 · 요구 466 · 467 · 468) — 범례 급경사 줄 ⚙ → 모달
|
||||
* (범위 줄 = 아래 % · 위 % · 색 · 지움 · 칸 옆 ° 환산 · [범위 추가] · [기본값] · [적용] · 빈 값 · 겹침 막음).
|
||||
* 기억 = 화면 취향(`aislo:pref:d03.slope-bands` · 계정에 붙음 · 프로젝트 무관).
|
||||
* 칠하기 = 서버 값 그림(회색 · 칸 = 기울기 % 내림) → 범위 색 그림(서버 왕복 없음 · CLAUDE.md 5장).
|
||||
* 범위 · 비율 셈 = `logic/D03_PlanAnalysis_SlopeBands.ts` · 부르는 곳 = `D03_PlanAnalysis_UI_Layers.ts`.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, createIconButton } from "@ui/ui_template_button";
|
||||
import { createInputField, createSelectField } from "@ui/ui_template_input";
|
||||
import { createLegend } from "@ui/ui_template_flow_legend";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import { themeColor } from "@ui/ui_template_palette";
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
import { readByKey, writeByKey } from "../A00_Common/b_page_state";
|
||||
import { loadImage } from "./D03_PlanAnalysis_UI_LayerDefs";
|
||||
import {
|
||||
DEFAULT_SLOPE_BANDS,
|
||||
SLOPE_COLORS,
|
||||
SLOPE_MAX_BANDS,
|
||||
SLOPE_MAX_PCT,
|
||||
bandOf,
|
||||
checkBands,
|
||||
parseBands,
|
||||
pctToDeg,
|
||||
type SlopeBand,
|
||||
} from "./logic/D03_PlanAnalysis_SlopeBands";
|
||||
|
||||
const PREF_KEY = "aislo:pref:d03.slope-bands";
|
||||
/** 칠한 칸 투명도(겹 투명도 0.55 는 따로). */
|
||||
const ALPHA = 150;
|
||||
|
||||
export const readSlopeBands = (): SlopeBand[] => parseBands(readByKey(PREF_KEY));
|
||||
export const writeSlopeBands = (bands: SlopeBand[]): void =>
|
||||
writeByKey(PREF_KEY, JSON.stringify(bands));
|
||||
|
||||
/** 값 그림 → 칸 값(빨강 자리 = 회색 값). */
|
||||
export function slopeValues(image: HTMLImageElement): ImageData {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = image.naturalWidth;
|
||||
canvas.height = image.naturalHeight;
|
||||
const context = canvas.getContext("2d", { willReadFrequently: true })!;
|
||||
context.drawImage(image, 0, 0);
|
||||
return context.getImageData(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
|
||||
/** 범위대로 칠한 그림(주소 = blob · 다 쓰면 부르는 쪽이 `URL.revokeObjectURL`). */
|
||||
export async function paintSlope(
|
||||
values: ImageData,
|
||||
bands: SlopeBand[],
|
||||
): Promise<{ url: string; image: HTMLImageElement }> {
|
||||
const rgb = bands.map((band) => hexRgb(themeColor(band.color, "#facc15")));
|
||||
const lut = new Uint32Array(256);
|
||||
const view = new DataView(lut.buffer);
|
||||
for (let v = 0; v < 256; v++) {
|
||||
const i = bandOf(v, bands);
|
||||
if (i >= 0) view.setUint32(v * 4, ((rgb[i] << 8) | ALPHA) >>> 0, false);
|
||||
}
|
||||
const out = new ImageData(values.width, values.height);
|
||||
const src = values.data;
|
||||
const dst = new Uint32Array(out.data.buffer);
|
||||
for (let p = 0; p < dst.length; p++) dst[p] = lut[src[p * 4]];
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = values.width;
|
||||
canvas.height = values.height;
|
||||
canvas.getContext("2d")!.putImageData(out, 0, 0);
|
||||
const blob = await new Promise<Blob>((done, fail) =>
|
||||
canvas.toBlob(
|
||||
(got) => (got ? done(got) : fail(new Error("그림을 만들지 못했습니다"))),
|
||||
"image/png",
|
||||
),
|
||||
);
|
||||
const url = URL.createObjectURL(blob);
|
||||
return { url, image: await loadImage(url) };
|
||||
}
|
||||
|
||||
/** `#rrggbb` → 0xrrggbb(못 읽으면 노랑). */
|
||||
function hexRgb(color: string): number {
|
||||
const hex = /^#?([0-9a-f]{6})$/i.exec(color.trim())?.[1];
|
||||
return hex ? parseInt(hex, 16) : 0xfacc15;
|
||||
}
|
||||
|
||||
/** 범례 급경사 줄 ⚙. */
|
||||
export const slopeGear = (onClick: () => void): HTMLButtonElement => {
|
||||
const button = createIconButton({
|
||||
icon: "settings",
|
||||
title: "급경사 범위 바꾸기(기울기 %)",
|
||||
size: 20,
|
||||
onClick,
|
||||
});
|
||||
button.dataset.role = "slope-bands";
|
||||
return button;
|
||||
};
|
||||
|
||||
const degText = (value: string): string => {
|
||||
const pct = Number(value);
|
||||
return value.trim() && Number.isFinite(pct) ? `≈ ${pctToDeg(pct).toFixed(1)}°` : "—";
|
||||
};
|
||||
|
||||
/** 옵션 모달 — [적용] = 막을 것 없으면 `onApply` 후 닫음. */
|
||||
export function openSlopeBands(current: SlopeBand[], onApply: (bands: SlopeBand[]) => void): void {
|
||||
let rows: Array<{ root: HTMLElement; read: () => SlopeBand }> = [];
|
||||
const list = document.createElement("div");
|
||||
const error = createText("", "error");
|
||||
error.hidden = true;
|
||||
const addButton = createButton({
|
||||
label: "범위 추가",
|
||||
variant: "ghost",
|
||||
size: 28,
|
||||
iconStart: "plus",
|
||||
onClick: () => {
|
||||
const last = rows.at(-1)?.read();
|
||||
const low = last && Number.isFinite(last.high) ? Math.min(last.high, SLOPE_MAX_PCT - 1) : 0;
|
||||
const color = SLOPE_COLORS[rows.length % SLOPE_COLORS.length].color;
|
||||
add({ low, high: SLOPE_MAX_PCT, color });
|
||||
},
|
||||
});
|
||||
addButton.dataset.role = "slope-band-add";
|
||||
|
||||
function refresh(): void {
|
||||
addButton.disabled = rows.length >= SLOPE_MAX_BANDS;
|
||||
error.hidden = true;
|
||||
}
|
||||
|
||||
function add(band: SlopeBand): void {
|
||||
const field = (label: string, value: number) => {
|
||||
const got = createInputField({
|
||||
label,
|
||||
type: "number",
|
||||
unit: "%",
|
||||
min: 0,
|
||||
max: SLOPE_MAX_PCT,
|
||||
size: 28,
|
||||
value: Number.isFinite(value) ? String(value) : "",
|
||||
});
|
||||
const deg = createText(degText(got.input.value), "muted");
|
||||
got.input.addEventListener("input", () => {
|
||||
deg.textContent = degText(got.input.value);
|
||||
refresh();
|
||||
});
|
||||
got.root.append(deg);
|
||||
return got;
|
||||
};
|
||||
const low = field("아래", band.low);
|
||||
const high = field("위", band.high);
|
||||
const color = createSelectField({
|
||||
label: "색",
|
||||
size: 28,
|
||||
options: SLOPE_COLORS.map((c) => ({ value: c.color, text: c.name })),
|
||||
value: band.color,
|
||||
});
|
||||
const chip = createLegend([{ label: "", color: `var(${band.color})` }]);
|
||||
color.select.addEventListener("change", () =>
|
||||
chip
|
||||
.querySelector<HTMLElement>(".ui-legend__mark")!
|
||||
.style.setProperty("--legend-color", `var(${color.select.value})`),
|
||||
);
|
||||
const root = document.createElement("div");
|
||||
root.className = "d03-plan__row";
|
||||
root.dataset.role = "slope-band";
|
||||
const entry = {
|
||||
root,
|
||||
read: (): SlopeBand => ({
|
||||
low: low.input.value.trim() === "" ? NaN : Number(low.input.value),
|
||||
high: high.input.value.trim() === "" ? NaN : Number(high.input.value),
|
||||
color: color.select.value,
|
||||
}),
|
||||
};
|
||||
const remove = createIconButton({
|
||||
icon: "trash",
|
||||
title: "이 범위 지움",
|
||||
size: 28,
|
||||
onClick: () => {
|
||||
rows = rows.filter((row) => row !== entry);
|
||||
root.remove();
|
||||
refresh();
|
||||
},
|
||||
});
|
||||
root.append(low.root, high.root, color.root, chip, remove);
|
||||
rows.push(entry);
|
||||
list.append(root);
|
||||
refresh();
|
||||
}
|
||||
|
||||
function fill(bands: SlopeBand[]): void {
|
||||
rows = [];
|
||||
list.replaceChildren();
|
||||
bands.forEach(add);
|
||||
}
|
||||
|
||||
openModal({
|
||||
title: "급경사 범위(기울기 %)",
|
||||
closeLabel: "닫기",
|
||||
width: "40rem",
|
||||
mount: (body, close, actions) => {
|
||||
fill(current);
|
||||
const reset = createButton({
|
||||
label: "기본값",
|
||||
variant: "ghost",
|
||||
size: 28,
|
||||
onClick: () => fill(DEFAULT_SLOPE_BANDS),
|
||||
});
|
||||
reset.dataset.role = "slope-band-default";
|
||||
const apply = createButton({
|
||||
label: "적용",
|
||||
size: 28,
|
||||
onClick: () => {
|
||||
const bands = rows.map((row) => row.read());
|
||||
const why = checkBands(bands);
|
||||
if (why) {
|
||||
error.lastChild!.textContent = why;
|
||||
error.hidden = false;
|
||||
return;
|
||||
}
|
||||
onApply(bands.sort((a, b) => a.low - b.low));
|
||||
close();
|
||||
},
|
||||
});
|
||||
apply.dataset.role = "slope-band-apply";
|
||||
actions.append(apply);
|
||||
const foot = document.createElement("div");
|
||||
foot.className = "d03-plan__row";
|
||||
foot.append(addButton, reset);
|
||||
body.append(
|
||||
createText(
|
||||
`0~${SLOPE_MAX_PCT} % · 아래 포함 · 위 뺌 · 겹치면 안 됨 · 기본 = 15 · 35 · 40°(지식DB)`,
|
||||
"hint",
|
||||
),
|
||||
list,
|
||||
foot,
|
||||
error,
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -106,28 +106,6 @@
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.d03-plan__piece-head {
|
||||
margin: 6px 0 0;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.d03-plan__piece {
|
||||
display: flex;
|
||||
gap: var(--spacing-8);
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.d03-plan__piece > .ui-text {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.d03-plan__piece > .ui-field {
|
||||
flex: 0 0 6.5rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 지형 받기 범위 미리보기 — 카카오 지도 */
|
||||
.d03-plan__extent > .ui-text {
|
||||
margin: 0 0 6px;
|
||||
@@ -152,12 +130,6 @@
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
/* 580-3 고른 선 · 조각 줄 밝힘 */
|
||||
.d03-plan__piece.is-selected {
|
||||
background: var(--color-accent-soft);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
/* 585-2 고른 구간 속성 판 — 도구줄 아래 오른쪽 · 몸 스크롤 · 구간 줄 = 고스트 단추 */
|
||||
.d03-plan__props {
|
||||
top: calc(var(--spacing-8) * 6);
|
||||
@@ -188,12 +160,22 @@
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.d03-plan__piece[role="button"] {
|
||||
cursor: pointer;
|
||||
/* 597 지점 칸 — [추가] 머리 · 펼친 줄(이름 · 경도 · 위도) */
|
||||
.d03-plan__points-head {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: var(--spacing-8);
|
||||
}
|
||||
|
||||
.d03-plan__piece-year {
|
||||
font-weight: 600;
|
||||
.d03-plan__point-edit {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-8) var(--spacing-8) var(--spacing-8) calc(var(--spacing-8) * 2);
|
||||
}
|
||||
|
||||
.d03-plan__point-edit > .ui-field {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 599 그리기 칸 — 방식 칸(마디 고름 2 × 2 · 길이 · 기울기 두 열) */
|
||||
@@ -213,3 +195,22 @@
|
||||
.d03-plan__draw-fields .ui-field {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 597 구역 · 연도 목록 — 구역 줄(이름 칸 · 연도 고름 · 지움) · 구간 줄(측점 글 작게) */
|
||||
.d03-plan__line-tail {
|
||||
display: flex;
|
||||
gap: var(--spacing-4);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.d03-plan__pieces .ui-list-row__label > .ui-field {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.d03-plan__piece-row {
|
||||
padding-left: calc(var(--spacing-8) * 2);
|
||||
}
|
||||
|
||||
.d03-plan__piece-row .ui-list-row__label {
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/* =============================================================================
|
||||
* D03_PlanAnalysis_PointEdit.ts
|
||||
* D03 지점 칸 손 고침 계산(PLAN 597-2 · 요구 463 7 · 8) — 화면 · fetch 없음(logic/).
|
||||
* 이름 규칙 = `Plan.ts` 의 `nameError`(20 자 · 파일 이름 못 쓰는 글자 · 서버 400 짝) · 빈 값 = 「지점 N」 보임.
|
||||
* 좌표 = 경위도 손 고침 → 작업 좌표(TM) · 그 자리 노선 노드도 같이(`moveNodesAt`).
|
||||
* ========================================================================== */
|
||||
|
||||
import { moveNodesAt, type PlanNetwork, type Vertex } from "./D03_PlanAnalysis_Plan";
|
||||
import { lonLatToTm, type TmParams } from "./D03_PlanAnalysis_Tm";
|
||||
|
||||
/** 지점 상한 — 서버 `MAX_POINTS` 와 같음. */
|
||||
export const MAX_POINTS = 40;
|
||||
|
||||
/** 보이는 이름 — 빈 값 = 「지점 N」. */
|
||||
export function pointLabel(names: string[], index: number): string {
|
||||
return names[index]?.trim() || `지점 ${index + 1}`;
|
||||
}
|
||||
|
||||
/** 경위도 글 → 값 · 못 읽거나 범위 밖이면 까닭 글. */
|
||||
export function parseLonLat(lonText: string, latText: string): [number, number] | string {
|
||||
const lon = Number(lonText.trim());
|
||||
const lat = Number(latText.trim());
|
||||
if (!lonText.trim() || !Number.isFinite(lon) || Math.abs(lon) > 180) return "경도는 −180 ~ 180";
|
||||
if (!latText.trim() || !Number.isFinite(lat) || Math.abs(lat) > 90) return "위도는 −90 ~ 90";
|
||||
return [lon, lat];
|
||||
}
|
||||
|
||||
export interface PointsState {
|
||||
points: Vertex[];
|
||||
names: string[];
|
||||
network: PlanNetwork;
|
||||
}
|
||||
|
||||
/** 지점 더함 — 이름 비움. 상한이면 그대로. */
|
||||
export function addPoint(state: PointsState, at: Vertex): PointsState {
|
||||
if (state.points.length >= MAX_POINTS) return state;
|
||||
return {
|
||||
...state,
|
||||
points: [...state.points, [at[0], at[1]]],
|
||||
names: [...state.names, ""],
|
||||
};
|
||||
}
|
||||
|
||||
/** 이름 바꿈 — 빈 값 허용. */
|
||||
export function renamePoint(state: PointsState, index: number, name: string): PointsState {
|
||||
const names = state.points.map((_, k) => state.names[k] ?? "");
|
||||
names[index] = name.trim();
|
||||
return { ...state, names };
|
||||
}
|
||||
|
||||
/** 경위도로 옮김 — 그 자리 노선 노드도 같이. */
|
||||
export function movePointTo(
|
||||
state: PointsState,
|
||||
index: number,
|
||||
tm: TmParams,
|
||||
lon: number,
|
||||
lat: number,
|
||||
): PointsState {
|
||||
const to = lonLatToTm(tm, lon, lat);
|
||||
const from = state.points[index];
|
||||
return {
|
||||
...state,
|
||||
points: state.points.map((p, k): Vertex => (k === index ? to : p)),
|
||||
network: moveNodesAt(state.network, from, to),
|
||||
};
|
||||
}
|
||||
|
||||
/** 선 이름 바꿈(597-3) — 빈 값 = 차례 이름으로 돌아감(`name` 비움). */
|
||||
export function setLineName(network: PlanNetwork, lineId: string, name: string): PlanNetwork {
|
||||
const text = name.trim();
|
||||
return {
|
||||
...network,
|
||||
lines: network.lines.map((line) =>
|
||||
line.id === lineId ? { ...line, name: text || null } : line,
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/* =============================================================================
|
||||
* D03_PlanAnalysis_SlopeBands.ts
|
||||
* D03 급경사 범위(PLAN 603 · 요구 466 · 467 · 468) — 기울기 % 범위 · 색 · 비율 셈(화면 · 서버 없이).
|
||||
* 서버 값 그림 칸 = 기울기 % 내림(0~250 · 250 = 250 % 이상 · `D03_PlanAnalysis_Layers.slope_values_png`) ·
|
||||
* 칸 v 는 가운데(v + 0.5)가 [아래, 위) 안이면 그 범위(위 = 250 이면 250 % 이상도) · 앞 범위 먼저.
|
||||
* 기본 = 지식DB 15 · 35 · 40° 의 % — `임도망_편성.md:20` · `타당성평가.md:59` · `노선_배치.md:29`.
|
||||
* 부르는 곳 = `D03_PlanAnalysis_UI_SlopeBands.ts` · `D03_PlanAnalysis_UI_LayerLegend.ts`.
|
||||
* ========================================================================== */
|
||||
|
||||
export interface SlopeBand {
|
||||
/** 아래 % (포함) */
|
||||
low: number;
|
||||
/** 위 % (뺌 · 250 = 끝까지) */
|
||||
high: number;
|
||||
/** 그림 색 이름(`--draw-map-*`) — `SLOPE_COLORS` 중 하나 */
|
||||
color: string;
|
||||
}
|
||||
|
||||
export const SLOPE_MAX_PCT = 250;
|
||||
export const SLOPE_EMPTY = 255;
|
||||
export const SLOPE_MAX_BANDS = 8;
|
||||
|
||||
/** 고를 색(이름 · 그림 색 값). */
|
||||
export const SLOPE_COLORS: Array<{ name: string; color: string }> = [
|
||||
{ name: "노랑", color: "--draw-map-landslide-3" },
|
||||
{ name: "주황", color: "--draw-map-landslide-2" },
|
||||
{ name: "빨강", color: "--draw-map-landslide-1" },
|
||||
{ name: "연두", color: "--draw-map-landslide-4" },
|
||||
{ name: "초록", color: "--draw-map-landslide-5" },
|
||||
{ name: "하늘", color: "--draw-map-stream" },
|
||||
{ name: "파랑", color: "--draw-map-zone-water" },
|
||||
{ name: "보라", color: "--draw-map-zone-heritage" },
|
||||
{ name: "분홍", color: "--draw-map-parcel" },
|
||||
];
|
||||
|
||||
export const DEFAULT_SLOPE_BANDS: SlopeBand[] = [
|
||||
{ low: 26.8, high: 70.0, color: "--draw-map-landslide-3" },
|
||||
{ low: 70.0, high: 83.9, color: "--draw-map-landslide-2" },
|
||||
{ low: 83.9, high: SLOPE_MAX_PCT, color: "--draw-map-landslide-1" },
|
||||
];
|
||||
|
||||
export const pctToDeg = (pct: number): number => (Math.atan(pct / 100) * 180) / Math.PI;
|
||||
|
||||
/** 범위 고칠 거리 — 없으면 null(빈 값 · 0~250 밖 · 아래 ≥ 위 · 겹침 · 없음 · 너무 많음). */
|
||||
export function checkBands(bands: SlopeBand[]): string | null {
|
||||
if (!bands.length) return "범위가 하나는 있어야 합니다";
|
||||
if (bands.length > SLOPE_MAX_BANDS) return `범위는 ${SLOPE_MAX_BANDS}개까지입니다`;
|
||||
for (const [i, band] of bands.entries()) {
|
||||
const at = `${i + 1}번째 범위`;
|
||||
if (!Number.isFinite(band.low) || !Number.isFinite(band.high)) return `${at} — 값이 비었습니다`;
|
||||
if (band.low < 0 || band.high > SLOPE_MAX_PCT) return `${at} — 0~${SLOPE_MAX_PCT} % 안으로`;
|
||||
if (band.low >= band.high) return `${at} — 아래가 위보다 작아야 합니다`;
|
||||
if (!SLOPE_COLORS.some((c) => c.color === band.color)) return `${at} — 색을 고르세요`;
|
||||
}
|
||||
const sorted = [...bands].sort((a, b) => a.low - b.low);
|
||||
for (let i = 1; i < sorted.length; i++)
|
||||
if (sorted[i].low < sorted[i - 1].high)
|
||||
return `범위가 겹칩니다 — ${sorted[i - 1].low}~${sorted[i - 1].high} % 와 ${sorted[i].low}~${sorted[i].high} %`;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 저장 글 → 범위(깨졌거나 맞지 않으면 기본). */
|
||||
export function parseBands(raw: string | null): SlopeBand[] {
|
||||
try {
|
||||
const got = JSON.parse(raw ?? "null") as unknown;
|
||||
if (Array.isArray(got)) {
|
||||
const bands = got.map((b: Partial<SlopeBand>) => ({
|
||||
low: Number(b?.low),
|
||||
high: Number(b?.high),
|
||||
color: String(b?.color),
|
||||
}));
|
||||
if (!checkBands(bands)) return bands;
|
||||
}
|
||||
} catch {
|
||||
// 깨진 값 = 기본
|
||||
}
|
||||
return DEFAULT_SLOPE_BANDS.map((b) => ({ ...b }));
|
||||
}
|
||||
|
||||
/** 칸 값 v(0~255) → 범위 차례(없으면 −1). */
|
||||
export function bandOf(v: number, bands: SlopeBand[]): number {
|
||||
if (v > SLOPE_MAX_PCT) return -1;
|
||||
const mid = v + 0.5;
|
||||
return bands.findIndex(
|
||||
(b) => b.low <= mid && (mid < b.high || (b.high >= SLOPE_MAX_PCT && v === SLOPE_MAX_PCT)),
|
||||
);
|
||||
}
|
||||
|
||||
/** 1 % 칸 수 → 범위별 비율(유효 칸 대비). */
|
||||
export function bandRatios(counts: number[], bands: SlopeBand[]): number[] {
|
||||
const total = counts.reduce((sum, n) => sum + n, 0) || 1;
|
||||
const out = bands.map(() => 0);
|
||||
counts.forEach((n, v) => {
|
||||
const i = bandOf(v, bands);
|
||||
if (i >= 0) out[i] += n;
|
||||
});
|
||||
return out.map((n) => n / total);
|
||||
}
|
||||
@@ -36,6 +36,13 @@ def test_d03_parts():
|
||||
layers = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts")
|
||||
assert 'createElement("progress")' not in ts and "createProgressBar(" not in ts
|
||||
assert "createProgressCircle({ size: 40, corner: true })" in ts # 596-3 받기 = 지도 귀퉁이 서클
|
||||
site = _src(
|
||||
"D03_PlanAnalysis/D03_PlanAnalysis_UI_Site.ts"
|
||||
) # 596-2 위치 칸 · 596-5 등록 찾기 = 같은 부품
|
||||
assert "createFindList(" in site and "searchPlaces(" in site and "createPlanSite(" in ts
|
||||
assert "createPlaceFind(" in _src("D01_PlanRegister/D01_PlanRegister_UI_Page.ts")
|
||||
for raw in ('createElement("button")', 'createElement("input")'):
|
||||
assert raw not in site, raw
|
||||
# 562-3 — 날 단추 · 입력 · 고르기 없음 · 도구줄 · 제약 칸 · 연도 고름은 부품
|
||||
for src in (ts, draw, draw_panel, tools, layers):
|
||||
for raw in ('createElement("button")', 'createElement("input")', 'createElement("select")'):
|
||||
@@ -90,8 +97,12 @@ def test_d03_parts():
|
||||
props = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Props.ts") # 585-2 조각 연도 = 속성 판
|
||||
assert "createSelectField(" in props and "createFloatPanel(" in props and "createKv(" in props
|
||||
assert 'createElement("button")' not in props and 'createElement("select")' not in props
|
||||
assert 'createElement("select")' not in three and "createSelectField" not in three # 601 선 고름 걷음
|
||||
assert "createCheckbox" not in three and "setCorridors(" in three # 601 노선망 예상 코리도 한 번에
|
||||
assert (
|
||||
'createElement("select")' not in three and "createSelectField" not in three
|
||||
) # 601 선 고름 걷음
|
||||
assert (
|
||||
"createCheckbox" not in three and "setCorridors(" in three
|
||||
) # 601 노선망 예상 코리도 한 번에
|
||||
for dead in (".d03-plan__bar", ".d03-plan__mode", "is-picked"):
|
||||
assert dead not in css, dead
|
||||
assert "b05-route__3d-modal" not in three and "b05-route__3d-body" not in three # 빌림 끊음
|
||||
|
||||
@@ -113,7 +113,7 @@ def test_de_pages_guard_leave():
|
||||
if name:
|
||||
assert f'discard: () => clearState("{name}", projectId)' in call, path
|
||||
# D03 — 못 읽은 채(프로젝트 없음 · 불러오기 실패) 떠나면 고침 없음
|
||||
assert "let savedJson = JSON.stringify({ network, points });" in _src(
|
||||
assert "let savedJson = JSON.stringify({ network, points, pointNames, site });" in _src(
|
||||
"D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts"
|
||||
)
|
||||
e03 = _src("E03_StabSection/E03_StabSection_UI_Page.ts")
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""D 노선분석 지도 겹(PLAN 569-1 · 595-1) — 도엽 겹 풀어 냄 · 지적도 1 km 조각 · 1,000 꽉 차면 넷 ·
|
||||
겹친 필지 거름 · 실패 조각만 다시 · 급경사 15° · 35° · 40° 단계 · 격자 지문 같으면 다시 안 셈.
|
||||
겹친 필지 거름 · 실패 조각만 다시 · 급경사 % 값 그림 · 1 % 칸 수(603) · 격자 지문 같으면 다시 안 셈.
|
||||
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_569_1_plan_layers.py -q
|
||||
"""
|
||||
@@ -169,20 +169,22 @@ def _indices(value) -> np.ndarray:
|
||||
return np.asarray(Image.open(io.BytesIO(raw)))
|
||||
|
||||
|
||||
def test_slope_bands_and_north_up() -> None:
|
||||
def test_slope_values_and_north_up() -> None:
|
||||
"""값 그림(603) — 칸 = 기울기 % 내림 · 위 = 북 · 빈 칸 · 끝 줄 = 255 · 칸 수 합 = 유효 칸."""
|
||||
z = _plane(10.0, 37.0)
|
||||
value = layers.slope_bands_png(z, np.ones(z.shape, bool), 2.0)
|
||||
ratios = [b["ratio"] for b in value["bands"]]
|
||||
assert [b["min_deg"] for b in value["bands"]] == [15.0, 35.0, 40.0]
|
||||
assert ratios[0] < 0.1 and 0.4 < ratios[1] < 0.6 and ratios[2] < 0.1 # 경계 줄만
|
||||
value = layers.slope_values_png(z, np.ones(z.shape, bool), 2.0)
|
||||
index = _indices(value)
|
||||
assert value["width"] == value["height"] == 40
|
||||
assert (index[5, 5:35] == 2).all() and (index[-5, 5:35] == 0).all() # 위 = 북 = 37°
|
||||
steep = layers.slope_bands_png(_plane(45.0, 20.0), np.ones((40, 40), bool), 2.0)
|
||||
assert steep["bands"][2]["ratio"] > 0.4 and steep["bands"][0]["ratio"] > 0.4
|
||||
assert value["width"] == value["height"] == 40 and "bands" not in value
|
||||
assert (index[5, 5:35] == 75).all() and (index[-5, 5:35] == 17).all() # 위 = 북 = 37° = 75 %
|
||||
assert (index[0] == layers.SLOPE_EMPTY).all() # 격자 끝 = 셈 못 함
|
||||
assert sum(value["counts"]) == int((index != layers.SLOPE_EMPTY).sum()) == 38 * 38
|
||||
assert len(value["counts"]) == layers.SLOPE_MAX_PCT + 1
|
||||
cliff = layers.slope_values_png(_plane(80.0, 80.0), np.ones((40, 40), bool), 2.0)
|
||||
assert cliff["counts"][layers.SLOPE_MAX_PCT] == 38 * 38 # 567 % → 250 % 이상 칸
|
||||
hole = np.ones(z.shape, bool)
|
||||
hole[25:30, 10:20] = False
|
||||
assert (_indices(layers.slope_bands_png(z, hole, 2.0))[-29:-24, 10:20] == 0).all()
|
||||
holed = _indices(layers.slope_values_png(z, hole, 2.0))
|
||||
assert (holed[-29:-24, 10:20] == layers.SLOPE_EMPTY).all()
|
||||
|
||||
|
||||
def test_slope_layer_fingerprint(tmp_path, monkeypatch) -> None:
|
||||
@@ -194,8 +196,8 @@ def test_slope_layer_fingerprint(tmp_path, monkeypatch) -> None:
|
||||
y = 450000.0 + np.arange(40) * 2.0
|
||||
np.savez(npz, x=x, y=y, z=z, valid_mask=np.ones(z.shape, bool), resolution=[2.0])
|
||||
counted = []
|
||||
real = layers.slope_bands_png
|
||||
monkeypatch.setattr(layers, "slope_bands_png", lambda *a: counted.append(1) or real(*a))
|
||||
real = layers.slope_values_png
|
||||
monkeypatch.setattr(layers, "slope_values_png", lambda *a: counted.append(1) or real(*a))
|
||||
first = layers.slope_layer(tmp_path, models, EPSG)
|
||||
assert first["box_m"] == [199999.0, 449999.0, 200079.0, 450079.0] and len(first["box"]) == 4
|
||||
assert layers.slope_layer(tmp_path, models, EPSG) == first and len(counted) == 1
|
||||
|
||||
@@ -200,7 +200,7 @@ def test_hillshade_saved_apart_from_slope(tmp_path) -> None:
|
||||
shade = layers.slope_layer(tmp_path, models, EPSG, "hillshade")
|
||||
slope = layers.slope_layer(tmp_path, models, EPSG, "slope")
|
||||
assert shade["image"].startswith("data:image/png;base64,") and shade["bands"] == []
|
||||
assert slope["bands"] and shade["box_m"] == slope["box_m"]
|
||||
assert slope["counts"] and shade["box_m"] == slope["box_m"] # 603 = 값 그림 + 칸 수
|
||||
assert layers.slope_layer(tmp_path, models, EPSG, "hillshade") == shade
|
||||
|
||||
|
||||
|
||||
@@ -276,7 +276,9 @@ def test_screen_defs() -> None:
|
||||
assert entry and entry.group(2) == "false", key # 처음 값 끔
|
||||
assert re.search(rf"^\s+{entry.group(1)}:", icons, re.M), key
|
||||
assert f"--draw-map-{color}:" in theme and f'"--draw-map-{color}"' in page
|
||||
assert re.search(r"^\s+upload:", icons, re.M) and "pickFiles(" in page # 부품 상자 고르기 창
|
||||
site = (folder / "D03_PlanAnalysis_UI_Site.ts").read_text("utf-8") # 596-2 올리기 = 위치 칸
|
||||
assert "pickFiles(" in site and "pickFiles(" not in page # 부품 상자 고르기 창
|
||||
assert "uploadRoads" in page and "uploadPlanForestRoads(" in page
|
||||
assert "<input" not in page and 'createElement("input")' not in page
|
||||
assert paint.count("base: true,") == paint.count("draw: (context, map) =>") # 겹 모두 바탕
|
||||
assert "임도 짐작" in legend and "올린 임도망도" in legend # 범례 구분
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"""597-2 예정노선 지점 칸 손 고침 계산 — 이름 규칙 · 보이는 이름 · 경위도 읽기 · 더함 · 이름 바꿈 · 경위도로 옮김
|
||||
(요구 463 7 · 8) — TS `logic/D03_PlanAnalysis_PointEdit.ts`.
|
||||
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_597_point_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]
|
||||
TS_FILE = ROOT / "D03_PlanAnalysis" / "logic" / "D03_PlanAnalysis_PointEdit.ts"
|
||||
TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
|
||||
|
||||
_RUNNER = """
|
||||
import { writeFileSync } from "node:fs";
|
||||
import * as plan from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.js";
|
||||
import * as pe from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_PointEdit.js";
|
||||
import * as tmx from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Tm.js";
|
||||
const Y = 2026;
|
||||
const tm = tmx.tmParams(5187);
|
||||
// 지점 (100,0) 에서 시작하는 선 · 지점 둘
|
||||
let n = plan.newLine(plan.emptyNetwork(), [100, 0]);
|
||||
n = plan.normalizePieces(plan.appendVertex(n, 0, [200, 0]), Y);
|
||||
const state = { points: [[100, 0], [500, 500]], names: ["", "가"], network: n };
|
||||
const named = pe.setLineName(pe.setLineName(n, n.lines[0].id, ' 본선A '), 'none', 'x');
|
||||
const cleared = pe.setLineName(named, n.lines[0].id, ' ');
|
||||
const added = pe.addPoint(state, [7, 8]);
|
||||
const full = { points: Array.from({ length: 40 }, (_, i) => [i, 0]), names: [], network: n };
|
||||
const [lon, lat] = tmx.tmToLonLat(tm, 100, 0);
|
||||
const moved = pe.movePointTo(state, 0, tm, lon + 0.001, lat);
|
||||
const back = tmx.tmToLonLat(tm, ...moved.points[0]);
|
||||
writeFileSync(process.argv[2], JSON.stringify({
|
||||
errors: ["", "가나다", "a/b", "a" + String.fromCharCode(92) + "b", "a:b", "a*b", "a?b", 'a"b', "a<b", "a>b", "a|b", "x".repeat(20), "x".repeat(21)]
|
||||
.map((s) => plan.nameError(s)),
|
||||
labels: [pe.pointLabel(["", " ", "가"], 0), pe.pointLabel(["", " ", "가"], 1), pe.pointLabel(["", " ", "가"], 2), pe.pointLabel([], 3)],
|
||||
parse: [pe.parseLonLat("128.5", "36.9"), pe.parseLonLat("", "36.9"), pe.parseLonLat("128.5", "abc"), pe.parseLonLat("181", "0"), pe.parseLonLat("0", "-91")],
|
||||
added: [added.points, added.names, added.network === n],
|
||||
lineName: [named.lines[0].name, plan.lineNames(named.lines), cleared.lines[0].name, plan.lineNames(cleared.lines)],
|
||||
full: pe.addPoint(full, [1, 1]) === full,
|
||||
renamed: [pe.renamePoint(state, 1, " 나 ").names, pe.renamePoint({ ...state, names: [] }, 1, "다").names],
|
||||
moved: { points: moved.points, lines: moved.network.lines.map((l) => l.vertices), back, expect: [lon + 0.001, lat], untouched: moved.points[1], names: moved.names },
|
||||
}));
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def got(tmp_path_factory: pytest.TempPathFactory) -> dict:
|
||||
tmp_path = tmp_path_factory.mktemp("pe")
|
||||
out = tmp_path / "js"
|
||||
subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
["node", str(TSC), str(TS_FILE), "--outDir", str(out), "--rootDir", str(ROOT),
|
||||
"--module", "esnext", "--target", "es2022", "--moduleResolution", "bundler",
|
||||
"--ignoreConfig"],
|
||||
cwd=str(ROOT), check=True, capture_output=True,
|
||||
) # fmt: skip
|
||||
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_path / "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 test_name_rule(got: dict) -> None:
|
||||
ok, bad = got["errors"][:2], got["errors"][2:11]
|
||||
assert ok == [None, None] # 빈 이름 · 한글 = 됨
|
||||
assert all(msg and "쓸 수 없습니다" in msg for msg in bad) # 파일 이름 못 쓰는 글자 아홉
|
||||
assert got["errors"][11] is None # 20 자
|
||||
assert "20 자" in got["errors"][12] # 21 자 = 막음
|
||||
|
||||
|
||||
def test_label_blank_falls_back(got: dict) -> None:
|
||||
assert got["labels"] == ["지점 1", "지점 2", "가", "지점 4"]
|
||||
|
||||
|
||||
def test_parse_lonlat(got: dict) -> None:
|
||||
ok, no_lon, bad_lat, big_lon, big_lat = got["parse"]
|
||||
assert ok == [128.5, 36.9]
|
||||
assert no_lon.startswith("경도") and bad_lat.startswith("위도")
|
||||
assert big_lon.startswith("경도") and big_lat.startswith("위도")
|
||||
|
||||
|
||||
def test_add_and_limit(got: dict) -> None:
|
||||
points, names, network_same = got["added"]
|
||||
assert points == [[100, 0], [500, 500], [7, 8]] and names == ["", "가", ""]
|
||||
assert network_same # 더해도 노선 그대로
|
||||
assert got["full"] # 40 개 = 그대로
|
||||
|
||||
|
||||
def test_rename(got: dict) -> None:
|
||||
assert got["renamed"][0] == ["", "나"] # 앞뒤 빈칸 뺌
|
||||
assert got["renamed"][1] == ["", "다"] # 이름 목록이 짧아도 차례 맞춤
|
||||
|
||||
|
||||
def test_move_by_lonlat_drags_node(got: dict) -> None:
|
||||
moved = got["moved"]
|
||||
assert moved["back"] == pytest.approx(moved["expect"], abs=1e-7) # 경위도 왕복
|
||||
assert moved["lines"][0][0] == moved["points"][0] # 그 자리 노선 노드도 같이
|
||||
assert moved["lines"][0][1] == [200, 0] # 다른 노드는 그대로
|
||||
assert moved["untouched"] == [500, 500] and moved["names"] == ["", "가"]
|
||||
|
||||
|
||||
def test_line_name(got: dict) -> None:
|
||||
named, names, cleared, back = got["lineName"]
|
||||
assert named == "본선A" and names == ["본선A"] # 앞뒤 빈칸 뺌 · 이름 우선
|
||||
assert cleared is None and back == ["본선"] # 빈 값 = 차례 이름으로
|
||||
@@ -0,0 +1,172 @@
|
||||
"""D03 급경사 범위(PLAN 603 · 요구 466 · 467 · 468) — 서버 1 % 칸 수 +
|
||||
`logic/D03_PlanAnalysis_SlopeBands.ts`.
|
||||
|
||||
1 % 칸 수 합 = 유효 칸 · 기본 범위(26.8 · 70.0 · 83.9 %) 비율 = 옛 15 · 35 · 40° 비율 ·
|
||||
범위 막음(빈 값 · 0~250 밖 · 아래 ≥ 위 · 겹침 · 없음) · 깨진 취향 = 기본 · 250 % 이상 칸 = 끝 범위.
|
||||
TS 를 tsc 로 옮겨 node 로 돌림(서버 · 화면 없이).
|
||||
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_603_plan_slope_bands.py -q
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from D03_PlanAnalysis import D03_PlanAnalysis_Layers as layers
|
||||
from E07_StabArea.E07_StabArea_Engine import slope_rad
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
TS_FILE = ROOT / "D03_PlanAnalysis" / "logic" / "D03_PlanAnalysis_SlopeBands.ts"
|
||||
TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
|
||||
CELL = 2.0
|
||||
OLD_DEG = ((15.0, 35.0), (35.0, 40.0), (40.0, 90.0)) # 옛 SLOPE_BANDS
|
||||
|
||||
|
||||
def _hills(n: int = 300) -> np.ndarray:
|
||||
"""0 ~ 60° 남짓 고루 섞인 언덕(구멍 하나)."""
|
||||
y, x = np.mgrid[0:n, 0:n] * CELL
|
||||
return 40 * np.sin(x / 37) * np.cos(y / 53) + 25 * np.sin((x + 2 * y) / 19) + 0.3 * x
|
||||
|
||||
|
||||
_RUNNER = """
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import * as bands from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_SlopeBands.js";
|
||||
const cases = JSON.parse(readFileSync(process.argv[2], "utf8"));
|
||||
const out = { DEFAULT: bands.DEFAULT_SLOPE_BANDS };
|
||||
// 인자 null = 기본 범위
|
||||
for (const [key, [name, ...args]] of Object.entries(cases))
|
||||
out[key] = bands[name](...args.map((a) => (a === null ? bands.DEFAULT_SLOPE_BANDS : a)));
|
||||
writeFileSync(process.argv[3], JSON.stringify(out));
|
||||
"""
|
||||
|
||||
C = "--draw-map-landslide-3"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def hills() -> dict:
|
||||
z = _hills()
|
||||
valid = np.ones(z.shape, bool)
|
||||
valid[100:140, 100:180] = False
|
||||
value = layers.slope_values_png(z, valid, CELL)
|
||||
deg = np.degrees(slope_rad(np.where(valid, z, np.nan), CELL))
|
||||
known = np.isfinite(deg)
|
||||
old = [float(((deg >= lo) & (deg < hi) & known).sum() / known.sum()) for lo, hi in OLD_DEG]
|
||||
return {"counts": value["counts"], "known": int(known.sum()), "old": old}
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def r(tmp_path_factory: pytest.TempPathFactory, hills: dict) -> dict:
|
||||
tmp = tmp_path_factory.mktemp("slope_bands")
|
||||
out = tmp / "js"
|
||||
subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
[
|
||||
"node",
|
||||
str(TSC),
|
||||
str(TS_FILE),
|
||||
"--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")
|
||||
band = lambda lo, hi: {"low": lo, "high": hi, "color": C} # noqa: E731
|
||||
top = [0] * 250 + [7]
|
||||
cases = {
|
||||
"default_ratios": ["bandRatios", hills["counts"], None],
|
||||
"user_ratios": [
|
||||
"bandRatios",
|
||||
hills["counts"],
|
||||
[band(10, 15), band(15, 20), band(20, 250), band(0, 10)],
|
||||
],
|
||||
"top": ["bandRatios", top, [band(83.9, 250)]],
|
||||
"top_closed": ["bandRatios", top, [band(83.9, 249)]],
|
||||
"ok": ["checkBands", [band(10, 15), band(15, 20), band(20, 250), band(0, 10)]],
|
||||
"overlap": ["checkBands", [band(10, 20), band(15, 30)]],
|
||||
"empty_value": ["checkBands", [band(None, 20)]],
|
||||
"out_of_range": ["checkBands", [band(10, 260)]],
|
||||
"upside_down": ["checkBands", [band(30, 20)]],
|
||||
"none": ["checkBands", []],
|
||||
"bad_color": ["checkBands", [{"low": 1, "high": 2, "color": "red"}]],
|
||||
"parse_broken": ["parseBands", "{oops"],
|
||||
"parse_overlap": ["parseBands", json.dumps([band(10, 20), band(15, 30)])],
|
||||
"parse_ok": ["parseBands", json.dumps([band(10, 20)])],
|
||||
"deg": ["pctToDeg", 100],
|
||||
}
|
||||
job, result = tmp / "job.json", tmp / "out.json"
|
||||
job.write_text(json.dumps(cases), encoding="utf-8")
|
||||
subprocess.run( # noqa: S603
|
||||
["node", str(out / "runner.mjs"), str(job), str(result)],
|
||||
cwd=str(ROOT),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
return json.loads(result.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def test_칸_수_합은_유효_칸(hills):
|
||||
assert sum(hills["counts"]) == hills["known"]
|
||||
assert len(hills["counts"]) == layers.SLOPE_MAX_PCT + 1
|
||||
|
||||
|
||||
def test_기본은_지식DB_15_35_40도(r):
|
||||
assert [(b["low"], b["high"]) for b in r["DEFAULT"]] == [
|
||||
(26.8, 70.0),
|
||||
(70.0, 83.9),
|
||||
(83.9, 250),
|
||||
]
|
||||
|
||||
|
||||
def test_기본_비율은_옛_15_35_40도_비율과_같음(r, hills):
|
||||
"""1 % 칸 경계(26.8 → 27 · 83.9 → 84 %) 차이만 — 범례 0.1 % 자리 반 칸 안."""
|
||||
for new, old in zip(r["default_ratios"], hills["old"], strict=True):
|
||||
assert old > 0.02
|
||||
assert new == pytest.approx(old, abs=0.002)
|
||||
|
||||
|
||||
def test_사용자_범위_넷(r):
|
||||
ratios = r["user_ratios"]
|
||||
assert len(ratios) == 4 and sum(ratios) == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_250_퍼센트_이상은_끝_범위(r):
|
||||
assert r["top"] == [1.0]
|
||||
assert r["top_closed"] == [0.0]
|
||||
|
||||
|
||||
def test_범위_막음(r):
|
||||
assert r["ok"] is None
|
||||
assert "겹칩니다" in r["overlap"]
|
||||
assert "비었습니다" in r["empty_value"]
|
||||
assert "0~250" in r["out_of_range"]
|
||||
assert "아래가 위보다" in r["upside_down"]
|
||||
assert "하나는" in r["none"]
|
||||
assert "색" in r["bad_color"]
|
||||
|
||||
|
||||
def test_깨진_취향은_기본(r):
|
||||
assert r["parse_broken"] == r["DEFAULT"]
|
||||
assert r["parse_overlap"] == r["DEFAULT"]
|
||||
assert r["parse_ok"] == [{"low": 10, "high": 20, "color": C}]
|
||||
assert r["deg"] == pytest.approx(45.0)
|
||||
@@ -102,6 +102,13 @@ export const ui_locales_de = {
|
||||
DE_Reg_SiteNoAddress: ["주소를 찾지 못했습니다", "Address not found"],
|
||||
DE_Reg_SiteSource: ["출처", "Source"],
|
||||
DE_Reg_SiteRequired: ["지도에서 기준점을 찍으세요.", "Set the reference point on the map."],
|
||||
DE_Reg_Find: ["주소 · 장소 찾기 (Enter)", "Find address or place (Enter)"],
|
||||
DE_Reg_FindShort: ["두 글자 이상", "Two or more characters"],
|
||||
DE_Reg_FindBusy: ["찾는 중…", "Searching…"],
|
||||
DE_Reg_FindNone: ["찾은 곳 없음", "Nothing found"],
|
||||
DE_Reg_FindFail: ["찾지 못함", "Search failed"],
|
||||
DE_Reg_FindAddress: ["주소", "Address"],
|
||||
DE_Reg_FindPlace: ["장소", "Place"],
|
||||
DE_Reg_MapType: ["바탕 지도", "Base map"],
|
||||
DE_Reg_MapNormal: ["일반", "Map"],
|
||||
DE_Reg_MapSatellite: ["위성", "Satellite"],
|
||||
|
||||
Reference in New Issue
Block a user