From 64b35da265ab137afc5bda076965dbe1f2539aed Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 30 Oct 2026 21:43:20 +0900 Subject: [PATCH] =?UTF-8?q?feat(D=20=EC=98=88=EC=A0=95=EB=85=B8=EC=84=A0):?= =?UTF-8?q?=20=EC=98=81=EC=97=AD=20=EB=A3=A8=ED=8B=B4=20=E2=80=94=20?= =?UTF-8?q?=EB=B0=9B=EA=B8=B0=20=EB=81=9D=20=EC=A7=80=ED=98=95=EB=A9=B4=20?= =?UTF-8?q?=C2=B7=20=EB=B0=9B=EC=9D=80=20=EB=B2=94=EC=9C=84=20=EB=A8=BC?= =?UTF-8?q?=EC=A0=80=20=E2=86=92=20=EA=B2=B9=20=EB=8B=A4=EC=8B=9C(?= =?UTF-8?q?=EC=B0=A8=EB=A1=80=20=EB=B2=88=ED=98=B8)=20=C2=B7=20=EB=8F=84?= =?UTF-8?q?=EC=97=BD=20=EA=B2=B9=20=EC=84=9C=EB=B2=84=20=EC=9E=90=EB=A6=84?= =?UTF-8?q?=20=C2=B7=20=EC=9C=84=EC=B9=98=20=EC=B9=B8=20=EC=A0=9C=EB=AA=A9?= =?UTF-8?q?=20=EC=84=9C=ED=81=B4=20=ED=95=98=EB=82=98=20=C2=B7=2010=20km?= =?UTF-8?q?=20=EB=B0=96=20=EC=95=8C=EB=A6=BC=20(PLAN=20604=20=C2=B7=20?= =?UTF-8?q?=EC=9A=94=EA=B5=AC=20475=20=C2=B7=20476-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 받기 끝 = 지형면 · 받은 범위 다 읽은 뒤 겹 판정(동시 읽기 = 옛 범위 판정 뿌리) · 되돌리기 · 다시하기도 영역 확인 - 겹 load(have) — 받은 범위 지문 · 위성 틀 바뀜 = 차례 +1 · 자료 · 받는 중 비움 · 켠 겹 모두 다시 · 옛 차례 답은 자료 · GL 에 안 씀 - 등고 · 세류 = 예정노선 겹 길(layers/sheet) · 도엽 겹 모두 서버가 받은 범위로 자른 사본(화면 상자 · 보관함 지문 걷음) - 서클 = 위치 칸 제목 오른쪽 하나 · 들어간 일 수(지형 받기 · 지형면 · 겹 · 임도망 올리기) · 부품 상자 원에 제목 줄 꼴(inline) 덧 · 지도 귀퉁이 서클 걷음 - 기준점 10 km 밖 점 = 위치 칸 알림 한 줄 - 시험 553_4 서클 줄 = 새 자리로 고침 Co-Authored-By: Claude Opus 5.5 --- D03_PlanAnalysis/D03_PlanAnalysis_Router.py | 46 +- .../D03_PlanAnalysis_UI_Layers.ts | 455 +++++++++--------- D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts | 50 +- .../D03_PlanAnalysis_UI_Terrain.ts | 68 ++- .../tester/test_475_plan_area_refresh.py | 131 +++++ resources/tester/test_553_4_de_parts.py | 2 +- ui_template/ui_template_progress.css | 26 + ui_template/ui_template_progress.ts | 17 +- 8 files changed, 528 insertions(+), 267 deletions(-) create mode 100644 resources/tester/test_475_plan_area_refresh.py diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Router.py b/D03_PlanAnalysis/D03_PlanAnalysis_Router.py index e1e7f351a..1267305bc 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Router.py +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Router.py @@ -290,12 +290,51 @@ async def _layer_root(project_id: UUID) -> tuple[Path, int, terrain.Box]: return root, epsg, box +SHEET_CLIP_KIND = "plan_sheet" +SHEET_CLIP_VERSION = 1 + + +def _clip_to_box(root: Path, source: Path, box: terrain.Box, epsg: int) -> Path: + """받은 범위(경위도 외접 상자)로 자른 사본(475) — 열쇠 = 사본 지문 + 범위 · 실패 = 통째.""" + from B04_PreProcess.B04_PreProcess_Engine_SheetClip import clip_collection + from common_util.common_util_derived_store import ( + derived_dir, + derived_key, + file_stamp, + prune_derived, + ) + + try: + bounds = layers._lonlat_bounds(box, epsg) + rect = (bounds["min_lon"], bounds["min_lat"], bounds["max_lon"], bounds["max_lat"]) + key = derived_key( + SHEET_CLIP_VERSION, source.name, file_stamp(source), [round(v, 1) for v in box] + ) + target = derived_dir(root, SHEET_CLIP_KIND) / f"{key}.json" + if target.is_file(): + return target + collection = json.loads(source.read_text(encoding="utf-8")) + text = json.dumps( + clip_collection(collection, rect), ensure_ascii=False, separators=(",", ":") + ) + target.parent.mkdir(parents=True, exist_ok=True) + temporary = target.with_suffix(f".{uuid4().hex}.tmp") + temporary.write_text(text, encoding="utf-8") + temporary.replace(target) + prune_derived(root, SHEET_CLIP_KIND, keep=2 * len(layers.SHEET_LAYERS)) + return target + except Exception as error: # 자르기 실패가 화면을 막으면 안 됨 + logger.warning("도엽 겹 받은 범위 자름 실패 (%s): %s", source.name, error) + return source + + @router.get("/{project_id}/plan/layers/sheet", response_model=None) async def plan_layer_sheet(project_id: UUID, key: str, request: Request) -> Response: - """도엽 겹 GeoJSON(하천중심선 · 도로중심선 …) — 없으면 지난 병합 도엽에서 풀어 냄.""" + """도엽 겹 GeoJSON(등고선 · 하천중심선 · 도로중심선 …) — 없으면 지난 병합 도엽에서 풀어 냄 · + 받은 범위로 자름(475 · 범위가 늘면 새 사본).""" from B04_PreProcess.B04_PreProcess_Router_GIS import _display_copy - root, _, _ = await _layer_root(project_id) + root, epsg, box = await _layer_root(project_id) try: path = await asyncio.to_thread(layers.ensure_sheet_layer, root, key) except KeyError as error: @@ -303,7 +342,8 @@ async def plan_layer_sheet(project_id: UUID, key: str, request: Request) -> Resp except FileNotFoundError as error: raise HTTPException(status_code=404, detail=str(error)) from error display = await asyncio.to_thread(_display_copy, path) - return cached_file_response(request, display, "application/geo+json") + clipped = await asyncio.to_thread(_clip_to_box, root, display, box, epsg) + return cached_file_response(request, clipped, "application/geo+json") @router.get("/{project_id}/plan/layers/cadastral", response_model=None) diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts index ec5041073..36da969cb 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts @@ -10,8 +10,9 @@ * 선 겹(단층 · 지질구조선 · 0차곡) · * 계류 100 m · 도로 300 m 띠 · 건물 · 지명). * 처음 켬 = 위성 · 등고선 · 세류 · 고른 값 = 화면 취향(`aislo:pref:d03.layers`) 기억. - * 받기 = 켤 때 · 받은 지형 범위(`have`)가 바뀌면 다시(596-4 · 등고 · 세류 주소에 범위 지문) · 지형을 안 받았으면 단추를 막음 · - * 받기 실패 = 그 겹만 꺼짐 + 알림. + * 받기 = 켤 때 · 받은 지형 범위 지문(`load(have)` · 페이지가 지형면을 다 읽은 뒤 넘김)이 바뀌면 차례 +1 · 켠 겹 모두 다시 · + * 옛 차례 답은 버림(475) · 범위는 서버가 받은 범위로 정함(등고 · 세류 · 도엽 겹 = 서버가 자름) · 지형을 안 받았으면 단추를 막음 · + * 받기 실패 = 그 겹만 꺼짐 + 알림 · 받는 일은 페이지 서클에 셈(`track`). * 그리기 차례(아래 → 위) = 위성(GL 조각만 · 옛 캔버스 = 위성 없음 · 590-2) → 음영기복 → 급경사 → 산사태위험지도 → 산림입지도 → 보호구역 → 띠 → 지적 → 물 면 → * 건물 → 등고선 → 도로 → 임도 짐작 → 올린 임도망도 → 세류 → 선 겹 → 산사태취약지역 → 지명 → 그은 선. * 겹 모두 = 바탕 캔버스(`base` · 582) · 등고선 = 줌 단계 색인 · 글 폭 기억. @@ -27,8 +28,6 @@ import { createLegend } from "@ui/ui_template_flow_legend"; import { createIconButton } from "@ui/ui_template_button"; import { createMapTools } from "@ui/ui_template_map_tools"; import { themeColor } from "@ui/ui_template_palette"; -import { API_BASE_URL } from "@config/config_frontend"; -import { fetchCachedJson } from "../A00_Common/b_asset_cache"; import { readByKey, writeByKey } from "../A00_Common/b_page_state"; import { fetchVWorldMeta, type VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { @@ -83,7 +82,6 @@ import { writeSlopeBands, } from "./D03_PlanAnalysis_UI_SlopeBands"; import type { PlanMap, PlanMapLayer } from "./D03_PlanAnalysis_UI_Map"; -import type { Vertex } from "./logic/D03_PlanAnalysis_Plan"; const PREF_KEY = "aislo:pref:d03.layers"; /** GL 글꼴 미리 받음 — 받기 전에 그린 글자는 대체 글꼴로 굳음(못 받아도 그림). */ @@ -93,8 +91,6 @@ const glFont = (weight: string, text: string): Promise => const CANVAS_PREF_KEY = "aislo:pref:d03.canvas"; /** 옛 캔버스 알림 — 위성 = GL 조각만(590-2). */ const NO_SATELLITE_NOTE = "옛 그림은 위성 바탕 없이 그림"; -/** 등고선을 받아 올 그은 선 둘레(m) — 받은 범위를 모를 때만. */ -const CONTOUR_PAD_M = 600; type DataKey = keyof Prepared; /** 겹 → 받는 자료(임도 짐작 = 도로 자료). */ @@ -106,8 +102,8 @@ export interface PlanLayers { legend: HTMLElement; /** 바탕 겹(아래 → 위 · 위성 뺌) — 그은 선 겹 아래에 둠. */ layers: PlanMapLayer[]; - /** 지형(위성 틀)을 다시 읽음 — 처음 · 지형 받은 뒤 · 받은 범위가 바뀌면 겹을 새로 받음. */ - load: () => Promise; + /** 지형(위성 틀)을 다시 읽음 — 처음 · 지형 받은 뒤 · 받은 범위(`have` · 지형면을 다 읽은 뒤 값)가 바뀌면 켠 겹 모두 새로. */ + load: (have: [number, number, number, number] | null) => Promise; /** 임도망도 올리기(왼쪽 「위치」 칸) — 받으면 그 겹을 켬 · 못 받으면 알림 + null. */ uploadRoads: (files: File[]) => Promise; /** 검증 창구 — 켬끔 · 받은 겹 · 겹별 수(지물 · 넓이 m²). */ @@ -117,23 +113,26 @@ export interface PlanLayers { counts: Record; /** GL 겹마다 화면에 그린 지물 수(보임만 · GL 끔 = 빈 것). */ gl: Record; + /** 겹이 받은 범위(마지막 `load` 값) · 차례 번호(475 검증). */ + area: [number, number, number, number] | null; + turn: number; }; } export function createPlanLayers(opts: { projectId: string; map: PlanMap; - /** 등고선 받을 범위를 정할 점(시점 · 종점 · 그은 선) — 받은 범위를 모를 때만. */ - points: () => Vertex[]; - /** 받은 지형 범위(작업 좌표계 m) — 바뀜 판정 · 등고 · 세류 주소 지문. */ - have: () => [number, number, number, number] | null; + /** 받는 일을 진행 서클에 셈(끝나면 뺌). */ + track: (job: Promise) => Promise; }): PlanLayers { - const { projectId, map } = opts; + const { projectId, map, track } = opts; const on = readPref(PREF_KEY); const data: Partial = {}; const pending = new Map>(); let meta: VWorldMeta | null = null; - let loadedHave = ""; + let area: [number, number, number, number] | null = null; + /** 차례 — 받은 범위 · 위성 틀이 바뀔 때 +1 · 옛 차례 답은 자료 · GL 에 안 씀. */ + let areaTurn = 0; /** 음영 GL 그림 — 그대로 · 위성 위 그늘(「곱하기」 꼴). */ let hillshade: { plain: GlImage; shade: GlImage } | null = null; const gl = createGlBase(projectId, (why) => { @@ -268,14 +267,15 @@ export function createPlanLayers(opts: { const slot = key === "satellite" ? key : dataKey(key); const running = pending.get(slot); if (running) return running; - const at = meta; - const job = fetchLayer(key, at) + const mine = areaTurn; + const job: Promise = track(fetchLayer(key, meta, mine)) .then(() => { - if (meta !== at) return; + if (mine !== areaTurn) return; sync(); map.redrawBase(); }) .catch((error: unknown) => { + if (mine !== areaTurn) return; const text = error instanceof Error ? error.message : String(error); for (const def of LAYERS) if (def.key === key || (def.key !== "satellite" && dataKey(def.key) === slot)) @@ -284,7 +284,7 @@ export function createPlanLayers(opts: { map.redraw(); showToast(`${LAYERS.find((def) => def.key === key)!.title} 받지 못함 — ${text}`, "error"); }) - .finally(() => pending.delete(slot)); + .finally(() => pending.get(slot) === job && pending.delete(slot)); pending.set(slot, job); return job; } @@ -292,7 +292,7 @@ export function createPlanLayers(opts: { /** 올리기 — 받으면 그 겹을 켜고 바로 그림(지형 받기 전이면 받은 뒤 그림) · 못 받으면 옛 것 그대로 + 서버 까닭 알림. */ async function uploadRoads(files: File[]): Promise { const at = meta; - const job = uploadPlanForestRoads(projectId, files).then((info) => { + const job = track(uploadPlanForestRoads(projectId, files)).then((info) => { if (meta !== at) return info; if (at) data.forestRoadFile = { layer: prepareLayer(info.collection, createNormalizer(at)), info }; @@ -328,209 +328,206 @@ export function createPlanLayers(opts: { } } - /** 받은 범위 지문 — 등고 · 세류 주소에 붙여 브라우저 보관함이 옛 범위를 안 줌. */ - const haveTag = (): string => (opts.have() ?? []).map((v) => Math.round(v)).join("_"); - const sheetLayer = (layer: string, extra = ""): Promise => - fetchCachedJson( - projectId, - `${API_BASE_URL}/projects/${projectId}/geojson?layer=${encodeURIComponent(layer)}${extra}&v=${haveTag()}`, - ); + /** 겹 하나 받기 — 자료 · GL 은 모아 두었다가 지금 차례일 때만 씀(옛 받은 범위 답 버림 · 475). */ + async function fetchLayer(key: PlanLayerKey, at: VWorldMeta, mine: number): Promise { + const got: Partial = {}; + const sets: Array> = []; + const put = (...args: Parameters): void => void sets.push(args); + let shade = null as typeof hillshade; + await fill(); + if (mine !== areaTurn) { + if (got.slope?.url) URL.revokeObjectURL(got.slope.url); + return; + } + if (got.slope && data.slope?.url) URL.revokeObjectURL(data.slope.url); + Object.assign(data, got); + if (shade) hillshade = shade; + for (const args of sets) gl.setData(...args); + // 칠하는 동안 범위가 바뀜 = 새 범위로 다시 + if (got.slope && got.slope.bands !== slopeBands) { + got.slope.bands = slopeBands; + void repaintSlope(); + } - async function fetchLayer(key: PlanLayerKey, at: VWorldMeta): Promise { - const normalizer = createNormalizer(at); - const prepare = (collection: GeoJsonCollection, labels?: string[]): PreparedLayer => - prepareLayer(collection, normalizer, labels); - const pick = ( - collection: GeoJsonCollection, - test: (props: Record) => boolean, - ): GeoJsonCollection => ({ - ...collection, - features: (collection.features ?? []).filter((feature) => test(feature.properties ?? {})), - }); - if (key === "contour") { - // 받은 범위 통째(모르면 그은 선 둘레) — 범위가 늘면 `load` 가 다시 받게 함 - const points = opts.points(); - const xs = points.map(([x]) => x); - const ys = points.map(([, y]) => y); - const bbox = ( - opts.have() ?? [ - Math.min(...xs) - CONTOUR_PAD_M, - Math.min(...ys) - CONTOUR_PAD_M, - Math.max(...xs) + CONTOUR_PAD_M, - Math.max(...ys) + CONTOUR_PAD_M, - ] - ).map((v) => v.toFixed(0)); - const [collection] = await Promise.all([ - sheetLayer("도엽_등고선", `&scope=route&bbox=${bbox.join(",")}`), - glFont("400", "0123456789-"), - ]); - data.contour = indexLayerLod(prepare(collection, ["등고수치"])); - gl.setData("contour", collection); - } else if (key === "stream") { - const collection = await sheetLayer("도엽_하천중심선"); - data.stream = [ - prepare(pick(collection, (props) => props["구분"] === "세류")), - prepare(pick(collection, (props) => props["구분"] !== "세류")), - ]; - gl.setData("stream", collection); - } else if (key === "cadastral") { - const collection = await fetchPlanCadastral(projectId); - data.cadastral = prepare(collection); - gl.setData("cadastral", collection); - } else if (key === "road" || key === "forestRoad") { - const collection = await fetchPlanSheetLayer(projectId, "도로중심선"); - data.road = { - all: prepare(collection), - small: prepare( - pick(collection, (props) => FOREST_ROAD_KINDS.has(String(props["도로구분"]))), - ), - }; - gl.setData("road", collection); - } else if (key === "forestRoadFile") { - const info = await fetchPlanForestRoads(projectId); - data.forestRoadFile = { layer: prepare(info.collection), info }; - gl.setData("forestRoadFile", info.collection); - } else if (key === "landslideZones") { - const info = await fetchPlanLandslideZones(projectId); - data.landslideZones = { parcels: prepare(info.parcels), info }; - gl.setData("landslideZones", { - features: [ - ...(info.parcels.features ?? []), - ...info.points.map((point) => ({ - type: "Feature", - properties: {}, - geometry: { type: "Point", coordinates: [point.lon, point.lat] }, - })), - ], - } as GeoJsonCollection); - if (info.missing.length) - showToast(`산사태취약지역 — ${info.missing.join(" · ")} 자료 없음`, "info"); - } 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 }; - const plain = glImage(raster); - if (key !== "hillshade") gl.setData(key, plain); - else if (plain) hillshade = { plain, shade: glImage(raster, shadeImage(image))! }; - } else if (key === "water") { - const parts = await Promise.all( - WATER_KEYS.map((name) => fetchPlanSheetLayer(projectId, name)), - ); - const water = { features: parts.flatMap((part) => part.features ?? []) }; - data.water = prepare(water); - gl.setData("water", water); - } else if (key === "building") { - const building = await fetchPlanSheetLayer(projectId, "건물"); - data.building = prepare(building); - gl.setData("building", building); - } else if (key === "zones") { - const zones = await fetchPlanZones(projectId); - const kinds = new Map(); - for (const kind of Object.keys(zones.summary)) - kinds.set(kind, prepare(pick(zones, (props) => props["kind"] === kind))); - data.zones = { kinds, summary: zones.summary }; - gl.setData( - "zones", - glTagged(zones, (props) => - themeColor(ZONE_COLORS[String(props["kind"])] ?? "--draw-warning", "#eab308"), - ), - ); - const failed = Object.values(zones.summary).filter((kind) => kind.failed); - if (failed.length) - showToast( - `보호 · 규제 구역 일부 받지 못함 — ${failed.map((kind) => kind.label).join(" · ")}`, - "error", - ); - } else if (key === "soils") { - const soils = await fetchPlanSoils(projectId); - data.soils = { - kinds: soils.summary.map((soil, i) => ({ - layer: prepare(pick(soils, (props) => props["code"] === soil.code)), - color: SOIL_COLORS[i % SOIL_COLORS.length], - })), - summary: soils.summary, - }; - const colorOf = new Map( - soils.summary.map((soil, i) => [soil.code, data.soils!.kinds[i].color]), - ); - gl.setData( - "soils", - glTagged(soils, (props) => - themeColor(colorOf.get(props["code"] as never) ?? "", "#facc15"), - ), - ); - } else if (key === "faults" || key === "lineaments" || key === "zeroOrder") { - const kind = LINE_LAYERS[key].kind; - // 0차곡 첫 셈 = 몇 초 — 늦으면 알림 - const slow = window.setTimeout( - () => showToast(`${LINE_LAYERS[key].label} 셈 중 — 처음 한 번만`, "info"), - 600, - ); - try { - const got = kind - ? await fetchPlanLines(projectId, kind).then((lines) => ({ - collection: lines, - ...lines.summary, - })) - : await fetchPlanZeroOrder(projectId); - data[key] = { - layer: prepare(got.collection), - count: got.count, - length_m: got.length_m, + async function fill(): Promise { + const normalizer = createNormalizer(at); + const prepare = (collection: GeoJsonCollection, labels?: string[]): PreparedLayer => + prepareLayer(collection, normalizer, labels); + const pick = ( + collection: GeoJsonCollection, + test: (props: Record) => boolean, + ): GeoJsonCollection => ({ + ...collection, + features: (collection.features ?? []).filter((feature) => test(feature.properties ?? {})), + }); + if (key === "contour") { + // 받은 범위로 서버가 자름 — 범위가 늘면 `load` 가 다시 받게 함 + const [collection] = await Promise.all([ + fetchPlanSheetLayer(projectId, "등고선"), + glFont("400", "0123456789-"), + ]); + got.contour = indexLayerLod(prepare(collection, ["등고수치"])); + put("contour", collection); + } else if (key === "stream") { + const collection = await fetchPlanSheetLayer(projectId, "하천중심선"); + got.stream = [ + prepare(pick(collection, (props) => props["구분"] === "세류")), + prepare(pick(collection, (props) => props["구분"] !== "세류")), + ]; + put("stream", collection); + } else if (key === "cadastral") { + const collection = await fetchPlanCadastral(projectId); + got.cadastral = prepare(collection); + put("cadastral", collection); + } else if (key === "road" || key === "forestRoad") { + const collection = await fetchPlanSheetLayer(projectId, "도로중심선"); + got.road = { + all: prepare(collection), + small: prepare( + pick(collection, (props) => FOREST_ROAD_KINDS.has(String(props["도로구분"]))), + ), }; - gl.setData(key, got.collection); - } finally { - window.clearTimeout(slow); - } - } else if (key === "bands") { - const info = await fetchPlanBands(projectId); - data.bands = { - stream: prepare(info.stream.collection), - road: prepare(info.road.collection), - info, - }; - const band = (name: "stream" | "road", fallback: string): GeoJsonCollection => - glTagged(info[name].collection, () => themeColor(`--draw-map-band-${name}`, fallback), { - _band: name, + put("road", collection); + } else if (key === "forestRoadFile") { + const info = await fetchPlanForestRoads(projectId); + got.forestRoadFile = { layer: prepare(info.collection), info }; + put("forestRoadFile", info.collection); + } else if (key === "landslideZones") { + const info = await fetchPlanLandslideZones(projectId); + got.landslideZones = { parcels: prepare(info.parcels), info }; + put("landslideZones", { + features: [ + ...(info.parcels.features ?? []), + ...info.points.map((point) => ({ + type: "Feature", + properties: {}, + geometry: { type: "Point", coordinates: [point.lon, point.lat] }, + })), + ], + } as GeoJsonCollection); + if (info.missing.length) + showToast(`산사태취약지역 — ${info.missing.join(" · ")} 자료 없음`, "info"); + } 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); + const paintMs = Math.round(performance.now() - started); + got.slope = { raster, values, bands, ...painted, paintMs }; + put("slope", glImage(raster, painted.url)); + } else if (key === "hillshade" || key === "landslide") { + const raster = await fetchPlanRaster(projectId, key); + const image = await loadImage(raster.image); + got[key] = { raster, image }; + const plain = glImage(raster); + if (key !== "hillshade") put(key, plain); + else if (plain) shade = { plain, shade: glImage(raster, shadeImage(image))! }; + } else if (key === "water") { + const parts = await Promise.all( + WATER_KEYS.map((name) => fetchPlanSheetLayer(projectId, name)), + ); + const water = { features: parts.flatMap((part) => part.features ?? []) }; + got.water = prepare(water); + put("water", water); + } else if (key === "building") { + const building = await fetchPlanSheetLayer(projectId, "건물"); + got.building = prepare(building); + put("building", building); + } else if (key === "zones") { + const zones = await fetchPlanZones(projectId); + const kinds = new Map(); + for (const kind of Object.keys(zones.summary)) + kinds.set(kind, prepare(pick(zones, (props) => props["kind"] === kind))); + got.zones = { kinds, summary: zones.summary }; + put( + "zones", + glTagged(zones, (props) => + themeColor(ZONE_COLORS[String(props["kind"])] ?? "--draw-warning", "#eab308"), + ), + ); + const failed = Object.values(zones.summary).filter((kind) => kind.failed); + if (failed.length) + showToast( + `보호 · 규제 구역 일부 받지 못함 — ${failed.map((kind) => kind.label).join(" · ")}`, + "error", + ); + } else if (key === "soils") { + const soils = await fetchPlanSoils(projectId); + got.soils = { + kinds: soils.summary.map((soil, i) => ({ + layer: prepare(pick(soils, (props) => props["code"] === soil.code)), + color: SOIL_COLORS[i % SOIL_COLORS.length], + })), + summary: soils.summary, + }; + const colorOf = new Map( + soils.summary.map((soil, i) => [soil.code, got.soils!.kinds[i].color]), + ); + put( + "soils", + glTagged(soils, (props) => + themeColor(colorOf.get(props["code"] as never) ?? "", "#facc15"), + ), + ); + } else if (key === "faults" || key === "lineaments" || key === "zeroOrder") { + const kind = LINE_LAYERS[key].kind; + // 0차곡 첫 셈 = 몇 초 — 늦으면 알림 + const slow = window.setTimeout( + () => showToast(`${LINE_LAYERS[key].label} 셈 중 — 처음 한 번만`, "info"), + 600, + ); + try { + const found = kind + ? await fetchPlanLines(projectId, kind).then((lines) => ({ + collection: lines, + ...lines.summary, + })) + : await fetchPlanZeroOrder(projectId); + got[key] = { + layer: prepare(found.collection), + count: found.count, + length_m: found.length_m, + }; + put(key, found.collection); + } finally { + window.clearTimeout(slow); + } + } else if (key === "bands") { + const info = await fetchPlanBands(projectId); + got.bands = { + stream: prepare(info.stream.collection), + road: prepare(info.road.collection), + info, + }; + const band = (name: "stream" | "road", fallback: string): GeoJsonCollection => + glTagged(info[name].collection, () => themeColor(`--draw-map-band-${name}`, fallback), { + _band: name, + }); + put("bands", { + features: [ + ...(band("road", "#facc15").features ?? []), + ...(band("stream", "#22d3ee").features ?? []), + ], }); - gl.setData("bands", { - features: [ - ...(band("road", "#facc15").features ?? []), - ...(band("stream", "#22d3ee").features ?? []), - ], - }); - } else { - const collection = await fetchPlanSheetLayer(projectId, "지명"); - data.place = (collection.features ?? []).flatMap((feature) => { - const geometry = feature.geometry; - const name = feature.properties?.["명칭"]; - if (geometry?.type !== "Point" || typeof name !== "string" || !name) return []; - const [lon, lat] = geometry.coordinates as number[]; - return [{ lon, lat, name }]; - }); - await glFont("600", data.place.map((place) => place.name).join("")); - gl.setData("place", { - features: data.place.map(({ lon, lat, name }) => ({ - type: "Feature", - properties: { name }, - geometry: { type: "Point", coordinates: [lon, lat] }, - })), - } as GeoJsonCollection); + } else { + const collection = await fetchPlanSheetLayer(projectId, "지명"); + got.place = (collection.features ?? []).flatMap((feature) => { + const geometry = feature.geometry; + const name = feature.properties?.["명칭"]; + if (geometry?.type !== "Point" || typeof name !== "string" || !name) return []; + const [lon, lat] = geometry.coordinates as number[]; + return [{ lon, lat, name }]; + }); + await glFont("600", got.place.map((place) => place.name).join("")); + put("place", { + features: got.place.map(({ lon, lat, name }) => ({ + type: "Feature", + properties: { name }, + geometry: { type: "Point", coordinates: [lon, lat] }, + })), + } as GeoJsonCollection); + } } } @@ -555,16 +552,16 @@ export function createPlanLayers(opts: { }, ]; - async function load(): Promise { + async function load(have: [number, number, number, number] | null): Promise { const next = await fetchVWorldMeta(projectId, "satellite").catch(() => null); - // 바뀜 = 위성 틀 또는 받은 범위(같은 도엽 안에서 늘어도 겹을 새로 · 596-4) - const have = JSON.stringify(opts.have()); - const changed = - have !== loadedHave || - JSON.stringify(next && [next.x_min, next.x_max, next.y_min, next.y_max]) !== - JSON.stringify(meta && [meta.x_min, meta.x_max, meta.y_min, meta.y_max]); - [meta, loadedHave] = [next, have]; + // 바뀜 = 받은 범위 지문 또는 위성 틀 → 차례 +1 · 자료 · 받는 중 비움 · 켠 겹 모두 다시(475 · 596-4) + const frame = (m: VWorldMeta | null) => + JSON.stringify(m && [m.x_min, m.x_max, m.y_min, m.y_max]); + const changed = JSON.stringify(have) !== JSON.stringify(area) || frame(next) !== frame(meta); + [meta, area] = [next, have]; if (changed) { + areaTurn += 1; + pending.clear(); for (const key of Object.keys(data)) delete data[key as DataKey]; hillshade = null; for (const def of LAYERS) @@ -595,6 +592,8 @@ export function createPlanLayers(opts: { : gl.map()!.queryRenderedFeatures({ layers: [layer.id] }).length, ]), ), + area, + turn: areaTurn, }), }; } diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts index 4301c0d71..cc46efa74 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts @@ -4,7 +4,10 @@ * * 메인 = 평면(바탕 겹 켜고 끔 — 위성 · 등고선 · 세류 · 지적 · 도로 · 급경사 + 그은 선 · 측점 · 연도 구분) + 도구 여섯. * 좌 = 지형(조용히 받기 — 처음 = 등록 기준점 둘레 1 km · 지점 · 선 · 지도 보기 가운데가 받은 범위 밖이면 이어 받음 · - * 단추 · 막는 창 없음 · 진행 = 이 칸 작은 막대 · 실패만 [다시 받기]) · 지점(목록 · 핀 · 우클릭 메뉴 = + * 단추 · 막는 창 없음 · 진행 = 위치 칸 제목 오른쪽 서클(받는 일 모두 · 604-3) · 실패만 [다시 받기] · + * 영역 루틴(475) = 사건(지점 · 선 · 위치 · 되돌리기 · 다시하기 · 처음 열기) → `ensureCovered` → 받기 → + * 지형면 · 받은 범위 먼저 → 겹(`backdrop.load(have)` · 지문 바뀜 = 켠 겹 모두 다시) · + * 기준점 10 km 밖 = 위치 칸 알림 · 지운 지점 영역은 그대로) · 지점(목록 · 핀 · 우클릭 메뉴 = * `D03_PlanAnalysis_UI_Points`) · 그리기([긋기] · 제약 칸 · 연도 = 도구줄 「연도 마커」 끌어 놓기 585-3) · * 구역 · 연도(해 · 선 · 구간 줄 = 고르기 · 고른 줄 밝힘) · [저장] [초기화]. * 고른 구간 속성(연도 · 넓이 · 방향 · 지움) = 지도 위 속성 판 `D03_PlanAnalysis_UI_Props`(585-2). @@ -64,7 +67,9 @@ import { createPlanSite } from "./D03_PlanAnalysis_UI_Site"; import { aroundSite, boxOfLonLat, + createWorkCircle, errorText, + farFromSite, nearSite, watchTerrain, type Site, @@ -212,16 +217,11 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { on3d: open3d, onMeasure: () => setMeasure(!measuring), }); - const backdrop = createPlanLayers({ - projectId, - map, - points: () => [...points, ...allVertices()], - have: () => have, - }); - // 받기 진행 = 지도 오른쪽 아래 작은 서클(받을 때만 · 지도 조작 안 막음 596-3) - const circle = createProgressCircle({ size: 40, corner: true }); - circle.root.hidden = true; - map.root.append(backdrop.bar, backdrop.legend, props.panel, circle.root); + // 받기 진행 = 위치 칸 제목 오른쪽 서클 하나(받는 일 수 · 0 = 숨김 · 지도 조작 안 막음 604-3) + const circle = createProgressCircle({ size: 16, inline: true }); + const work = createWorkCircle(circle); + const backdrop = createPlanLayers({ projectId, map, track: work.track }); + map.root.append(backdrop.bar, backdrop.legend, props.panel); const main = document.createElement("div"); main.className = "d03-plan"; main.append(map.root); @@ -235,6 +235,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { onUpload: (files) => void uploadRoads(files), }); const siteCard = createPanelCard("위치", false); + siteCard.firstElementChild!.append(circle.root); siteCard.append(...siteBox.parts); const pointCard = createPanelCard("지점", false); pointCard.append(marks.panel); @@ -309,6 +310,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { ({ network, points, pointNames, site } = last); keepDraft(); refresh(); + void ensureCovered(); // 영역 루틴 — 되돌린 지점 · 선도 받은 범위 밖이면 받음 } function redo(): void { @@ -318,6 +320,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { ({ network, points, pointNames, site } = next); keepDraft(); refresh(); + void ensureCovered(); } function setMeasure(on: boolean): void { @@ -371,9 +374,11 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { props.render(); pieces.render(); marks.render(); + const far = epsg ? farFromSite(epsg, site, [...points, ...allVertices()]) : 0; + const farNote = far ? `기준점 10 km 밖 점 ${far}곳 — 지형 안 받음 · 위치를 옮기세요` : ""; siteBox.render({ address: site?.address ?? null, - note: failed ? `지형 받기 실패 — ${failed}` : note, + note: [failed ? `지형 받기 실패 — ${failed}` : note, farNote].filter(Boolean).join(" · "), retry: !busy && !!failed, editable, }); @@ -446,12 +451,14 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { if (info) fitTo([...around(site), ...boxOfLonLat(epsg, info.bounds)]); } - /** 도는 받기를 지켜보고 끝나면 지형 · 바탕을 다시 읽음. */ + /** 도는 받기를 지켜보고 끝나면 지형면 · 받은 범위를 먼저 다 읽은 뒤 겹(475 — 동시면 겹이 옛 범위로 판정). */ async function follow(): Promise { const job = await watch(); if (!job || !root.isConnected) return; failed = job.error; - await Promise.all([loadSurface(), backdrop.load()]); + await loadSurface(); + refresh(); + await backdrop.load(have); refresh(); await ensureCovered(); // 받는 동안 옮긴 지점 · 선 · 보기 } @@ -460,18 +467,22 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { watchTerrain({ projectId, alive: () => root.isConnected, - circle, + work, setBusy: (on) => { busy = on; refresh(); }, }); + /** 지형면 · 받은 범위(have) — 서클에 셈. */ async function loadSurface(): Promise { try { - surface = await fetchPlanSurface(projectId); - const extent = await fetchTerrainExtent(projectId, null, []); - have = extent.have_m; + [surface, have] = await work.track( + Promise.all([ + fetchPlanSurface(projectId), + fetchTerrainExtent(projectId, null, []).then((extent) => extent.have_m), + ]), + ); } catch (error) { showToast(`지형면을 읽지 못했습니다 — ${errorText(error)}`, "error"); } @@ -565,6 +576,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { base: () => points, have: () => have, busy: () => busy, + work: work.count, site: () => site, pickSite, lineNames: () => lineNames(network.lines), @@ -628,7 +640,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { refresh(); await loadSurface(); refresh(); - await backdrop.load(); + await backdrop.load(have); const job = (await fetchPlanProgress(projectId)).terrain; if (job.running) { ready = true; diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Terrain.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Terrain.ts index e0626d79d..acca4ee0b 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Terrain.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Terrain.ts @@ -1,7 +1,8 @@ /* ============================================================================= * D03_PlanAnalysis_UI_Terrain.ts - * D 예정노선 받기 이음(PLAN 597 · 요구 463-7) — 페이지에서 뗀 조각: 기준점 꼴 · 둘레 상자 · 받기 지켜보기(서클). - * 상태는 페이지가 쥐고 여기는 인자로만 받음(화면 요소 만들기 없음 · 서클은 부품 그대로). + * D 예정노선 받기 이음(PLAN 597 · 604 · 요구 463-7 · 475 · 476-2) — 페이지에서 뗀 조각: 기준점 꼴 · 둘레 상자 · + * 받기 지켜보기 · 진행 서클 하나(위치 칸 제목 오른쪽 · 들어간 일 수 = 지형 받기 · 겹 받기 · 처음 열기 · 임도망 올리기). + * 상태는 페이지가 쥐고 여기는 인자로만 받음(서클은 부품 그대로). * ========================================================================== */ import { showToast } from "@ui/ui_template_elements"; @@ -56,27 +57,72 @@ export function nearSite(epsg: number, at: Site | null, list: Vertex[]): Vertex[ ); } +/** 기준점에서 `COVER_MAX_M` 넘는 점 수 — 위치 칸 알림(조용히 안 받음 475). */ +export const farFromSite = (epsg: number, at: Site | null, list: Vertex[]): number => + list.length - nearSite(epsg, at, list).length; + +/** 진행 서클 하나 · 들어간 일 수(0 = 숨김) — 지형 받기 = 진행률 · 나머지 일 = 돎. */ +export interface WorkCircle { + /** 일 하나를 셈(끝나면 뺌 · 답은 그대로). */ + track: (job: Promise) => Promise; + /** 지형 받기 진행(없음 = 끝). */ + terrain: (state: { ratio: number; text: string } | null) => void; + /** 들어간 일 수(검증 창구). */ + count: () => number; +} + +export function createWorkCircle(circle: ReturnType): WorkCircle { + let count = 0; + let terrain: { ratio: number; text: string } | null = null; + const show = (): void => { + circle.root.hidden = count === 0; + if (terrain) circle.set(terrain.ratio, "지형"); + else circle.set(null, count > 1 ? `받는 중 ${count}` : "받는 중"); + circle.root.title = terrain + ? `지형 받기 ${Math.round(terrain.ratio * 100)}% · ${terrain.text}` + : `겹 · 파일 받는 중 ${count}`; + }; + show(); + return { + track(job) { + count += 1; + show(); + return job.finally(() => { + count -= 1; + show(); + }); + }, + terrain(state) { + terrain = state; + show(); + }, + count: () => count, + }; +} + export interface WatchParams { projectId: string; alive: () => boolean; - circle: ReturnType; + work: WorkCircle; /** 받는 중 켬끔(페이지 `busy`) 뒤 화면 갱신. */ setBusy: (on: boolean) => void; } -/** 지형 받기를 끝날 때까지 지켜봄 — 진행 = 지도 귀퉁이 서클 · 끊김(서버 다시 켜짐 = 끝 아닌데 멎음 · +/** 지형 받기를 끝날 때까지 지켜봄 — 진행 = 위치 칸 서클 · 끊김(서버 다시 켜짐 = 끝 아닌데 멎음 · * 진행을 못 읽음) = 실패 꼴 + [다시 받기]. */ -export async function watchTerrain(params: WatchParams): Promise { - const { projectId, alive, circle, setBusy } = params; +export function watchTerrain(params: WatchParams): Promise { + return params.work.track(watching(params)); +} + +async function watching(params: WatchParams): Promise { + const { projectId, alive, work, setBusy } = params; setBusy(true); - circle.set(null, "지형"); - circle.root.hidden = false; + work.terrain({ ratio: 0, text: "" }); try { for (;;) { if (!alive()) return null; const job = (await fetchPlanProgress(projectId)).terrain; - circle.set(job.percent / 100, `지형 ${job.message ?? ""}`.trim()); - circle.root.title = `지형 받기 ${job.percent}% · ${job.message ?? ""}`; + work.terrain({ ratio: job.percent / 100, text: job.message ?? "" }); if (!job.running) { const cut = !job.error && (job.interrupted || job.percent < 100); if (job.error) showToast(job.error, "error"); @@ -88,7 +134,7 @@ export async function watchTerrain(params: WatchParams): Promise showToast(`진행을 읽지 못했습니다 — ${errorText(error)}`, "error"); return { running: false, percent: 0, message: null, error: INTERRUPTED }; } finally { - circle.root.hidden = true; + work.terrain(null); setBusy(false); } } diff --git a/resources/tester/test_475_plan_area_refresh.py b/resources/tester/test_475_plan_area_refresh.py new file mode 100644 index 000000000..e1ec365d3 --- /dev/null +++ b/resources/tester/test_475_plan_area_refresh.py @@ -0,0 +1,131 @@ +"""D 예정노선 영역 루틴(PLAN 604 · 요구 475 · 476-2) — 도엽 겹 = 서버가 받은 범위로 자름(범위 바뀜 = 새 사본) · +받기 끝 = 지형면 · 받은 범위 먼저 → 겹(지문 바뀜 = 차례 +1 · 옛 답 버림) · 되돌리기 · 다시하기도 영역 확인 · +기준점 10 km 밖 = 위치 칸 알림 · 서클 = 위치 칸 제목 오른쪽 하나(들어간 일 수). + +./venv/Scripts/python.exe -m pytest resources/tester/test_475_plan_area_refresh.py -q +""" + +import json +from pathlib import Path + +from common_util.common_util_crs import tm_to_lonlat +from D03_PlanAnalysis import D03_PlanAnalysis_Router as router + +ROOT = Path(__file__).resolve().parents[2] +D03 = ROOT / "D03_PlanAnalysis" +EPSG = 5186 +BOX_A = (206000.0, 467000.0, 208000.0, 469000.0) +BOX_B = (204000.0, 466300.0, 208000.0, 469000.0) # 서쪽 2 km · 남쪽 0.7 km 늚 + + +def _src(name: str) -> str: + return (D03 / name).read_text(encoding="utf-8") + + +def _line(x: float, y0: float, y1: float) -> dict: + coords = [list(tm_to_lonlat(x, y, EPSG)) for y in (y0, y1)] + return { + "type": "Feature", + "properties": {"x": x}, + "geometry": {"type": "LineString", "coordinates": coords}, + } + + +def _xs(path: Path) -> set[float]: + return {f["properties"]["x"] for f in json.loads(path.read_text("utf-8"))["features"]} + + +# ── 서버 — 도엽 겹 받은 범위 자름 ── +def test_sheet_clipped_to_received_box(tmp_path) -> None: + source = tmp_path / "도엽_등고선.display.geojson" + lines = [_line(x, 465000.0, 470000.0) for x in (203000.0, 205000.0, 207000.0, 209000.0)] + source.write_text(json.dumps({"type": "FeatureCollection", "features": lines}), "utf-8") + a = router._clip_to_box(tmp_path, source, BOX_A, EPSG) + assert a != source and _xs(a) == {207000.0} # 옛 범위 = 가운데 한 가닥 + b = router._clip_to_box(tmp_path, source, BOX_B, EPSG) + assert b != a and _xs(b) == {205000.0, 207000.0} # 서쪽 2 km 띠 가닥이 들어옴 + assert router._clip_to_box(tmp_path, source, BOX_B, EPSG) == b # 같은 범위 = 같은 저장본 + for feature in json.loads(b.read_text("utf-8"))["features"]: # 잘린 꼴 ⊆ 받은 범위(경위도 외접) + for lon, lat in feature["geometry"]["coordinates"]: + west, south = tm_to_lonlat(BOX_B[0] - 50, BOX_B[1] - 50, EPSG) + east, north = tm_to_lonlat(BOX_B[2] + 50, BOX_B[3] + 50, EPSG) + assert west <= lon <= east and south <= lat <= north + + +def test_sheet_clip_failure_falls_back(tmp_path) -> None: + broken = tmp_path / "깨짐.geojson" + broken.write_text("{", "utf-8") + assert router._clip_to_box(tmp_path, broken, BOX_A, EPSG) == broken + + +def test_sheet_endpoint_clips() -> None: + src = (D03 / "D03_PlanAnalysis_Router.py").read_text("utf-8") + body = src[src.index("async def plan_layer_sheet") :] + body = body[: body.index("@router")] + assert "_clip_to_box, root, display, box, epsg" in body + + +# ── 화면 — 받기 끝 차례 · 차례 번호 · 등고 · 세류 길 ── +def test_follow_reads_surface_before_layers() -> None: + page = _src("D03_PlanAnalysis_UI_Page.ts") + follow = page[page.index("async function follow") :] + follow = follow[: follow.index("\n }\n")] + assert "Promise.all([loadSurface(), backdrop.load" not in page + assert follow.index("await loadSurface();") < follow.index("await backdrop.load(have);") + assert follow.index("await backdrop.load(have);") < follow.index("await ensureCovered();") + assert page.count("await backdrop.load(have);") == 2 # 받기 끝 · 처음 열기 + + +def test_undo_redo_check_area() -> None: + page = _src("D03_PlanAnalysis_UI_Page.ts") + for name in ("function undo()", "function redo()"): + body = page[page.index(name) :] + assert "void ensureCovered();" in body[: body.index("\n }\n")], name + + +def test_layers_turn_drops_old_answers() -> None: + layers = _src("D03_PlanAnalysis_UI_Layers.ts") + load = layers[layers.index("async function load(have") :] + load = load[: load.index("\n }\n")] + assert "JSON.stringify(have) !== JSON.stringify(area)" in load + assert "areaTurn += 1;" in load and "pending.clear();" in load + fetch = layers[layers.index("async function fetchLayer") :] + assert fetch.index("if (mine !== areaTurn)") < fetch.index("Object.assign(data, got);") + fill = fetch[fetch.index("async function fill") : fetch.index("const canvasLayers")] + assert "data." not in fill and "gl.setData(" not in fill # 모아 두었다가 지금 차례만 씀 + assert ( + "pending.get(slot) === job && pending.delete(slot)" in layers + ) # 옛 일이 새 기다림을 안 지움 + # 등고 · 세류 = 예정노선 겹 길(서버가 받은 범위로 자름) · 화면 상자 · 보관함 지문 걷음 + assert 'fetchPlanSheetLayer(projectId, "등고선")' in layers + assert 'fetchPlanSheetLayer(projectId, "하천중심선")' in layers + for gone in ("/geojson?layer=", "CONTOUR_PAD_M", "haveTag", "opts.have"): + assert gone not in layers, gone + + +# ── 서클 · 10 km 알림 ── +def test_one_circle_in_site_title() -> None: + page = _src("D03_PlanAnalysis_UI_Page.ts") + assert "createProgressCircle({ size: 16, inline: true })" in page + assert "siteCard.firstElementChild!.append(circle.root);" in page + assert ( + "corner: true" not in page + and "circle.root);\n" not in page.split("map.root.append(")[1][:80] + ) + assert "createPlanLayers({ projectId, map, track: work.track })" in page + terrain = _src("D03_PlanAnalysis_UI_Terrain.ts") + assert "params.work.track(watching(params))" in terrain # 지형 받기도 같은 셈 + layers = _src("D03_PlanAnalysis_UI_Layers.ts") + assert "track(fetchLayer(" in layers and "track(uploadPlanForestRoads(" in layers + progress = (ROOT / "ui_template" / "ui_template_progress.ts").read_text("utf-8") + assert "ui-progress-circle--inline" in progress + css = (ROOT / "ui_template" / "ui_template_progress.css").read_text("utf-8") + assert ".ui-progress-circle--inline {" in css + + +def test_far_note() -> None: + page = _src("D03_PlanAnalysis_UI_Page.ts") + assert "farFromSite(epsg, site, [...points, ...allVertices()])" in page + assert "기준점 10 km 밖 점" in page + terrain = _src("D03_PlanAnalysis_UI_Terrain.ts") + assert "list.length - nearSite(epsg, at, list).length" in terrain diff --git a/resources/tester/test_553_4_de_parts.py b/resources/tester/test_553_4_de_parts.py index 7090fc893..a5a393273 100644 --- a/resources/tester/test_553_4_de_parts.py +++ b/resources/tester/test_553_4_de_parts.py @@ -35,7 +35,7 @@ def test_d03_parts(): tools = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts") layers = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts") assert 'createElement("progress")' not in ts and "createProgressBar(" not in ts - assert "createProgressCircle({ size: 40, corner: true })" in ts # 596-3 받기 = 지도 귀퉁이 서클 + assert "createProgressCircle({ size: 16, inline: true })" in ts # 604-3 받기 = 위치 칸 제목 서클 site = _src( "D03_PlanAnalysis/D03_PlanAnalysis_UI_Site.ts" ) # 596-2 위치 칸 · 596-5 등록 찾기 = 같은 부품 diff --git a/ui_template/ui_template_progress.css b/ui_template/ui_template_progress.css index 3cb312117..e005cb187 100644 --- a/ui_template/ui_template_progress.css +++ b/ui_template/ui_template_progress.css @@ -28,6 +28,32 @@ flex-direction: row-reverse; } +/* 제목 줄 안 — 패널 카드 제목 오른쪽(캐럿 왼쪽) · 제목 높이 그대로(음수 margin) · 글은 원 왼쪽 한 줄 */ +.ui-progress-circle--inline { + --ui-progress-size: 16px; + flex-direction: row-reverse; + gap: var(--spacing-4); + min-width: 0; + margin: -0.4em 0 -0.4em auto; +} + +.ui-progress-circle--inline :is(.ui-progress-circle__track, .ui-progress-circle__bar) { + stroke-width: 14; +} + +.ui-progress-circle--inline .ui-progress-circle__percent { + display: none; +} + +.ui-progress-circle--inline .ui-progress-circle__label { + overflow: hidden; + padding: 0; + background: none; + font-weight: var(--font-weight-regular); + text-overflow: ellipsis; + white-space: nowrap; +} + .ui-progress-circle__dial { position: relative; width: var(--ui-progress-size); diff --git a/ui_template/ui_template_progress.ts b/ui_template/ui_template_progress.ts index d2fe401d3..3fc506e6e 100644 --- a/ui_template/ui_template_progress.ts +++ b/ui_template/ui_template_progress.ts @@ -5,6 +5,7 @@ import "./ui_template_progress.css"; * 부품 「진행 막대」(552-3 · 목록 30) — 막대 · 쌓임 막대 · 원. * 꼴 = 막대(`createProgressBar` · 값 하나) · 쌓임 막대(`parts` · 갈래 셋 accent · soft · warn) · 원(`createProgressCircle` · 아래) * · 원 자리 = 흐름(기본) · 가운데 덮음(`overlay`) · 오른쪽 아래 귀퉁이(`corner` · 글은 원 왼쪽) + * · 제목 줄 안(`inline` · 카드 제목 오른쪽 · 캐럿 왼쪽 · 작은 원 + 글 한 줄 · 진행률은 글 끝에) * 크기 = 막대 굵기 6 · 8(`thick` → data-size) · 원 지름 72(기본) * 상태 = 기본 · 오류(`.is-error` · 막대가 위험색) · 판정(`tone`: warn · bad) * 쓰는 법 = `createProgressBar({ value: 0.4, label: "40 %" })` · `bar.set(0.8)` · `createProgressBar({ parts: [{ value: 3, tone: "accent" }, { value: 1, tone: "warn" }] })` @@ -21,6 +22,7 @@ const SVG_NS = "http://www.w3.org/2000/svg"; /** viewBox 기준 반지름 — 실제 크기는 CSS(--ui-progress-size)로 정한다. */ const RADIUS = 42; const CIRCUMFERENCE = 2 * Math.PI * RADIUS; +const clamp01 = (v: number): number => Math.min(1, Math.max(0, v)); export interface ProgressCircleOptions { /** 지름(px). 기본 72(공통 로딩 스피너와 같은 무게감). */ @@ -31,6 +33,8 @@ export interface ProgressCircleOptions { overlay?: boolean; /** 컨테이너 오른쪽 아래 귀퉁이에 작게 띄운다(가운데를 안 가림 · 컨테이너는 position: relative). */ corner?: boolean; + /** 제목 줄 안(패널 카드 제목 오른쪽 · 캐럿 왼쪽) — 작은 원 + 글 한 줄 · 진행률은 글 끝에 붙음. */ + inline?: boolean; } export interface ProgressCircleHandle { @@ -46,7 +50,8 @@ export function createProgressCircle(options: ProgressCircleOptions = {}): Progr root.className = "ui-progress-circle" + (options.overlay ? " ui-progress-circle--overlay" : "") + - (options.corner ? " ui-progress-circle--corner" : ""); + (options.corner ? " ui-progress-circle--corner" : "") + + (options.inline ? " ui-progress-circle--inline" : ""); root.setAttribute("role", "status"); root.setAttribute("aria-live", "polite"); if (options.size) root.style.setProperty("--ui-progress-size", `${options.size}px`); @@ -86,15 +91,19 @@ export function createProgressCircle(options: ProgressCircleOptions = {}): Progr dial.append(spin, percent); root.append(dial, label); + let text = options.label ?? ""; function set(ratio: number | null, nextLabel?: string): void { - if (nextLabel !== undefined) label.textContent = nextLabel; + if (nextLabel !== undefined) text = label.textContent = nextLabel; + if (options.inline) + label.textContent = + ratio === null ? text : `${text} ${Math.round(clamp01(ratio) * 100)}%`.trim(); if (ratio === null) { // 진행률 미상 — 4분의 1 호만 남긴다(회전은 껍데기가 늘 맡는다). bar.setAttribute("stroke-dashoffset", String(CIRCUMFERENCE * 0.75)); percent.textContent = ""; return; } - const clamped = Math.min(1, Math.max(0, ratio)); + const clamped = clamp01(ratio); bar.setAttribute("stroke-dashoffset", String(CIRCUMFERENCE * (1 - clamped))); percent.textContent = `${Math.round(clamped * 100)}%`; } @@ -126,8 +135,6 @@ export interface ProgressBarHandle { set: (value: number, label?: string) => void; } -const clamp01 = (v: number): number => Math.min(1, Math.max(0, v)); - /** 진행 막대 — 값 하나 또는 쌓임 */ export function createProgressBar(opts: ProgressBarOptions = {}): ProgressBarHandle { const root = document.createElement("div");