merge(D 예정노선): dev 받기 — 604 영역 루틴(fetchLayer · areaTurn · load(have) · track) 과 605 올린 임도망 묶음(applyRoads · deleteRoad · pickRoad) 함께 살림 · 옛 haveTag · sheetLayer 뺌 (PLAN 605 · 요구 475 · 476)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -290,12 +290,51 @@ async def _layer_root(project_id: UUID) -> tuple[Path, int, terrain.Box]:
|
|||||||
return root, epsg, 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)
|
@router.get("/{project_id}/plan/layers/sheet", response_model=None)
|
||||||
async def plan_layer_sheet(project_id: UUID, key: str, request: Request) -> Response:
|
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
|
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:
|
try:
|
||||||
path = await asyncio.to_thread(layers.ensure_sheet_layer, root, key)
|
path = await asyncio.to_thread(layers.ensure_sheet_layer, root, key)
|
||||||
except KeyError as error:
|
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:
|
except FileNotFoundError as error:
|
||||||
raise HTTPException(status_code=404, detail=str(error)) from error
|
raise HTTPException(status_code=404, detail=str(error)) from error
|
||||||
display = await asyncio.to_thread(_display_copy, path)
|
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)
|
@router.get("/{project_id}/plan/layers/cadastral", response_model=None)
|
||||||
|
|||||||
@@ -10,8 +10,9 @@
|
|||||||
* 선 겹(단층 · 지질구조선 · 0차곡) ·
|
* 선 겹(단층 · 지질구조선 · 0차곡) ·
|
||||||
* 계류 100 m · 도로 300 m 띠 · 건물 · 지명).
|
* 계류 100 m · 도로 300 m 띠 · 건물 · 지명).
|
||||||
* 처음 켬 = 위성 · 등고선 · 세류 · 고른 값 = 화면 취향(`aislo:pref:d03.layers`) 기억.
|
* 처음 켬 = 위성 · 등고선 · 세류 · 고른 값 = 화면 취향(`aislo:pref:d03.layers`) 기억.
|
||||||
* 받기 = 켤 때 · 받은 지형 범위(`have`)가 바뀌면 다시(596-4 · 등고 · 세류 주소에 범위 지문) · 지형을 안 받았으면 단추를 막음 ·
|
* 받기 = 켤 때 · 받은 지형 범위 지문(`load(have)` · 페이지가 지형면을 다 읽은 뒤 넘김)이 바뀌면 차례 +1 · 켠 겹 모두 다시 ·
|
||||||
* 받기 실패 = 그 겹만 꺼짐 + 알림.
|
* 옛 차례 답은 버림(475) · 범위는 서버가 받은 범위로 정함(등고 · 세류 · 도엽 겹 = 서버가 자름) · 지형을 안 받았으면 단추를 막음 ·
|
||||||
|
* 받기 실패 = 그 겹만 꺼짐 + 알림 · 받는 일은 페이지 서클에 셈(`track`).
|
||||||
* 그리기 차례(아래 → 위) = 위성(GL 조각만 · 옛 캔버스 = 위성 없음 · 590-2) → 음영기복 → 급경사 → 산사태위험지도 → 산림입지도 → 보호구역 → 띠 → 지적 → 물 면 →
|
* 그리기 차례(아래 → 위) = 위성(GL 조각만 · 옛 캔버스 = 위성 없음 · 590-2) → 음영기복 → 급경사 → 산사태위험지도 → 산림입지도 → 보호구역 → 띠 → 지적 → 물 면 →
|
||||||
* 건물 → 등고선 → 도로 → 임도 짐작 → 올린 임도망도 → 세류 → 선 겹 → 산사태취약지역 → 지명 → 그은 선.
|
* 건물 → 등고선 → 도로 → 임도 짐작 → 올린 임도망도 → 세류 → 선 겹 → 산사태취약지역 → 지명 → 그은 선.
|
||||||
* 겹 모두 = 바탕 캔버스(`base` · 582) · 등고선 = 줌 단계 색인 · 글 폭 기억.
|
* 겹 모두 = 바탕 캔버스(`base` · 582) · 등고선 = 줌 단계 색인 · 글 폭 기억.
|
||||||
@@ -27,8 +28,6 @@ import { createLegend } from "@ui/ui_template_flow_legend";
|
|||||||
import { createIconButton } from "@ui/ui_template_button";
|
import { createIconButton } from "@ui/ui_template_button";
|
||||||
import { createMapTools } from "@ui/ui_template_map_tools";
|
import { createMapTools } from "@ui/ui_template_map_tools";
|
||||||
import { themeColor } from "@ui/ui_template_palette";
|
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 { readByKey, writeByKey } from "../A00_Common/b_page_state";
|
||||||
import { fetchVWorldMeta, type VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
import { fetchVWorldMeta, type VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||||
import {
|
import {
|
||||||
@@ -85,7 +84,6 @@ import {
|
|||||||
writeSlopeBands,
|
writeSlopeBands,
|
||||||
} from "./D03_PlanAnalysis_UI_SlopeBands";
|
} from "./D03_PlanAnalysis_UI_SlopeBands";
|
||||||
import type { PlanMap, PlanMapLayer } from "./D03_PlanAnalysis_UI_Map";
|
import type { PlanMap, PlanMapLayer } from "./D03_PlanAnalysis_UI_Map";
|
||||||
import type { Vertex } from "./logic/D03_PlanAnalysis_Plan";
|
|
||||||
|
|
||||||
const PREF_KEY = "aislo:pref:d03.layers";
|
const PREF_KEY = "aislo:pref:d03.layers";
|
||||||
/** GL 글꼴 미리 받음 — 받기 전에 그린 글자는 대체 글꼴로 굳음(못 받아도 그림). */
|
/** GL 글꼴 미리 받음 — 받기 전에 그린 글자는 대체 글꼴로 굳음(못 받아도 그림). */
|
||||||
@@ -95,8 +93,6 @@ const glFont = (weight: string, text: string): Promise<unknown> =>
|
|||||||
const CANVAS_PREF_KEY = "aislo:pref:d03.canvas";
|
const CANVAS_PREF_KEY = "aislo:pref:d03.canvas";
|
||||||
/** 옛 캔버스 알림 — 위성 = GL 조각만(590-2). */
|
/** 옛 캔버스 알림 — 위성 = GL 조각만(590-2). */
|
||||||
const NO_SATELLITE_NOTE = "옛 그림은 위성 바탕 없이 그림";
|
const NO_SATELLITE_NOTE = "옛 그림은 위성 바탕 없이 그림";
|
||||||
/** 등고선을 받아 올 그은 선 둘레(m) — 받은 범위를 모를 때만. */
|
|
||||||
const CONTOUR_PAD_M = 600;
|
|
||||||
type DataKey = keyof Prepared;
|
type DataKey = keyof Prepared;
|
||||||
|
|
||||||
/** 겹 → 받는 자료(임도 짐작 = 도로 자료). */
|
/** 겹 → 받는 자료(임도 짐작 = 도로 자료). */
|
||||||
@@ -108,8 +104,8 @@ export interface PlanLayers {
|
|||||||
legend: HTMLElement;
|
legend: HTMLElement;
|
||||||
/** 바탕 겹(아래 → 위 · 위성 뺌) — 그은 선 겹 아래에 둠. */
|
/** 바탕 겹(아래 → 위 · 위성 뺌) — 그은 선 겹 아래에 둠. */
|
||||||
layers: PlanMapLayer[];
|
layers: PlanMapLayer[];
|
||||||
/** 지형(위성 틀)을 다시 읽음 — 처음 · 지형 받은 뒤 · 받은 범위가 바뀌면 겹을 새로 받음. */
|
/** 지형(위성 틀)을 다시 읽음 — 처음 · 지형 받은 뒤 · 받은 범위(`have` · 지형면을 다 읽은 뒤 값)가 바뀌면 켠 겹 모두 새로. */
|
||||||
load: () => Promise<void>;
|
load: (have: [number, number, number, number] | null) => Promise<void>;
|
||||||
/** 임도망도 올리기(왼쪽 「위치」 칸) — 받으면 그 겹을 켬 · 못 받으면 알림 + null. */
|
/** 임도망도 올리기(왼쪽 「위치」 칸) — 받으면 그 겹을 켬 · 못 받으면 알림 + null. */
|
||||||
uploadRoads: (files: File[]) => Promise<PlanForestRoads | null>;
|
uploadRoads: (files: File[]) => Promise<PlanForestRoads | null>;
|
||||||
/** 올린 묶음 하나 지움(605-1) — 지도에서 바로 빠짐 · 못 지우면 알림 + 거짓. */
|
/** 올린 묶음 하나 지움(605-1) — 지도에서 바로 빠짐 · 못 지우면 알림 + 거짓. */
|
||||||
@@ -123,23 +119,26 @@ export interface PlanLayers {
|
|||||||
counts: Record<string, number>;
|
counts: Record<string, number>;
|
||||||
/** GL 겹마다 화면에 그린 지물 수(보임만 · GL 끔 = 빈 것). */
|
/** GL 겹마다 화면에 그린 지물 수(보임만 · GL 끔 = 빈 것). */
|
||||||
gl: Record<string, number>;
|
gl: Record<string, number>;
|
||||||
|
/** 겹이 받은 범위(마지막 `load` 값) · 차례 번호(475 검증). */
|
||||||
|
area: [number, number, number, number] | null;
|
||||||
|
turn: number;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createPlanLayers(opts: {
|
export function createPlanLayers(opts: {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
map: PlanMap;
|
map: PlanMap;
|
||||||
/** 등고선 받을 범위를 정할 점(시점 · 종점 · 그은 선) — 받은 범위를 모를 때만. */
|
/** 받는 일을 진행 서클에 셈(끝나면 뺌). */
|
||||||
points: () => Vertex[];
|
track: <T>(job: Promise<T>) => Promise<T>;
|
||||||
/** 받은 지형 범위(작업 좌표계 m) — 바뀜 판정 · 등고 · 세류 주소 지문. */
|
|
||||||
have: () => [number, number, number, number] | null;
|
|
||||||
}): PlanLayers {
|
}): PlanLayers {
|
||||||
const { projectId, map } = opts;
|
const { projectId, map, track } = opts;
|
||||||
const on = readPref(PREF_KEY);
|
const on = readPref(PREF_KEY);
|
||||||
const data: Partial<Prepared> = {};
|
const data: Partial<Prepared> = {};
|
||||||
const pending = new Map<DataKey | "satellite", Promise<void>>();
|
const pending = new Map<DataKey | "satellite", Promise<void>>();
|
||||||
let meta: VWorldMeta | null = null;
|
let meta: VWorldMeta | null = null;
|
||||||
let loadedHave = "";
|
let area: [number, number, number, number] | null = null;
|
||||||
|
/** 차례 — 받은 범위 · 위성 틀이 바뀔 때 +1 · 옛 차례 답은 자료 · GL 에 안 씀. */
|
||||||
|
let areaTurn = 0;
|
||||||
/** 음영 GL 그림 — 그대로 · 위성 위 그늘(「곱하기」 꼴). */
|
/** 음영 GL 그림 — 그대로 · 위성 위 그늘(「곱하기」 꼴). */
|
||||||
let hillshade: { plain: GlImage; shade: GlImage } | null = null;
|
let hillshade: { plain: GlImage; shade: GlImage } | null = null;
|
||||||
const gl = createGlBase(projectId, (why) => {
|
const gl = createGlBase(projectId, (why) => {
|
||||||
@@ -274,14 +273,15 @@ export function createPlanLayers(opts: {
|
|||||||
const slot = key === "satellite" ? key : dataKey(key);
|
const slot = key === "satellite" ? key : dataKey(key);
|
||||||
const running = pending.get(slot);
|
const running = pending.get(slot);
|
||||||
if (running) return running;
|
if (running) return running;
|
||||||
const at = meta;
|
const mine = areaTurn;
|
||||||
const job = fetchLayer(key, at)
|
const job: Promise<void> = track(fetchLayer(key, meta, mine))
|
||||||
.then(() => {
|
.then(() => {
|
||||||
if (meta !== at) return;
|
if (mine !== areaTurn) return;
|
||||||
sync();
|
sync();
|
||||||
map.redrawBase();
|
map.redrawBase();
|
||||||
})
|
})
|
||||||
.catch((error: unknown) => {
|
.catch((error: unknown) => {
|
||||||
|
if (mine !== areaTurn) return;
|
||||||
const text = error instanceof Error ? error.message : String(error);
|
const text = error instanceof Error ? error.message : String(error);
|
||||||
for (const def of LAYERS)
|
for (const def of LAYERS)
|
||||||
if (def.key === key || (def.key !== "satellite" && dataKey(def.key) === slot))
|
if (def.key === key || (def.key !== "satellite" && dataKey(def.key) === slot))
|
||||||
@@ -290,7 +290,7 @@ export function createPlanLayers(opts: {
|
|||||||
map.redraw();
|
map.redraw();
|
||||||
showToast(`${LAYERS.find((def) => def.key === key)!.title} 받지 못함 — ${text}`, "error");
|
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);
|
pending.set(slot, job);
|
||||||
return job;
|
return job;
|
||||||
}
|
}
|
||||||
@@ -298,7 +298,7 @@ export function createPlanLayers(opts: {
|
|||||||
/** 올리기 — 받으면 그 겹을 켜고 바로 그림(지형 받기 전이면 받은 뒤 그림) · 못 받으면 옛 것 그대로 + 서버 까닭 알림. */
|
/** 올리기 — 받으면 그 겹을 켜고 바로 그림(지형 받기 전이면 받은 뒤 그림) · 못 받으면 옛 것 그대로 + 서버 까닭 알림. */
|
||||||
async function uploadRoads(files: File[]): Promise<PlanForestRoads | null> {
|
async function uploadRoads(files: File[]): Promise<PlanForestRoads | null> {
|
||||||
const at = meta;
|
const at = meta;
|
||||||
const job = uploadPlanForestRoads(projectId, files).then((info) => {
|
const job = track(uploadPlanForestRoads(projectId, files)).then((info) => {
|
||||||
if (meta !== at) return info;
|
if (meta !== at) return info;
|
||||||
applyRoads(info);
|
applyRoads(info);
|
||||||
showToast(
|
showToast(
|
||||||
@@ -404,209 +404,206 @@ export function createPlanLayers(opts: {
|
|||||||
else map.redrawBase();
|
else map.redrawBase();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 받은 범위 지문 — 등고 · 세류 주소에 붙여 브라우저 보관함이 옛 범위를 안 줌. */
|
/** 겹 하나 받기 — 자료 · GL 은 모아 두었다가 지금 차례일 때만 씀(옛 받은 범위 답 버림 · 475). */
|
||||||
const haveTag = (): string => (opts.have() ?? []).map((v) => Math.round(v)).join("_");
|
async function fetchLayer(key: PlanLayerKey, at: VWorldMeta, mine: number): Promise<void> {
|
||||||
const sheetLayer = (layer: string, extra = ""): Promise<GeoJsonCollection> =>
|
const got: Partial<Prepared> = {};
|
||||||
fetchCachedJson<GeoJsonCollection>(
|
const sets: Array<Parameters<typeof gl.setData>> = [];
|
||||||
projectId,
|
const put = (...args: Parameters<typeof gl.setData>): void => void sets.push(args);
|
||||||
`${API_BASE_URL}/projects/${projectId}/geojson?layer=${encodeURIComponent(layer)}${extra}&v=${haveTag()}`,
|
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<void> {
|
async function fill(): Promise<void> {
|
||||||
const normalizer = createNormalizer(at);
|
const normalizer = createNormalizer(at);
|
||||||
const prepare = (collection: GeoJsonCollection, labels?: string[]): PreparedLayer =>
|
const prepare = (collection: GeoJsonCollection, labels?: string[]): PreparedLayer =>
|
||||||
prepareLayer(collection, normalizer, labels);
|
prepareLayer(collection, normalizer, labels);
|
||||||
const pick = (
|
const pick = (
|
||||||
collection: GeoJsonCollection,
|
collection: GeoJsonCollection,
|
||||||
test: (props: Record<string, unknown>) => boolean,
|
test: (props: Record<string, unknown>) => boolean,
|
||||||
): GeoJsonCollection => ({
|
): GeoJsonCollection => ({
|
||||||
...collection,
|
...collection,
|
||||||
features: (collection.features ?? []).filter((feature) => test(feature.properties ?? {})),
|
features: (collection.features ?? []).filter((feature) => test(feature.properties ?? {})),
|
||||||
});
|
});
|
||||||
if (key === "contour") {
|
if (key === "contour") {
|
||||||
// 받은 범위 통째(모르면 그은 선 둘레) — 범위가 늘면 `load` 가 다시 받게 함
|
// 받은 범위로 서버가 자름 — 범위가 늘면 `load` 가 다시 받게 함
|
||||||
const points = opts.points();
|
const [collection] = await Promise.all([
|
||||||
const xs = points.map(([x]) => x);
|
fetchPlanSheetLayer(projectId, "등고선"),
|
||||||
const ys = points.map(([, y]) => y);
|
glFont("400", "0123456789-"),
|
||||||
const bbox = (
|
]);
|
||||||
opts.have() ?? [
|
got.contour = indexLayerLod(prepare(collection, ["등고수치"]));
|
||||||
Math.min(...xs) - CONTOUR_PAD_M,
|
put("contour", collection);
|
||||||
Math.min(...ys) - CONTOUR_PAD_M,
|
} else if (key === "stream") {
|
||||||
Math.max(...xs) + CONTOUR_PAD_M,
|
const collection = await fetchPlanSheetLayer(projectId, "하천중심선");
|
||||||
Math.max(...ys) + CONTOUR_PAD_M,
|
got.stream = [
|
||||||
]
|
prepare(pick(collection, (props) => props["구분"] === "세류")),
|
||||||
).map((v) => v.toFixed(0));
|
prepare(pick(collection, (props) => props["구분"] !== "세류")),
|
||||||
const [collection] = await Promise.all([
|
];
|
||||||
sheetLayer("도엽_등고선", `&scope=route&bbox=${bbox.join(",")}`),
|
put("stream", collection);
|
||||||
glFont("400", "0123456789-"),
|
} else if (key === "cadastral") {
|
||||||
]);
|
const collection = await fetchPlanCadastral(projectId);
|
||||||
data.contour = indexLayerLod(prepare(collection, ["등고수치"]));
|
got.cadastral = prepare(collection);
|
||||||
gl.setData("contour", collection);
|
put("cadastral", collection);
|
||||||
} else if (key === "stream") {
|
} else if (key === "road" || key === "forestRoad") {
|
||||||
const collection = await sheetLayer("도엽_하천중심선");
|
const collection = await fetchPlanSheetLayer(projectId, "도로중심선");
|
||||||
data.stream = [
|
got.road = {
|
||||||
prepare(pick(collection, (props) => props["구분"] === "세류")),
|
all: prepare(collection),
|
||||||
prepare(pick(collection, (props) => props["구분"] !== "세류")),
|
small: prepare(
|
||||||
];
|
pick(collection, (props) => FOREST_ROAD_KINDS.has(String(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<string, PreparedLayer>();
|
|
||||||
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,
|
|
||||||
};
|
};
|
||||||
gl.setData(key, got.collection);
|
put("road", collection);
|
||||||
} finally {
|
} else if (key === "forestRoadFile") {
|
||||||
window.clearTimeout(slow);
|
const info = await fetchPlanForestRoads(projectId);
|
||||||
}
|
got.forestRoadFile = { layer: prepare(info.collection), info };
|
||||||
} else if (key === "bands") {
|
put("forestRoadFile", info.collection);
|
||||||
const info = await fetchPlanBands(projectId);
|
} else if (key === "landslideZones") {
|
||||||
data.bands = {
|
const info = await fetchPlanLandslideZones(projectId);
|
||||||
stream: prepare(info.stream.collection),
|
got.landslideZones = { parcels: prepare(info.parcels), info };
|
||||||
road: prepare(info.road.collection),
|
put("landslideZones", {
|
||||||
info,
|
features: [
|
||||||
};
|
...(info.parcels.features ?? []),
|
||||||
const band = (name: "stream" | "road", fallback: string): GeoJsonCollection =>
|
...info.points.map((point) => ({
|
||||||
glTagged(info[name].collection, () => themeColor(`--draw-map-band-${name}`, fallback), {
|
type: "Feature",
|
||||||
_band: name,
|
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<string, PreparedLayer>();
|
||||||
|
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", {
|
} else {
|
||||||
features: [
|
const collection = await fetchPlanSheetLayer(projectId, "지명");
|
||||||
...(band("road", "#facc15").features ?? []),
|
got.place = (collection.features ?? []).flatMap((feature) => {
|
||||||
...(band("stream", "#22d3ee").features ?? []),
|
const geometry = feature.geometry;
|
||||||
],
|
const name = feature.properties?.["명칭"];
|
||||||
});
|
if (geometry?.type !== "Point" || typeof name !== "string" || !name) return [];
|
||||||
} else {
|
const [lon, lat] = geometry.coordinates as number[];
|
||||||
const collection = await fetchPlanSheetLayer(projectId, "지명");
|
return [{ lon, lat, name }];
|
||||||
data.place = (collection.features ?? []).flatMap((feature) => {
|
});
|
||||||
const geometry = feature.geometry;
|
await glFont("600", got.place.map((place) => place.name).join(""));
|
||||||
const name = feature.properties?.["명칭"];
|
put("place", {
|
||||||
if (geometry?.type !== "Point" || typeof name !== "string" || !name) return [];
|
features: got.place.map(({ lon, lat, name }) => ({
|
||||||
const [lon, lat] = geometry.coordinates as number[];
|
type: "Feature",
|
||||||
return [{ lon, lat, name }];
|
properties: { name },
|
||||||
});
|
geometry: { type: "Point", coordinates: [lon, lat] },
|
||||||
await glFont("600", data.place.map((place) => place.name).join(""));
|
})),
|
||||||
gl.setData("place", {
|
} as GeoJsonCollection);
|
||||||
features: data.place.map(({ lon, lat, name }) => ({
|
}
|
||||||
type: "Feature",
|
|
||||||
properties: { name },
|
|
||||||
geometry: { type: "Point", coordinates: [lon, lat] },
|
|
||||||
})),
|
|
||||||
} as GeoJsonCollection);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -632,16 +629,16 @@ export function createPlanLayers(opts: {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
async function load(): Promise<void> {
|
async function load(have: [number, number, number, number] | null): Promise<void> {
|
||||||
const next = await fetchVWorldMeta(projectId, "satellite").catch(() => null);
|
const next = await fetchVWorldMeta(projectId, "satellite").catch(() => null);
|
||||||
// 바뀜 = 위성 틀 또는 받은 범위(같은 도엽 안에서 늘어도 겹을 새로 · 596-4)
|
// 바뀜 = 받은 범위 지문 또는 위성 틀 → 차례 +1 · 자료 · 받는 중 비움 · 켠 겹 모두 다시(475 · 596-4)
|
||||||
const have = JSON.stringify(opts.have());
|
const frame = (m: VWorldMeta | null) =>
|
||||||
const changed =
|
JSON.stringify(m && [m.x_min, m.x_max, m.y_min, m.y_max]);
|
||||||
have !== loadedHave ||
|
const changed = JSON.stringify(have) !== JSON.stringify(area) || frame(next) !== frame(meta);
|
||||||
JSON.stringify(next && [next.x_min, next.x_max, next.y_min, next.y_max]) !==
|
[meta, area] = [next, have];
|
||||||
JSON.stringify(meta && [meta.x_min, meta.x_max, meta.y_min, meta.y_max]);
|
|
||||||
[meta, loadedHave] = [next, have];
|
|
||||||
if (changed) {
|
if (changed) {
|
||||||
|
areaTurn += 1;
|
||||||
|
pending.clear();
|
||||||
for (const key of Object.keys(data)) delete data[key as DataKey];
|
for (const key of Object.keys(data)) delete data[key as DataKey];
|
||||||
hillshade = null;
|
hillshade = null;
|
||||||
for (const def of LAYERS)
|
for (const def of LAYERS)
|
||||||
@@ -674,6 +671,8 @@ export function createPlanLayers(opts: {
|
|||||||
: gl.map()!.queryRenderedFeatures({ layers: [layer.id] }).length,
|
: gl.map()!.queryRenderedFeatures({ layers: [layer.id] }).length,
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
|
area,
|
||||||
|
turn: areaTurn,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,10 @@
|
|||||||
*
|
*
|
||||||
* 메인 = 평면(바탕 겹 켜고 끔 — 위성 · 등고선 · 세류 · 지적 · 도로 · 급경사 + 그은 선 · 측점 · 연도 구분) + 도구 여섯.
|
* 메인 = 평면(바탕 겹 켜고 끔 — 위성 · 등고선 · 세류 · 지적 · 도로 · 급경사 + 그은 선 · 측점 · 연도 구분) + 도구 여섯.
|
||||||
* 좌 = 지형(조용히 받기 — 처음 = 등록 기준점 둘레 1 km · 지점 · 선 · 지도 보기 가운데가 받은 범위 밖이면 이어 받음 ·
|
* 좌 = 지형(조용히 받기 — 처음 = 등록 기준점 둘레 1 km · 지점 · 선 · 지도 보기 가운데가 받은 범위 밖이면 이어 받음 ·
|
||||||
* 단추 · 막는 창 없음 · 진행 = 이 칸 작은 막대 · 실패만 [다시 받기]) · 지점(목록 · 핀 · 우클릭 메뉴 =
|
* 단추 · 막는 창 없음 · 진행 = 위치 칸 제목 오른쪽 서클(받는 일 모두 · 604-3) · 실패만 [다시 받기] ·
|
||||||
|
* 영역 루틴(475) = 사건(지점 · 선 · 위치 · 되돌리기 · 다시하기 · 처음 열기) → `ensureCovered` → 받기 →
|
||||||
|
* 지형면 · 받은 범위 먼저 → 겹(`backdrop.load(have)` · 지문 바뀜 = 켠 겹 모두 다시) ·
|
||||||
|
* 기준점 10 km 밖 = 위치 칸 알림 · 지운 지점 영역은 그대로) · 지점(목록 · 핀 · 우클릭 메뉴 =
|
||||||
* `D03_PlanAnalysis_UI_Points`) · 그리기([긋기] · 제약 칸 · 연도 = 도구줄 「연도 마커」 끌어 놓기 585-3) ·
|
* `D03_PlanAnalysis_UI_Points`) · 그리기([긋기] · 제약 칸 · 연도 = 도구줄 「연도 마커」 끌어 놓기 585-3) ·
|
||||||
* 구역 · 연도(해 · 선 · 구간 줄 = 고르기 · 고른 줄 밝힘) · [저장] [초기화].
|
* 구역 · 연도(해 · 선 · 구간 줄 = 고르기 · 고른 줄 밝힘) · [저장] [초기화].
|
||||||
* 고른 구간 속성(연도 · 넓이 · 방향 · 지움) = 지도 위 속성 판 `D03_PlanAnalysis_UI_Props`(585-2).
|
* 고른 구간 속성(연도 · 넓이 · 방향 · 지움) = 지도 위 속성 판 `D03_PlanAnalysis_UI_Props`(585-2).
|
||||||
@@ -64,7 +67,9 @@ import { createPlanSite } from "./D03_PlanAnalysis_UI_Site";
|
|||||||
import {
|
import {
|
||||||
aroundSite,
|
aroundSite,
|
||||||
boxOfLonLat,
|
boxOfLonLat,
|
||||||
|
createWorkCircle,
|
||||||
errorText,
|
errorText,
|
||||||
|
farFromSite,
|
||||||
nearSite,
|
nearSite,
|
||||||
watchTerrain,
|
watchTerrain,
|
||||||
type Site,
|
type Site,
|
||||||
@@ -212,16 +217,11 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
|||||||
on3d: open3d,
|
on3d: open3d,
|
||||||
onMeasure: () => setMeasure(!measuring),
|
onMeasure: () => setMeasure(!measuring),
|
||||||
});
|
});
|
||||||
const backdrop = createPlanLayers({
|
// 받기 진행 = 위치 칸 제목 오른쪽 서클 하나(받는 일 수 · 0 = 숨김 · 지도 조작 안 막음 604-3)
|
||||||
projectId,
|
const circle = createProgressCircle({ size: 16, inline: true });
|
||||||
map,
|
const work = createWorkCircle(circle);
|
||||||
points: () => [...points, ...allVertices()],
|
const backdrop = createPlanLayers({ projectId, map, track: work.track });
|
||||||
have: () => have,
|
map.root.append(backdrop.bar, backdrop.legend, props.panel);
|
||||||
});
|
|
||||||
// 받기 진행 = 지도 오른쪽 아래 작은 서클(받을 때만 · 지도 조작 안 막음 596-3)
|
|
||||||
const circle = createProgressCircle({ size: 40, corner: true });
|
|
||||||
circle.root.hidden = true;
|
|
||||||
map.root.append(backdrop.bar, backdrop.legend, props.panel, circle.root);
|
|
||||||
const main = document.createElement("div");
|
const main = document.createElement("div");
|
||||||
main.className = "d03-plan";
|
main.className = "d03-plan";
|
||||||
main.append(map.root);
|
main.append(map.root);
|
||||||
@@ -243,6 +243,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const siteCard = createPanelCard("위치", false);
|
const siteCard = createPanelCard("위치", false);
|
||||||
|
siteCard.firstElementChild!.append(circle.root);
|
||||||
siteCard.append(...siteBox.parts);
|
siteCard.append(...siteBox.parts);
|
||||||
const pointCard = createPanelCard("지점", false);
|
const pointCard = createPanelCard("지점", false);
|
||||||
pointCard.append(marks.panel);
|
pointCard.append(marks.panel);
|
||||||
@@ -317,6 +318,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
|||||||
({ network, points, pointNames, site } = last);
|
({ network, points, pointNames, site } = last);
|
||||||
keepDraft();
|
keepDraft();
|
||||||
refresh();
|
refresh();
|
||||||
|
void ensureCovered(); // 영역 루틴 — 되돌린 지점 · 선도 받은 범위 밖이면 받음
|
||||||
}
|
}
|
||||||
|
|
||||||
function redo(): void {
|
function redo(): void {
|
||||||
@@ -326,6 +328,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
|||||||
({ network, points, pointNames, site } = next);
|
({ network, points, pointNames, site } = next);
|
||||||
keepDraft();
|
keepDraft();
|
||||||
refresh();
|
refresh();
|
||||||
|
void ensureCovered();
|
||||||
}
|
}
|
||||||
|
|
||||||
function setMeasure(on: boolean): void {
|
function setMeasure(on: boolean): void {
|
||||||
@@ -379,9 +382,11 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
|||||||
props.render();
|
props.render();
|
||||||
pieces.render();
|
pieces.render();
|
||||||
marks.render();
|
marks.render();
|
||||||
|
const far = epsg ? farFromSite(epsg, site, [...points, ...allVertices()]) : 0;
|
||||||
|
const farNote = far ? `기준점 10 km 밖 점 ${far}곳 — 지형 안 받음 · 위치를 옮기세요` : "";
|
||||||
siteBox.render({
|
siteBox.render({
|
||||||
address: site?.address ?? null,
|
address: site?.address ?? null,
|
||||||
note: failed ? `지형 받기 실패 — ${failed}` : note,
|
note: [failed ? `지형 받기 실패 — ${failed}` : note, farNote].filter(Boolean).join(" · "),
|
||||||
retry: !busy && !!failed,
|
retry: !busy && !!failed,
|
||||||
editable,
|
editable,
|
||||||
});
|
});
|
||||||
@@ -454,12 +459,14 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
|||||||
if (info) fitTo([...around(site), ...boxOfLonLat(epsg, info.bounds)]);
|
if (info) fitTo([...around(site), ...boxOfLonLat(epsg, info.bounds)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 도는 받기를 지켜보고 끝나면 지형 · 바탕을 다시 읽음. */
|
/** 도는 받기를 지켜보고 끝나면 지형면 · 받은 범위를 먼저 다 읽은 뒤 겹(475 — 동시면 겹이 옛 범위로 판정). */
|
||||||
async function follow(): Promise<void> {
|
async function follow(): Promise<void> {
|
||||||
const job = await watch();
|
const job = await watch();
|
||||||
if (!job || !root.isConnected) return;
|
if (!job || !root.isConnected) return;
|
||||||
failed = job.error;
|
failed = job.error;
|
||||||
await Promise.all([loadSurface(), backdrop.load()]);
|
await loadSurface();
|
||||||
|
refresh();
|
||||||
|
await backdrop.load(have);
|
||||||
refresh();
|
refresh();
|
||||||
await ensureCovered(); // 받는 동안 옮긴 지점 · 선 · 보기
|
await ensureCovered(); // 받는 동안 옮긴 지점 · 선 · 보기
|
||||||
}
|
}
|
||||||
@@ -468,18 +475,22 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
|||||||
watchTerrain({
|
watchTerrain({
|
||||||
projectId,
|
projectId,
|
||||||
alive: () => root.isConnected,
|
alive: () => root.isConnected,
|
||||||
circle,
|
work,
|
||||||
setBusy: (on) => {
|
setBusy: (on) => {
|
||||||
busy = on;
|
busy = on;
|
||||||
refresh();
|
refresh();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** 지형면 · 받은 범위(have) — 서클에 셈. */
|
||||||
async function loadSurface(): Promise<void> {
|
async function loadSurface(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
surface = await fetchPlanSurface(projectId);
|
[surface, have] = await work.track(
|
||||||
const extent = await fetchTerrainExtent(projectId, null, []);
|
Promise.all([
|
||||||
have = extent.have_m;
|
fetchPlanSurface(projectId),
|
||||||
|
fetchTerrainExtent(projectId, null, []).then((extent) => extent.have_m),
|
||||||
|
]),
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast(`지형면을 읽지 못했습니다 — ${errorText(error)}`, "error");
|
showToast(`지형면을 읽지 못했습니다 — ${errorText(error)}`, "error");
|
||||||
}
|
}
|
||||||
@@ -573,6 +584,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
|||||||
base: () => points,
|
base: () => points,
|
||||||
have: () => have,
|
have: () => have,
|
||||||
busy: () => busy,
|
busy: () => busy,
|
||||||
|
work: work.count,
|
||||||
site: () => site,
|
site: () => site,
|
||||||
pickSite,
|
pickSite,
|
||||||
lineNames: () => lineNames(network.lines),
|
lineNames: () => lineNames(network.lines),
|
||||||
@@ -636,7 +648,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
|||||||
refresh();
|
refresh();
|
||||||
await loadSurface();
|
await loadSurface();
|
||||||
refresh();
|
refresh();
|
||||||
await backdrop.load();
|
await backdrop.load(have);
|
||||||
const job = (await fetchPlanProgress(projectId)).terrain;
|
const job = (await fetchPlanProgress(projectId)).terrain;
|
||||||
if (job.running) {
|
if (job.running) {
|
||||||
ready = true;
|
ready = true;
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
/* =============================================================================
|
/* =============================================================================
|
||||||
* D03_PlanAnalysis_UI_Terrain.ts
|
* 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";
|
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: <T>(job: Promise<T>) => Promise<T>;
|
||||||
|
/** 지형 받기 진행(없음 = 끝). */
|
||||||
|
terrain: (state: { ratio: number; text: string } | null) => void;
|
||||||
|
/** 들어간 일 수(검증 창구). */
|
||||||
|
count: () => number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createWorkCircle(circle: ReturnType<typeof createProgressCircle>): 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 {
|
export interface WatchParams {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
alive: () => boolean;
|
alive: () => boolean;
|
||||||
circle: ReturnType<typeof createProgressCircle>;
|
work: WorkCircle;
|
||||||
/** 받는 중 켬끔(페이지 `busy`) 뒤 화면 갱신. */
|
/** 받는 중 켬끔(페이지 `busy`) 뒤 화면 갱신. */
|
||||||
setBusy: (on: boolean) => void;
|
setBusy: (on: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 지형 받기를 끝날 때까지 지켜봄 — 진행 = 지도 귀퉁이 서클 · 끊김(서버 다시 켜짐 = 끝 아닌데 멎음 ·
|
/** 지형 받기를 끝날 때까지 지켜봄 — 진행 = 위치 칸 서클 · 끊김(서버 다시 켜짐 = 끝 아닌데 멎음 ·
|
||||||
* 진행을 못 읽음) = 실패 꼴 + [다시 받기]. */
|
* 진행을 못 읽음) = 실패 꼴 + [다시 받기]. */
|
||||||
export async function watchTerrain(params: WatchParams): Promise<PlanJob | null> {
|
export function watchTerrain(params: WatchParams): Promise<PlanJob | null> {
|
||||||
const { projectId, alive, circle, setBusy } = params;
|
return params.work.track(watching(params));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function watching(params: WatchParams): Promise<PlanJob | null> {
|
||||||
|
const { projectId, alive, work, setBusy } = params;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
circle.set(null, "지형");
|
work.terrain({ ratio: 0, text: "" });
|
||||||
circle.root.hidden = false;
|
|
||||||
try {
|
try {
|
||||||
for (;;) {
|
for (;;) {
|
||||||
if (!alive()) return null;
|
if (!alive()) return null;
|
||||||
const job = (await fetchPlanProgress(projectId)).terrain;
|
const job = (await fetchPlanProgress(projectId)).terrain;
|
||||||
circle.set(job.percent / 100, `지형 ${job.message ?? ""}`.trim());
|
work.terrain({ ratio: job.percent / 100, text: job.message ?? "" });
|
||||||
circle.root.title = `지형 받기 ${job.percent}% · ${job.message ?? ""}`;
|
|
||||||
if (!job.running) {
|
if (!job.running) {
|
||||||
const cut = !job.error && (job.interrupted || job.percent < 100);
|
const cut = !job.error && (job.interrupted || job.percent < 100);
|
||||||
if (job.error) showToast(job.error, "error");
|
if (job.error) showToast(job.error, "error");
|
||||||
@@ -88,7 +134,7 @@ export async function watchTerrain(params: WatchParams): Promise<PlanJob | null>
|
|||||||
showToast(`진행을 읽지 못했습니다 — ${errorText(error)}`, "error");
|
showToast(`진행을 읽지 못했습니다 — ${errorText(error)}`, "error");
|
||||||
return { running: false, percent: 0, message: null, error: INTERRUPTED };
|
return { running: false, percent: 0, message: null, error: INTERRUPTED };
|
||||||
} finally {
|
} finally {
|
||||||
circle.root.hidden = true;
|
work.terrain(null);
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -929,3 +929,7 @@ resource폴더에 미리 계산해서 저장한고 필요한 시점에 사용한
|
|||||||
## 요구 491 (10-30 · 사용자)
|
## 요구 491 (10-30 · 사용자)
|
||||||
|
|
||||||
일단 진행해주는데 조심해야된느 부분은 B04 B05에서의 도엽등고를 치환하는 변환하는 곳을 조심해. 지금 B그룹은 개발이 완료된 상태라 조심해야해. 진행해줘.
|
일단 진행해주는데 조심해야된느 부분은 B04 B05에서의 도엽등고를 치환하는 변환하는 곳을 조심해. 지금 B그룹은 개발이 완료된 상태라 조심해야해. 진행해줘.
|
||||||
|
|
||||||
|
## 요구 492 (10-30 · 사용자)
|
||||||
|
|
||||||
|
나는 이제 자러갈거야. 최종적으로 코드 반영하고 마지막에 아티팩트로 검증해서 보고서를 출력해줘.
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -35,7 +35,7 @@ def test_d03_parts():
|
|||||||
tools = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts")
|
tools = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts")
|
||||||
layers = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts")
|
layers = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts")
|
||||||
assert 'createElement("progress")' not in ts and "createProgressBar(" not in 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(
|
site = _src(
|
||||||
"D03_PlanAnalysis/D03_PlanAnalysis_UI_Site.ts"
|
"D03_PlanAnalysis/D03_PlanAnalysis_UI_Site.ts"
|
||||||
) # 596-2 위치 칸 · 596-5 등록 찾기 = 같은 부품
|
) # 596-2 위치 칸 · 596-5 등록 찾기 = 같은 부품
|
||||||
|
|||||||
@@ -28,6 +28,32 @@
|
|||||||
flex-direction: row-reverse;
|
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 {
|
.ui-progress-circle__dial {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: var(--ui-progress-size);
|
width: var(--ui-progress-size);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import "./ui_template_progress.css";
|
|||||||
* 부품 「진행 막대」(552-3 · 목록 30) — 막대 · 쌓임 막대 · 원.
|
* 부품 「진행 막대」(552-3 · 목록 30) — 막대 · 쌓임 막대 · 원.
|
||||||
* 꼴 = 막대(`createProgressBar` · 값 하나) · 쌓임 막대(`parts` · 갈래 셋 accent · soft · warn) · 원(`createProgressCircle` · 아래)
|
* 꼴 = 막대(`createProgressBar` · 값 하나) · 쌓임 막대(`parts` · 갈래 셋 accent · soft · warn) · 원(`createProgressCircle` · 아래)
|
||||||
* · 원 자리 = 흐름(기본) · 가운데 덮음(`overlay`) · 오른쪽 아래 귀퉁이(`corner` · 글은 원 왼쪽)
|
* · 원 자리 = 흐름(기본) · 가운데 덮음(`overlay`) · 오른쪽 아래 귀퉁이(`corner` · 글은 원 왼쪽)
|
||||||
|
* · 제목 줄 안(`inline` · 카드 제목 오른쪽 · 캐럿 왼쪽 · 작은 원 + 글 한 줄 · 진행률은 글 끝에)
|
||||||
* 크기 = 막대 굵기 6 · 8(`thick` → data-size) · 원 지름 72(기본)
|
* 크기 = 막대 굵기 6 · 8(`thick` → data-size) · 원 지름 72(기본)
|
||||||
* 상태 = 기본 · 오류(`.is-error` · 막대가 위험색) · 판정(`tone`: warn · bad)
|
* 상태 = 기본 · 오류(`.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" }] })`
|
* 쓰는 법 = `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)로 정한다. */
|
/** viewBox 기준 반지름 — 실제 크기는 CSS(--ui-progress-size)로 정한다. */
|
||||||
const RADIUS = 42;
|
const RADIUS = 42;
|
||||||
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
|
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
|
||||||
|
const clamp01 = (v: number): number => Math.min(1, Math.max(0, v));
|
||||||
|
|
||||||
export interface ProgressCircleOptions {
|
export interface ProgressCircleOptions {
|
||||||
/** 지름(px). 기본 72(공통 로딩 스피너와 같은 무게감). */
|
/** 지름(px). 기본 72(공통 로딩 스피너와 같은 무게감). */
|
||||||
@@ -31,6 +33,8 @@ export interface ProgressCircleOptions {
|
|||||||
overlay?: boolean;
|
overlay?: boolean;
|
||||||
/** 컨테이너 오른쪽 아래 귀퉁이에 작게 띄운다(가운데를 안 가림 · 컨테이너는 position: relative). */
|
/** 컨테이너 오른쪽 아래 귀퉁이에 작게 띄운다(가운데를 안 가림 · 컨테이너는 position: relative). */
|
||||||
corner?: boolean;
|
corner?: boolean;
|
||||||
|
/** 제목 줄 안(패널 카드 제목 오른쪽 · 캐럿 왼쪽) — 작은 원 + 글 한 줄 · 진행률은 글 끝에 붙음. */
|
||||||
|
inline?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProgressCircleHandle {
|
export interface ProgressCircleHandle {
|
||||||
@@ -46,7 +50,8 @@ export function createProgressCircle(options: ProgressCircleOptions = {}): Progr
|
|||||||
root.className =
|
root.className =
|
||||||
"ui-progress-circle" +
|
"ui-progress-circle" +
|
||||||
(options.overlay ? " ui-progress-circle--overlay" : "") +
|
(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("role", "status");
|
||||||
root.setAttribute("aria-live", "polite");
|
root.setAttribute("aria-live", "polite");
|
||||||
if (options.size) root.style.setProperty("--ui-progress-size", `${options.size}px`);
|
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);
|
dial.append(spin, percent);
|
||||||
root.append(dial, label);
|
root.append(dial, label);
|
||||||
|
|
||||||
|
let text = options.label ?? "";
|
||||||
function set(ratio: number | null, nextLabel?: string): void {
|
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) {
|
if (ratio === null) {
|
||||||
// 진행률 미상 — 4분의 1 호만 남긴다(회전은 껍데기가 늘 맡는다).
|
// 진행률 미상 — 4분의 1 호만 남긴다(회전은 껍데기가 늘 맡는다).
|
||||||
bar.setAttribute("stroke-dashoffset", String(CIRCUMFERENCE * 0.75));
|
bar.setAttribute("stroke-dashoffset", String(CIRCUMFERENCE * 0.75));
|
||||||
percent.textContent = "";
|
percent.textContent = "";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const clamped = Math.min(1, Math.max(0, ratio));
|
const clamped = clamp01(ratio);
|
||||||
bar.setAttribute("stroke-dashoffset", String(CIRCUMFERENCE * (1 - clamped)));
|
bar.setAttribute("stroke-dashoffset", String(CIRCUMFERENCE * (1 - clamped)));
|
||||||
percent.textContent = `${Math.round(clamped * 100)}%`;
|
percent.textContent = `${Math.round(clamped * 100)}%`;
|
||||||
}
|
}
|
||||||
@@ -126,8 +135,6 @@ export interface ProgressBarHandle {
|
|||||||
set: (value: number, label?: string) => void;
|
set: (value: number, label?: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const clamp01 = (v: number): number => Math.min(1, Math.max(0, v));
|
|
||||||
|
|
||||||
/** 진행 막대 — 값 하나 또는 쌓임 */
|
/** 진행 막대 — 값 하나 또는 쌓임 */
|
||||||
export function createProgressBar(opts: ProgressBarOptions = {}): ProgressBarHandle {
|
export function createProgressBar(opts: ProgressBarOptions = {}): ProgressBarHandle {
|
||||||
const root = document.createElement("div");
|
const root = document.createElement("div");
|
||||||
|
|||||||
Reference in New Issue
Block a user