perf(D03): 0차곡 무거운 셈 일꾼으로 · 급경사 값 그림 바이너리 · 칠하기 toDataURL (PLAN 616-3 · 616-4)

- 0차곡 길 = 창고 선으로 바로(스레드) · 셈 필요하면 서버 본체 밖 일꾼 · 응답 = json.dumps 바로(큰 선 묶음 jsonable_encoder 건넘)
- 급경사 = PNG 바이트 + 머리 X-Plan-Raster(칸 수 · 범위) · Content-Encoding identity(PNG 다시 gzip 안 함) · 음영기복 · 산사태 = JSON 그대로
- Api fetchPlanRaster 급경사만 바이너리(blob 주소 · slopeValues 가 다 읽고 놓음)
- 칠하기 = canvas.toDataURL(toBlob 한가 작업 기다림 최대 1 s 없앰)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 19:18:52 +09:00
co-authored by Claude Opus 5.5
parent 66f9c68a1e
commit 5d6d7bbb83
4 changed files with 43 additions and 19 deletions
+14 -2
View File
@@ -212,10 +212,22 @@ export const fetchPlanSheetTile = (
): Promise<GeoJsonCollection> =>
request(`${base(projectId)}/sheet-tiles/${sheet}/${encodeURIComponent(name)}`);
export const fetchPlanRaster = (
/** 그림 겹 — 급경사 = PNG 바이트 + 머리 `X-Plan-Raster`(616-4 · `image` = blob 주소 · 다 읽으면
* `slopeValues` 가 놓음) · 그 밖 = JSON(base64 그림). */
export const fetchPlanRaster = async (
projectId: string,
kind: "slope" | "hillshade" | "landslide",
): Promise<PlanRaster> => request(`${base(projectId)}/layers/raster?kind=${kind}`);
): Promise<PlanRaster> => {
const path = `${base(projectId)}/layers/raster?kind=${kind}`;
if (kind !== "slope") return request(path);
const response = await fetch(`${API_BASE_URL}${path}`, { credentials: "include" });
if (!response.ok) {
const body = (await response.json().catch(() => ({}))) as { detail?: unknown };
throw new Error(typeof body.detail === "string" ? body.detail : `HTTP ${response.status}`);
}
const meta = JSON.parse(response.headers.get("X-Plan-Raster") ?? "{}") as PlanRaster;
return { ...meta, image: URL.createObjectURL(await response.blob()) };
};
/** 보호 · 규제 구역(581-1) — 지물 `kind` · `name` · `area_m2` + 종류별 개수 · 넓이(실패 = `failed`). */
export interface PlanZones extends GeoJsonCollection {
@@ -281,6 +281,12 @@ def slope_layer(
return value
def raster_bytes(value: dict[str, Any]) -> tuple[bytes, dict[str, Any]]:
"""저장본 값 그림(base64 PNG 글) → (PNG 바이트, 나머지 칸) — 바이너리 전송(616-4)."""
meta = {k: v for k, v in value.items() if k != "image"}
return base64.b64decode(value["image"].split(",", 1)[1]), meta
def lonlat_corners(box: Any, epsg: int) -> list[list[float]]:
"""TM 상자 → 경위도 네 귀퉁이(왼쪽 아래부터 돎) — GL 바탕 그림 자리(586)."""
from common_util.common_util_crs import tm_to_lonlat
+18 -8
View File
@@ -399,8 +399,9 @@ async def plan_layer_cadastral(project_id: UUID, request: Request) -> Response:
@router.get("/{project_id}/plan/layers/raster", response_model=None)
async def plan_layer_raster(project_id: UUID, kind: str = "slope") -> dict[str, Any]:
"""급경사 기울기 % 값 그림 + 1 % 칸 수(603) · 음영기복(`hillshade`) — 격자 지문 같으면 저장본 ·
async def plan_layer_raster(project_id: UUID, kind: str = "slope") -> dict[str, Any] | Response:
"""급경사 기울기 % 값 그림 + 1 % 칸 수(603 · PNG 바이트 + 머리 `X-Plan-Raster` 616-4) ·
음영기복(`hillshade`) — 격자 지문 같으면 저장본 ·
산사태위험지도(`landslide` · 행안부 WMS) — 받은 범위를 덮으면 저장본."""
if kind == "landslide":
root, epsg, box = await _layer_root(project_id)
@@ -422,6 +423,10 @@ async def plan_layer_raster(project_id: UUID, kind: str = "slope") -> dict[str,
)
except FileNotFoundError as error:
raise HTTPException(status_code=404, detail=str(error)) from error
if kind == "slope":
png, meta = layers.raster_bytes(value)
head = {"X-Plan-Raster": json.dumps({"kind": kind, **meta}), "Content-Encoding": "identity"}
return Response(png, media_type="image/png", headers=head) # PNG = 다시 압축 안 함
return {"status": "success", "kind": kind, **value}
@@ -479,18 +484,23 @@ async def plan_layer_zero_order(
project_id: UUID,
min_m2: float = zero_order.ZERO_ORDER_MIN_M2,
max_m2: float = zero_order.ZERO_ORDER_MAX_M2,
) -> dict[str, Any]:
"""0차곡 선(지형 추정 · 위 집수 `min_m2` ~ `max_m2`) — 격자 지문 · 임계 같으면 저장본."""
) -> Response:
"""0차곡 선(지형 추정 · 위 집수 `min_m2` ~ `max_m2`) — 창고 도엽 선 · 다른 임계는 저장본 ·
창고에 없어 셈이 필요하면 서버 본체 밖 일꾼(616-3 · 다른 겹 요청을 안 막음)."""
if not 0 < min_m2 < max_m2:
raise HTTPException(status_code=400, detail="0 < min_m2 < max_m2 여야 합니다.")
root, epsg, _ = await _layer_root(project_id)
args = (root, terrain.models_dir_of(root), epsg, min_m2, max_m2)
pool = zero_order.heavy_pool()
try:
value = await asyncio.to_thread(
zero_order.zero_order_layer, root, terrain.models_dir_of(root), epsg, min_m2, max_m2
)
value = await asyncio.to_thread(zero_order.zero_order_layer, *args, False)
if value is None:
loop = asyncio.get_running_loop()
value = await loop.run_in_executor(pool, zero_order.zero_order_layer, *args)
except FileNotFoundError as error:
raise HTTPException(status_code=404, detail=str(error)) from error
return {"status": "success", **value}
body = json.dumps({"status": "success", **value}, ensure_ascii=False) # 큰 선 묶음 = 바로 글로
return Response(body, media_type="application/json")
async def _road_frame(project_id: UUID) -> tuple[Path, int, terrain.Box, tuple[float, float]]:
@@ -35,17 +35,19 @@ export const readSlopeBands = (): SlopeBand[] => parseBands(readByKey(PREF_KEY))
export const writeSlopeBands = (bands: SlopeBand[]): void =>
writeByKey(PREF_KEY, JSON.stringify(bands));
/** 값 그림 → 칸 값(빨강 자리 = 회색 값). */
/** 값 그림 → 칸 값(빨강 자리 = 회색 값) · 받은 blob 주소는 다 읽었으니 놓음(616-4). */
export function slopeValues(image: HTMLImageElement): ImageData {
const canvas = document.createElement("canvas");
canvas.width = image.naturalWidth;
canvas.height = image.naturalHeight;
const context = canvas.getContext("2d", { willReadFrequently: true })!;
context.drawImage(image, 0, 0);
if (image.src.startsWith("blob:")) URL.revokeObjectURL(image.src);
return context.getImageData(0, 0, canvas.width, canvas.height);
}
/** 범위대로 칠한 그림(주소 = blob · 다 쓰면 부르는 쪽이 `URL.revokeObjectURL`). */
/** 범위대로 칠한 그림(주소 = data URL · toBlob 의 한가 작업 기다림 최대 1 s 없앰 616-4 ·
* 부르는 쪽 `URL.revokeObjectURL` 은 그대로 둬도 됨). */
export async function paintSlope(
values: ImageData,
bands: SlopeBand[],
@@ -65,13 +67,7 @@ export async function paintSlope(
canvas.width = values.width;
canvas.height = values.height;
canvas.getContext("2d")!.putImageData(out, 0, 0);
const blob = await new Promise<Blob>((done, fail) =>
canvas.toBlob(
(got) => (got ? done(got) : fail(new Error("그림을 만들지 못했습니다"))),
"image/png",
),
);
const url = URL.createObjectURL(blob);
const url = canvas.toDataURL("image/png");
return { url, image: await loadImage(url) };
}