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:
2026-10-30 21:54:36 +09:00
co-authored by Claude Opus 5.5
9 changed files with 532 additions and 267 deletions
+43 -3
View File
@@ -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)
+227 -228
View File
@@ -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 {
@@ -85,7 +84,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 글꼴 미리 받음 — 받기 전에 그린 글자는 대체 글꼴로 굳음(못 받아도 그림). */
@@ -95,8 +93,6 @@ const glFont = (weight: string, text: string): Promise<unknown> =>
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;
/** 겹 → 받는 자료(임도 짐작 = 도로 자료). */
@@ -108,8 +104,8 @@ export interface PlanLayers {
legend: HTMLElement;
/** 바탕 겹(아래 → 위 · 위성 뺌) — 그은 선 겹 아래에 둠. */
layers: PlanMapLayer[];
/** 지형(위성 틀)을 다시 읽음 — 처음 · 지형 받은 뒤 · 받은 범위가 바뀌면 겹을 새로 받음. */
load: () => Promise<void>;
/** 지형(위성 틀)을 다시 읽음 — 처음 · 지형 받은 뒤 · 받은 범위(`have` · 지형면을 다 읽은 뒤 값)가 바뀌면 켠 겹 모두 새로. */
load: (have: [number, number, number, number] | null) => Promise<void>;
/** 임도망도 올리기(왼쪽 「위치」 칸) — 받으면 그 겹을 켬 · 못 받으면 알림 + null. */
uploadRoads: (files: File[]) => Promise<PlanForestRoads | null>;
/** 올린 묶음 하나 지움(605-1) — 지도에서 바로 빠짐 · 못 지우면 알림 + 거짓. */
@@ -123,23 +119,26 @@ export interface PlanLayers {
counts: Record<string, number>;
/** GL 겹마다 화면에 그린 지물 수(보임만 · GL 끔 = 빈 것). */
gl: Record<string, number>;
/** 겹이 받은 범위(마지막 `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: <T>(job: Promise<T>) => Promise<T>;
}): PlanLayers {
const { projectId, map } = opts;
const { projectId, map, track } = opts;
const on = readPref(PREF_KEY);
const data: Partial<Prepared> = {};
const pending = new Map<DataKey | "satellite", Promise<void>>();
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) => {
@@ -274,14 +273,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<void> = 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))
@@ -290,7 +290,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;
}
@@ -298,7 +298,7 @@ export function createPlanLayers(opts: {
/** 올리기 — 받으면 그 겹을 켜고 바로 그림(지형 받기 전이면 받은 뒤 그림) · 못 받으면 옛 것 그대로 + 서버 까닭 알림. */
async function uploadRoads(files: File[]): Promise<PlanForestRoads | null> {
const at = meta;
const job = uploadPlanForestRoads(projectId, files).then((info) => {
const job = track(uploadPlanForestRoads(projectId, files)).then((info) => {
if (meta !== at) return info;
applyRoads(info);
showToast(
@@ -404,209 +404,206 @@ export function createPlanLayers(opts: {
else 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()}`,
);
/** 겹 하나 받기 — 자료 · GL 은 모아 두었다가 지금 차례일 때만 씀(옛 받은 범위 답 버림 · 475). */
async function fetchLayer(key: PlanLayerKey, at: VWorldMeta, mine: number): Promise<void> {
const got: Partial<Prepared> = {};
const sets: Array<Parameters<typeof gl.setData>> = [];
const put = (...args: Parameters<typeof gl.setData>): 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<void> {
const normalizer = createNormalizer(at);
const prepare = (collection: GeoJsonCollection, labels?: string[]): PreparedLayer =>
prepareLayer(collection, normalizer, labels);
const pick = (
collection: GeoJsonCollection,
test: (props: Record<string, unknown>) => 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<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,
async function fill(): Promise<void> {
const normalizer = createNormalizer(at);
const prepare = (collection: GeoJsonCollection, labels?: string[]): PreparedLayer =>
prepareLayer(collection, normalizer, labels);
const pick = (
collection: GeoJsonCollection,
test: (props: Record<string, unknown>) => 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<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", {
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);
}
}
}
@@ -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);
// 바뀜 = 위성 틀 또는 받은 범위(같은 도엽 안에서 늘어도 겹을 새로 · 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)
@@ -674,6 +671,8 @@ export function createPlanLayers(opts: {
: gl.map()!.queryRenderedFeatures({ layers: [layer.id] }).length,
]),
),
area,
turn: areaTurn,
}),
};
}
+31 -19
View File
@@ -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<void> {
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);
@@ -243,6 +243,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
},
});
const siteCard = createPanelCard("위치", false);
siteCard.firstElementChild!.append(circle.root);
siteCard.append(...siteBox.parts);
const pointCard = createPanelCard("지점", false);
pointCard.append(marks.panel);
@@ -317,6 +318,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
({ network, points, pointNames, site } = last);
keepDraft();
refresh();
void ensureCovered(); // 영역 루틴 — 되돌린 지점 · 선도 받은 범위 밖이면 받음
}
function redo(): void {
@@ -326,6 +328,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
({ network, points, pointNames, site } = next);
keepDraft();
refresh();
void ensureCovered();
}
function setMeasure(on: boolean): void {
@@ -379,9 +382,11 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
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,
});
@@ -454,12 +459,14 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
if (info) fitTo([...around(site), ...boxOfLonLat(epsg, info.bounds)]);
}
/** 도는 받기를 지켜보고 끝나면 지형 · 바탕을 다시 읽음. */
/** 도는 받기를 지켜보고 끝나면 지형면 · 받은 범위를 먼저 다 읽은 뒤 겹(475 — 동시면 겹이 옛 범위로 판정). */
async function follow(): Promise<void> {
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(); // 받는 동안 옮긴 지점 · 선 · 보기
}
@@ -468,18 +475,22 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
watchTerrain({
projectId,
alive: () => root.isConnected,
circle,
work,
setBusy: (on) => {
busy = on;
refresh();
},
});
/** 지형면 · 받은 범위(have) — 서클에 셈. */
async function loadSurface(): Promise<void> {
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");
}
@@ -573,6 +584,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
base: () => points,
have: () => have,
busy: () => busy,
work: work.count,
site: () => site,
pickSite,
lineNames: () => lineNames(network.lines),
@@ -636,7 +648,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
refresh();
await loadSurface();
refresh();
await backdrop.load();
await backdrop.load(have);
const job = (await fetchPlanProgress(projectId)).terrain;
if (job.running) {
ready = true;
+57 -11
View File
@@ -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: <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 {
projectId: string;
alive: () => boolean;
circle: ReturnType<typeof createProgressCircle>;
work: WorkCircle;
/** 받는 중 켬끔(페이지 `busy`) 뒤 화면 갱신. */
setBusy: (on: boolean) => void;
}
/** 지형 받기를 끝날 때까지 지켜봄 — 진행 = 지도 귀퉁이 서클 · 끊김(서버 다시 켜짐 = 끝 아닌데 멎음 ·
/** 지형 받기를 끝날 때까지 지켜봄 — 진행 = 위치 칸 서클 · 끊김(서버 다시 켜짐 = 끝 아닌데 멎음 ·
* 진행을 못 읽음) = 실패 꼴 + [다시 받기]. */
export async function watchTerrain(params: WatchParams): Promise<PlanJob | null> {
const { projectId, alive, circle, setBusy } = params;
export function watchTerrain(params: WatchParams): Promise<PlanJob | null> {
return params.work.track(watching(params));
}
async function watching(params: WatchParams): Promise<PlanJob | null> {
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<PlanJob | null>
showToast(`진행을 읽지 못했습니다 — ${errorText(error)}`, "error");
return { running: false, percent: 0, message: null, error: INTERRUPTED };
} finally {
circle.root.hidden = true;
work.terrain(null);
setBusy(false);
}
}
@@ -929,3 +929,7 @@ resource폴더에 미리 계산해서 저장한고 필요한 시점에 사용한
## 요구 491 (10-30 · 사용자)
일단 진행해주는데 조심해야된느 부분은 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
+1 -1
View File
@@ -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 등록 찾기 = 같은 부품
+26
View File
@@ -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);
+12 -5
View File
@@ -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");