feat(D 예정노선): 받기 진행 = 지도 귀퉁이 서클 · 끊김 = [다시 받기] · 겹 다시 받기 = 받은 범위 판정 · 보기 옮김 받지 않음 (PLAN 596-3 · 596-4 · 요구 463)
- 부품 progress 원에 corner 꼴(오른쪽 아래 · 글 왼쪽) · 부품 모음 견본 - 서버 다시 켜짐(끝 아닌데 멎음 · 진행 못 읽음) = 끊김 → [다시 받기] - 겹: 받은 범위(have) 바뀌면 새로 받음 · 등고 · 세류 주소에 범위 지문 &v= · 등고 자르는 상자 = 받은 범위 - 임도망 [올리기] 지도 겹 아이콘 줄에서 왼쪽 칸 단추로(Layers 가 uploadRoads 내줌) - Api: 받기 center · reset · 답 far · band 꼴 · 진행 interrupted · 임도망 bounds · distance_m(595 꼴) - 시험 553_4: 막대 대신 귀퉁이 서클 단언 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -32,6 +32,20 @@ export interface PlanJob {
|
||||
percent: number;
|
||||
message: string | null;
|
||||
error: string | null;
|
||||
/** 서버 다시 켜짐 등으로 끊김(595-3). */
|
||||
interrupted?: boolean;
|
||||
}
|
||||
|
||||
/** 받기 시작 답(595-2) — 받음 · 멂(받은 범위에서 5 km 넘음 → 묻고 `reset`) · 다른 TM 띠(노선 지움 확인 뒤 `reset`). */
|
||||
export type TerrainStart =
|
||||
| (PlanJob & { status?: "accepted" })
|
||||
| { status: "far"; distance_m: number; message: string }
|
||||
| { status: "band"; epsg: number; center_epsg: number; message: string };
|
||||
|
||||
/** 위치 칸 기준점(캐시 · [저장] 전) — 서버가 둘레 ±1 km 를 더 덮음 · `reset` = 옛 받은 범위 버림. */
|
||||
export interface TerrainCenter {
|
||||
center?: { lon: number; lat: number } | null;
|
||||
reset?: boolean;
|
||||
}
|
||||
|
||||
/** 받기 전 범위(561-2) — 상자(작업 좌표계 m · 경위도 네 귀) · 받은 범위 · 도엽 · 칸 수 어림. */
|
||||
@@ -94,10 +108,15 @@ export const fetchTerrainExtent = (
|
||||
projectId: string,
|
||||
marginM: number | null,
|
||||
extra: Vertex[],
|
||||
center: TerrainCenter["center"] = null,
|
||||
): Promise<TerrainExtent> => {
|
||||
const query = new URLSearchParams();
|
||||
if (marginM !== null) query.set("margin_m", String(marginM));
|
||||
if (extra.length) query.set("extra", JSON.stringify(extra));
|
||||
if (center) {
|
||||
query.set("lon", String(center.lon));
|
||||
query.set("lat", String(center.lat));
|
||||
}
|
||||
return request(`${base(projectId)}/terrain/extent?${query}`);
|
||||
};
|
||||
|
||||
@@ -105,10 +124,11 @@ export const startTerrain = (
|
||||
projectId: string,
|
||||
marginM: number | null,
|
||||
extra: Vertex[],
|
||||
): Promise<PlanJob> =>
|
||||
where: TerrainCenter = {},
|
||||
): Promise<TerrainStart> =>
|
||||
request(`${base(projectId)}/terrain`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ margin_m: marginM, extra }),
|
||||
body: JSON.stringify({ margin_m: marginM, extra, ...where }),
|
||||
});
|
||||
|
||||
export const fetchPlanProgress = (projectId: string): Promise<{ terrain: PlanJob }> =>
|
||||
@@ -214,6 +234,10 @@ export interface PlanForestRoads {
|
||||
crs: string[];
|
||||
source: { files: string[]; bytes: number; uploaded_at: string } | null;
|
||||
collection: GeoJsonCollection;
|
||||
/** 잘린 임도 상자 [서, 남, 동, 북] 경위도 · 없으면 null(595-4). */
|
||||
bounds?: [number, number, number, number] | null;
|
||||
/** 기준점 ~ 가장 가까운 임도(m). */
|
||||
distance_m?: number | null;
|
||||
}
|
||||
|
||||
/** 올린 적 없으면 오류(404 글 그대로). */
|
||||
|
||||
@@ -70,7 +70,7 @@ export const GROUPS: readonly (readonly LayerDef[])[] = [
|
||||
{
|
||||
key: "forestRoadFile",
|
||||
icon: "map",
|
||||
title: "올린 임도망도(산림청 시군구 SHP = 진한 주황 실선 · 옆 단추로 올림)",
|
||||
title: "올린 임도망도(산림청 시군구 SHP = 진한 주황 실선 · 왼쪽 「위치」 칸에서 올림)",
|
||||
on: false,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -5,12 +5,13 @@
|
||||
*
|
||||
* 겹 = 바탕(위성 · 음영기복 · 도엽 등고선(계곡선 25 m 굵게 · 수치) · 급경사 색) ·
|
||||
* 물 · 길(세류선 · 하천 · 물 면(실폭하천 · 저수지) · 기존 도로 · 기존 임도 짐작(소로 · 미분류) ·
|
||||
* 올린 임도망도(시군구 SHP · 옆 올리기 단추)) ·
|
||||
* 올린 임도망도(시군구 SHP · 올리기 = 왼쪽 「위치」 칸 `uploadRoads`)) ·
|
||||
* 구역(지적도 · 보호 · 규제 구역 여덟 종 · 산림입지도(토양형) · 산사태위험지도 · 산사태취약지역(시도 표 점 · 지번 필지 테두리) ·
|
||||
* 선 겹(단층 · 지질구조선 · 0차곡) ·
|
||||
* 계류 100 m · 도로 300 m 띠 · 건물 · 지명).
|
||||
* 처음 켬 = 위성 · 등고선 · 세류 · 고른 값 = 화면 취향(`aislo:pref:d03.layers`) 기억.
|
||||
* 받기 = 켤 때만(한 번 받으면 다시 안 받음) · 지형을 안 받았으면 단추를 막음 · 받기 실패 = 그 겹만 꺼짐 + 알림.
|
||||
* 받기 = 켤 때 · 받은 지형 범위(`have`)가 바뀌면 다시(596-4 · 등고 · 세류 주소에 범위 지문) · 지형을 안 받았으면 단추를 막음 ·
|
||||
* 받기 실패 = 그 겹만 꺼짐 + 알림.
|
||||
* 그리기 차례(아래 → 위) = 위성(GL 조각만 · 옛 캔버스 = 위성 없음 · 590-2) → 음영기복 → 급경사 → 산사태위험지도 → 산림입지도 → 보호구역 → 띠 → 지적 → 물 면 →
|
||||
* 건물 → 등고선 → 도로 → 임도 짐작 → 올린 임도망도 → 세류 → 선 겹 → 산사태취약지역 → 지명 → 그은 선.
|
||||
* 겹 모두 = 바탕 캔버스(`base` · 582) · 등고선 = 줌 단계 색인 · 글 폭 기억.
|
||||
@@ -22,11 +23,11 @@
|
||||
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { createLegend } from "@ui/ui_template_flow_legend";
|
||||
import { pickFiles } from "@ui/ui_template_drop";
|
||||
import { createIconButton } from "@ui/ui_template_button";
|
||||
import { createMapTools } from "@ui/ui_template_map_tools";
|
||||
import { themeColor } from "@ui/ui_template_palette";
|
||||
import { fetchCachedSheetLayer } from "../A00_Common/b_asset_cache";
|
||||
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 {
|
||||
@@ -51,6 +52,7 @@ import {
|
||||
fetchPlanZeroOrder,
|
||||
fetchPlanZones,
|
||||
uploadPlanForestRoads,
|
||||
type PlanForestRoads,
|
||||
} from "./D03_PlanAnalysis_Api";
|
||||
import {
|
||||
FOREST_ROAD_KINDS,
|
||||
@@ -81,7 +83,7 @@ const glFont = (weight: string, text: string): Promise<unknown> =>
|
||||
const CANVAS_PREF_KEY = "aislo:pref:d03.canvas";
|
||||
/** 옛 캔버스 알림 — 위성 = GL 조각만(590-2). */
|
||||
const NO_SATELLITE_NOTE = "옛 그림은 위성 바탕 없이 그림";
|
||||
/** 등고선을 받아 올 그은 선 둘레(m). */
|
||||
/** 등고선을 받아 올 그은 선 둘레(m) — 받은 범위를 모를 때만. */
|
||||
const CONTOUR_PAD_M = 600;
|
||||
type DataKey = keyof Prepared;
|
||||
|
||||
@@ -94,8 +96,10 @@ export interface PlanLayers {
|
||||
legend: HTMLElement;
|
||||
/** 바탕 겹(아래 → 위 · 위성 뺌) — 그은 선 겹 아래에 둠. */
|
||||
layers: PlanMapLayer[];
|
||||
/** 지형(위성 틀)을 다시 읽음 — 처음 · 지형 받은 뒤. */
|
||||
/** 지형(위성 틀)을 다시 읽음 — 처음 · 지형 받은 뒤 · 받은 범위가 바뀌면 겹을 새로 받음. */
|
||||
load: () => Promise<void>;
|
||||
/** 임도망도 올리기(왼쪽 「위치」 칸) — 받으면 그 겹을 켬 · 못 받으면 알림 + null. */
|
||||
uploadRoads: (files: File[]) => Promise<PlanForestRoads | null>;
|
||||
/** 검증 창구 — 켬끔 · 받은 겹 · 겹별 수(지물 · 넓이 m²). */
|
||||
state: () => {
|
||||
on: Record<PlanLayerKey, boolean>;
|
||||
@@ -109,15 +113,17 @@ export interface PlanLayers {
|
||||
export function createPlanLayers(opts: {
|
||||
projectId: string;
|
||||
map: PlanMap;
|
||||
/** 등고선 받을 범위를 정할 점(시점 · 종점 · 그은 선). */
|
||||
/** 등고선 받을 범위를 정할 점(시점 · 종점 · 그은 선) — 받은 범위를 모를 때만. */
|
||||
points: () => Vertex[];
|
||||
/** 받은 지형 범위(작업 좌표계 m) — 바뀜 판정 · 등고 · 세류 주소 지문. */
|
||||
have: () => [number, number, number, number] | null;
|
||||
}): PlanLayers {
|
||||
const { projectId, map } = opts;
|
||||
const on = readPref(PREF_KEY);
|
||||
const data: Partial<Prepared> = {};
|
||||
const pending = new Map<DataKey | "satellite", Promise<void>>();
|
||||
let uploading = false;
|
||||
let meta: VWorldMeta | null = null;
|
||||
let loadedHave = "";
|
||||
/** 음영 GL 그림 — 그대로 · 위성 위 그늘(「곱하기」 꼴). */
|
||||
let hillshade: { plain: GlImage; shade: GlImage } | null = null;
|
||||
const gl = createGlBase(projectId, (why) => {
|
||||
@@ -142,19 +148,6 @@ export function createPlanLayers(opts: {
|
||||
return button;
|
||||
}),
|
||||
);
|
||||
const upload = createIconButton({
|
||||
icon: "upload",
|
||||
title: "임도망도 SHP 올리기(시군구 zip 하나 · 또는 shp · shx · dbf · prj 함께 · 100 MB 까지)",
|
||||
size: 32,
|
||||
onClick: () =>
|
||||
pickFiles(
|
||||
{ accept: ".zip,.shp,.shx,.dbf,.prj,.cpg", multiple: true },
|
||||
(files) => void uploadRoads(files),
|
||||
),
|
||||
});
|
||||
upload.dataset.layer = "forestRoadUpload";
|
||||
const roadGroup = groups.find((group) => group.includes(buttons.get("forestRoadFile")!))!;
|
||||
roadGroup.splice(roadGroup.indexOf(buttons.get("forestRoadFile")!) + 1, 0, upload);
|
||||
const glButton = createIconButton({
|
||||
icon: "gauge",
|
||||
title: "그래픽 카드 그림(바탕 겹 · 글을 GPU 로 · 끔 = 옛 캔버스 그림)",
|
||||
@@ -203,7 +196,6 @@ export function createPlanLayers(opts: {
|
||||
button.title = title;
|
||||
button.setAttribute("aria-label", title);
|
||||
}
|
||||
upload.disabled = !meta || uploading;
|
||||
for (const def of LAYERS) gl.setVisible(def.key, on[def.key]);
|
||||
gl.setData("hillshade", hillshade && (on.satellite ? hillshade.shade : hillshade.plain));
|
||||
const rows = meta ? legendRows(on, data) : [];
|
||||
@@ -249,16 +241,13 @@ export function createPlanLayers(opts: {
|
||||
return job;
|
||||
}
|
||||
|
||||
/** 올리기 — 받으면 그 겹을 켜고 바로 그림 · 못 받으면 옛 것 그대로 + 서버 까닭 알림. */
|
||||
async function uploadRoads(files: File[]): Promise<void> {
|
||||
if (!meta) return;
|
||||
/** 올리기 — 받으면 그 겹을 켜고 바로 그림(지형 받기 전이면 받은 뒤 그림) · 못 받으면 옛 것 그대로 + 서버 까닭 알림. */
|
||||
async function uploadRoads(files: File[]): Promise<PlanForestRoads | null> {
|
||||
const at = meta;
|
||||
const job = uploadPlanForestRoads(projectId, files).then((info) => {
|
||||
if (meta !== at) return;
|
||||
data.forestRoadFile = {
|
||||
layer: prepareLayer(info.collection, createNormalizer(at)),
|
||||
info,
|
||||
};
|
||||
if (meta !== at) return info;
|
||||
if (at)
|
||||
data.forestRoadFile = { layer: prepareLayer(info.collection, createNormalizer(at)), info };
|
||||
gl.setData("forestRoadFile", info.collection);
|
||||
on.forestRoadFile = true;
|
||||
writeByKey(PREF_KEY, JSON.stringify(on));
|
||||
@@ -266,25 +255,35 @@ export function createPlanLayers(opts: {
|
||||
`임도망도 받음 — 범위 안 ${info.count}가닥 ${(info.length_m / 1000).toFixed(2)} km (파일 ${info.total}가닥)`,
|
||||
"success",
|
||||
);
|
||||
return info;
|
||||
});
|
||||
uploading = true;
|
||||
pending.set("forestRoadFile", job);
|
||||
sync();
|
||||
pending.set(
|
||||
"forestRoadFile",
|
||||
job.then(() => undefined),
|
||||
);
|
||||
try {
|
||||
await job;
|
||||
return await job;
|
||||
} catch (error) {
|
||||
showToast(
|
||||
`임도망도 올리지 못함 — ${error instanceof Error ? error.message : String(error)}`,
|
||||
"error",
|
||||
);
|
||||
return null;
|
||||
} finally {
|
||||
uploading = false;
|
||||
pending.delete("forestRoadFile");
|
||||
sync();
|
||||
map.redrawBase();
|
||||
}
|
||||
}
|
||||
|
||||
/** 받은 범위 지문 — 등고 · 세류 주소에 붙여 브라우저 보관함이 옛 범위를 안 줌. */
|
||||
const haveTag = (): string => (opts.have() ?? []).map((v) => Math.round(v)).join("_");
|
||||
const sheetLayer = (layer: string, extra = ""): Promise<GeoJsonCollection> =>
|
||||
fetchCachedJson<GeoJsonCollection>(
|
||||
projectId,
|
||||
`${API_BASE_URL}/projects/${projectId}/geojson?layer=${encodeURIComponent(layer)}${extra}&v=${haveTag()}`,
|
||||
);
|
||||
|
||||
async function fetchLayer(key: PlanLayerKey, at: VWorldMeta): Promise<void> {
|
||||
const normalizer = createNormalizer(at);
|
||||
const prepare = (collection: GeoJsonCollection, labels?: string[]): PreparedLayer =>
|
||||
@@ -297,26 +296,26 @@ export function createPlanLayers(opts: {
|
||||
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 = [
|
||||
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 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([
|
||||
fetchCachedSheetLayer<GeoJsonCollection>(projectId, "도엽_등고선", "route", bbox.join(",")),
|
||||
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 fetchCachedSheetLayer<GeoJsonCollection>(
|
||||
projectId,
|
||||
"도엽_하천중심선",
|
||||
);
|
||||
const collection = await sheetLayer("도엽_하천중심선");
|
||||
data.stream = [
|
||||
prepare(pick(collection, (props) => props["구분"] === "세류")),
|
||||
prepare(pick(collection, (props) => props["구분"] !== "세류")),
|
||||
@@ -491,10 +490,13 @@ export function createPlanLayers(opts: {
|
||||
|
||||
async function load(): Promise<void> {
|
||||
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 = next;
|
||||
JSON.stringify(meta && [meta.x_min, meta.x_max, meta.y_min, meta.y_max]);
|
||||
[meta, loadedHave] = [next, have];
|
||||
if (changed) {
|
||||
for (const key of Object.keys(data)) delete data[key as DataKey];
|
||||
hillshade = null;
|
||||
@@ -511,6 +513,7 @@ export function createPlanLayers(opts: {
|
||||
legend,
|
||||
layers,
|
||||
load,
|
||||
uploadRoads,
|
||||
state: () => ({
|
||||
on: { ...on },
|
||||
loaded: LAYERS.map((def) => def.key).filter(loadedOf),
|
||||
|
||||
@@ -19,8 +19,9 @@
|
||||
import "./D03_PlanAnalysis_UI_Style.css";
|
||||
import { ROUTES } from "@config/config_frontend";
|
||||
import { attachCollapsible, createPanelCard } from "@ui/ui_template_collapsible";
|
||||
import { pickFiles } from "@ui/ui_template_drop";
|
||||
import { askConfirm } from "@ui/ui_template_modal";
|
||||
import { createProgressBar } from "@ui/ui_template_progress";
|
||||
import { createProgressCircle } from "@ui/ui_template_progress";
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
import {
|
||||
createButton,
|
||||
@@ -70,10 +71,8 @@ import {
|
||||
import { lonLatToTm, tmParams } from "./logic/D03_PlanAnalysis_Tm";
|
||||
|
||||
const POLL_MS = 1000;
|
||||
/** 지도 보기 가운데(보기의 반)가 받은 범위 밖이면 이어 받음 — 그 상자가 이보다 넓으면(멀리 줄여 봄) 안 받음(m). */
|
||||
const VIEW_FETCH_MAX_M = 2000;
|
||||
/** 지도 보기 바뀐 뒤 이만큼 멈추면 범위를 봄(ms). */
|
||||
const VIEW_SETTLE_MS = 800;
|
||||
/** 받기가 끊김(서버 다시 켜짐 · 진행을 못 읽음) — [다시 받기]. */
|
||||
const INTERRUPTED = "끊김(서버 다시 켜짐 등)";
|
||||
/** 처음 맞춤 = 기준점 둘레(서버 `SITE_RADIUS_M`). */
|
||||
const SITE_RADIUS_M = 1000;
|
||||
/** 되돌리기 쌓는 수. */
|
||||
@@ -109,9 +108,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
let redoStack: Snap[] = [];
|
||||
let surface: PlanSurface | null = null;
|
||||
let have: [number, number, number, number] | null = null;
|
||||
let viewCore: Vertex[] = []; // 지도 보기 가운데 상자 두 귀(넓으면 빈 것)
|
||||
let viewMid: Vertex | null = null; // 지도 보기 가운데(지점 [추가] 자리)
|
||||
let viewTimer = 0;
|
||||
let lastTried = ""; // 같은 받기를 거듭하지 않음
|
||||
let ready = false; // 들어올 때 받기 판단이 끝남(그 앞 보기 바뀜은 안 받음)
|
||||
let busy = false;
|
||||
@@ -166,30 +163,11 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
idle: () => draw.mode() === "idle" && !measuring,
|
||||
editable: () => editable,
|
||||
});
|
||||
// 지도 보기 가운데 상자 — 그릴 때마다 보이는 화면 가운데 반을 작업 좌표로(바뀌면 잠시 뒤 범위 봄)
|
||||
// 지도 보기 가운데 — 그릴 때마다 작업 좌표로(지점 [추가] 자리 · 보기만 옮김 = 안 받음 596-4)
|
||||
const viewWatch: PlanMapLayer = {
|
||||
draw(context, screen) {
|
||||
const [w, h] = [context.canvas.clientWidth, context.canvas.clientHeight];
|
||||
const corners = [
|
||||
[w / 4, h / 4],
|
||||
[(3 * w) / 4, h / 4],
|
||||
[(3 * w) / 4, (3 * h) / 4],
|
||||
[w / 4, (3 * h) / 4],
|
||||
].map(([x, y]) => screen.toMetric(...screen.unrotate(x, y)));
|
||||
const xs = corners.map((c) => c[0]);
|
||||
const ys = corners.map((c) => c[1]);
|
||||
viewMid = w ? screen.toMetric(...screen.unrotate(w / 2, h / 2)) : null;
|
||||
const box: Vertex[] = [
|
||||
[Math.min(...xs), Math.min(...ys)],
|
||||
[Math.max(...xs), Math.max(...ys)],
|
||||
];
|
||||
const wide =
|
||||
box[1][0] - box[0][0] > VIEW_FETCH_MAX_M || box[1][1] - box[0][1] > VIEW_FETCH_MAX_M;
|
||||
const next = wide || !w ? [] : box;
|
||||
if (JSON.stringify(next) === JSON.stringify(viewCore)) return;
|
||||
viewCore = next;
|
||||
clearTimeout(viewTimer);
|
||||
viewTimer = window.setTimeout(() => void ensureCovered(), VIEW_SETTLE_MS);
|
||||
},
|
||||
};
|
||||
const map = createPlanMap({
|
||||
@@ -202,16 +180,18 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
projectId,
|
||||
map,
|
||||
points: () => [...points, ...allVertices()],
|
||||
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");
|
||||
main.className = "d03-plan";
|
||||
main.append(map.root);
|
||||
|
||||
const terrainText = createText("", "muted");
|
||||
terrainText.classList.add("d03-plan__hint");
|
||||
const bar = createProgressBar();
|
||||
bar.root.hidden = true;
|
||||
const retryButton = createButton({
|
||||
label: "다시 받기",
|
||||
variant: "filled",
|
||||
@@ -222,7 +202,18 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
},
|
||||
});
|
||||
const terrainCard = createPanelCard("지형", false);
|
||||
terrainCard.append(terrainText, bar.root, retryButton);
|
||||
const roadButton = createButton({
|
||||
label: "임도망 올리기",
|
||||
variant: "ghost",
|
||||
size: 28,
|
||||
onClick: () =>
|
||||
pickFiles({ accept: ".zip,.shp,.shx,.dbf,.prj,.cpg", multiple: true }, (files) => {
|
||||
void backdrop.uploadRoads(files);
|
||||
}),
|
||||
});
|
||||
roadButton.title =
|
||||
"임도망도 SHP(시군구 zip 하나 · 또는 shp · shx · dbf · prj 함께 · 100 MB 까지)";
|
||||
terrainCard.append(terrainText, retryButton, roadButton);
|
||||
const pointCard = createPanelCard("지점", false);
|
||||
pointCard.append(marks.panel);
|
||||
const drawCard = createPanelCard("그리기", false);
|
||||
@@ -322,13 +313,12 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
list.every(([x, y]) => x >= have![0] && x <= have![2] && y >= have![1] && y <= have![3]);
|
||||
const outside = (): boolean => !!have && !inside([...points, ...allVertices()]);
|
||||
|
||||
/** 조용히 받기(요구 453) — 지점 · 선 · 지도 보기 가운데가 받은 범위 밖이면 이어 받음 ·
|
||||
* 단추 · 막는 창 없음 · 실패 = [다시 받기] · 같은 요청은 한 번만. */
|
||||
/** 조용히 받기(요구 453) — 지점 · 선이 받은 범위 밖이면 이어 받음(지도 보기만 옮김 = 안 받음 596-4) ·
|
||||
* 단추 · 막는 창 없음 · 실패 · 끊김 = [다시 받기] · 같은 요청은 한 번만. */
|
||||
async function ensureCovered(): Promise<void> {
|
||||
if (!ready || !editable || busy || failed || !epsg || !root.isConnected) return;
|
||||
const need = [...points, ...allVertices()];
|
||||
if (inside(need) && inside(viewCore)) return; // 받을 때 서버가 여유를 더함
|
||||
const extra = [...need, ...viewCore];
|
||||
const extra = [...points, ...allVertices()];
|
||||
if (inside(extra)) return; // 받을 때 서버가 여유를 더함
|
||||
const key = JSON.stringify([have, extra]);
|
||||
if (key === lastTried) return;
|
||||
lastTried = key;
|
||||
@@ -349,6 +339,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
props.render();
|
||||
marks.render();
|
||||
retryButton.hidden = !editable || busy || !failed;
|
||||
roadButton.hidden = !editable;
|
||||
if (!busy)
|
||||
terrainText.textContent = failed
|
||||
? `지형 받기 실패 — ${failed}`
|
||||
@@ -387,29 +378,32 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
await ensureCovered(); // 받는 동안 옮긴 지점 · 선 · 보기
|
||||
}
|
||||
|
||||
/** 지형 받기를 끝날 때까지 지켜봄 — 진행은 지형 칸에. */
|
||||
/** 지형 받기를 끝날 때까지 지켜봄 — 진행 = 지도 귀퉁이 서클 · 끊김(서버 다시 켜짐 = 끝 아닌데 멎음 ·
|
||||
* 진행을 못 읽음) = 실패 꼴 + [다시 받기]. */
|
||||
async function watch(): Promise<PlanJob | null> {
|
||||
busy = true;
|
||||
bar.root.hidden = false;
|
||||
circle.set(null, "지형");
|
||||
circle.root.hidden = false;
|
||||
refresh();
|
||||
try {
|
||||
for (;;) {
|
||||
if (!root.isConnected) return null;
|
||||
const job = (await fetchPlanProgress(projectId)).terrain;
|
||||
bar.set(job.percent / 100);
|
||||
terrainText.textContent = `지형 받기 ${job.percent}% · ${job.message ?? ""}`;
|
||||
circle.set(job.percent / 100, `지형 ${job.message ?? ""}`.trim());
|
||||
circle.root.title = `지형 받기 ${job.percent}% · ${job.message ?? ""}`;
|
||||
if (!job.running) {
|
||||
const cut = !job.error && (job.interrupted || job.percent < 100);
|
||||
if (job.error) showToast(job.error, "error");
|
||||
return job;
|
||||
return cut ? { ...job, error: INTERRUPTED } : job;
|
||||
}
|
||||
await sleep(POLL_MS);
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(`진행을 읽지 못했습니다 — ${errorText(error)}`, "error");
|
||||
return null;
|
||||
return { running: false, percent: 0, message: null, error: INTERRUPTED };
|
||||
} finally {
|
||||
busy = false;
|
||||
bar.root.hidden = true;
|
||||
circle.root.hidden = true;
|
||||
refresh();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,8 @@ def test_d03_parts():
|
||||
draw = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts")
|
||||
tools = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts")
|
||||
layers = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts")
|
||||
assert 'createElement("progress")' not in ts and "createProgressBar()" in ts
|
||||
assert 'createElement("progress")' not in ts and "createProgressBar(" not in ts
|
||||
assert "createProgressCircle({ size: 40, corner: true })" in ts # 596-3 받기 = 지도 귀퉁이 서클
|
||||
# 562-3 — 날 단추 · 입력 · 고르기 없음 · 도구줄 · 제약 칸 · 연도 고름은 부품
|
||||
for src in (ts, draw, tools, layers):
|
||||
for raw in ('createElement("button")', 'createElement("input")', 'createElement("select")'):
|
||||
|
||||
@@ -19,6 +19,15 @@
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* 오른쪽 아래 귀퉁이 — 지도 가운데(그리는 자리)를 안 가림 · 글은 원 왼쪽 */
|
||||
.ui-progress-circle--corner {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
right: var(--spacing-8);
|
||||
bottom: var(--spacing-8);
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.ui-progress-circle__dial {
|
||||
position: relative;
|
||||
width: var(--ui-progress-size);
|
||||
|
||||
@@ -4,6 +4,7 @@ import "./ui_template_progress.css";
|
||||
* ui_template_progress.ts
|
||||
* 부품 「진행 막대」(552-3 · 목록 30) — 막대 · 쌓임 막대 · 원.
|
||||
* 꼴 = 막대(`createProgressBar` · 값 하나) · 쌓임 막대(`parts` · 갈래 셋 accent · soft · warn) · 원(`createProgressCircle` · 아래)
|
||||
* · 원 자리 = 흐름(기본) · 가운데 덮음(`overlay`) · 오른쪽 아래 귀퉁이(`corner` · 글은 원 왼쪽)
|
||||
* 크기 = 막대 굵기 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" }] })`
|
||||
@@ -28,6 +29,8 @@ export interface ProgressCircleOptions {
|
||||
label?: string;
|
||||
/** 컨테이너 정중앙에 띄우는 오버레이로 만든다(컨테이너는 position: relative 여야 한다). */
|
||||
overlay?: boolean;
|
||||
/** 컨테이너 오른쪽 아래 귀퉁이에 작게 띄운다(가운데를 안 가림 · 컨테이너는 position: relative). */
|
||||
corner?: boolean;
|
||||
}
|
||||
|
||||
export interface ProgressCircleHandle {
|
||||
@@ -40,7 +43,10 @@ export interface ProgressCircleHandle {
|
||||
|
||||
export function createProgressCircle(options: ProgressCircleOptions = {}): ProgressCircleHandle {
|
||||
const root = document.createElement("div");
|
||||
root.className = "ui-progress-circle" + (options.overlay ? " ui-progress-circle--overlay" : "");
|
||||
root.className =
|
||||
"ui-progress-circle" +
|
||||
(options.overlay ? " ui-progress-circle--overlay" : "") +
|
||||
(options.corner ? " ui-progress-circle--corner" : "");
|
||||
root.setAttribute("role", "status");
|
||||
root.setAttribute("aria-live", "polite");
|
||||
if (options.size) root.style.setProperty("--ui-progress-size", `${options.size}px`);
|
||||
|
||||
@@ -337,9 +337,10 @@ export const KIT_PARTS: KitPart[] = [
|
||||
demo: (s) => (s === "default" ? col(createKv([{ label: "연장", value: "1,080 m", big: true }, { label: "종단 경사", value: "12 %", ok: true }, { label: "최소 곡선반경", value: "12 m", ok: false }, { label: "비고", value: null }]), createKv([{ label: kitKeys("Ctrl", "Z"), value: "되돌리기" }, { label: kitKeys("휠 끌기"), value: "화면 옮기기" }, { label: kitKeys("Esc") }], { rule: true })) : null) },
|
||||
{ key: "calc-row", name: "계산 줄", group: "compound", kinds: "값 줄(이름 · 값 112 · 덧칸) · 식 줄(이름 · 식 · 값) · 위 · 아래첨자 · 중요 결과 굵게", file: "ui_template_calc_row.ts",
|
||||
demo: (s) => (s === "default" ? kitCalc() : null) },
|
||||
{ key: "progress", name: "진행 막대", group: "compound", kinds: "막대 6 · 8 · 쌓임 막대 · 원 · 오류", file: "ui_template_progress.ts",
|
||||
{ key: "progress", name: "진행 막대", group: "compound", kinds: "막대 6 · 8 · 쌓임 막대 · 원 · 원 귀퉁이(corner) · 오류", file: "ui_template_progress.ts",
|
||||
demo: (s) => { if (s === "error") return markState(createProgressBar({ value: 0.6, label: "실패" }).root, s); if (s !== "default") return null; const ring = createProgressCircle({ size: 48 }); ring.set(0.4);
|
||||
return col(createProgressBar({ value: 0.4, label: "40 %" }).root, createProgressBar({ value: 0.7, thick: 6 }).root, createProgressBar({ parts: [{ value: 2, tone: "soft" }, { value: 3 }, { value: 1, tone: "warn" }], label: "6 건" }).root, ring.root); } },
|
||||
const corner = createProgressCircle({ size: 40, corner: true }); corner.set(0.65, "지형 65%"); const box = document.createElement("div"); box.style.cssText = "position:relative;height:72px;border:1px dashed var(--color-border);border-radius:var(--radius-inputs)"; box.append(corner.root);
|
||||
return col(createProgressBar({ value: 0.4, label: "40 %" }).root, createProgressBar({ value: 0.7, thick: 6 }).root, createProgressBar({ parts: [{ value: 2, tone: "soft" }, { value: 3 }, { value: 1, tone: "warn" }], label: "6 건" }).root, ring.root, box); } },
|
||||
{ key: "mini-chart", name: "작은 그래프", group: "compound", kinds: "막대 · 원 · 선", file: "ui_template_elements_chart.ts",
|
||||
demo: (s) => (s === "default" ? col(createBarChart([{ label: "산림설계", value: 12 }, { label: "녹색기술", value: 7 }, { label: "그 밖 3 곳", value: 4 }]), createDonut([{ label: "임도", value: 5 }, { label: "예정노선", value: 3 }, { label: "안정성", value: 2 }]), createLineChart({ series: [{ name: "CPU", values: [20, 35, 30, 50, 42, 60], colorIndex: 0 }], height: 120 })) : null) },
|
||||
{ key: "legend", name: "범례", group: "compound", kinds: "색 칩 · 선 · 점선 · 점 · 꺼짐 · 세로", file: "ui_template_flow_legend.ts",
|
||||
|
||||
Reference in New Issue
Block a user