Files
Aislo/B05_Profile/B05_Profile_UI_RouteEdit_Layers.ts
T
eomsangdonandClaude Opus 5.5 99ec4b8efe feat(B05 평면 지도): 공용 도구줄 표 · 겹 줄 · 범례 · 지형 칩 붙임 · 노선추천 = 도구줄 미리보기(넷 · 하나만 · 끔) · [적용] · 추천 모달 · 좌측 칸 걷음 · 지형 바꿈 = 지형 칩 (PLAN 624-3 · 626-1 · 요구 530 · 531)
- 624-3 도구줄 = 공용 표 「b05」 · 아래 줄 = 공용 겹 줄(띠 · 올린 임도망도 숨김 · 처음 = 등고선 · 하천) · 범례 = 공용 legendRows(상태 글판 위)
- 노선 띠 300 m 자르기 = B05 자기 칸(등고선 · 하천 · 1 m)만 · 그 밖 겹 = 안 자르는 둘째 GL 칸(맨 밑 · 켠 겹 있을 때만 · GL 만) · 등고선 끔 = 등고 글도 숨김
- 626-1 추천 보기 = 도구줄 아이콘 + 아래 판(영선 · 토공 최소 · 기울기 최소 · 비중 계산 · 끔) — route_recommend 저장본(지금 설계 지형) 선만 · 다시 셈 · 쓰기 없음
- 지형 칩 → 확인창 → 새 POST /route/design-terrain(설계 지형만 바꿈 · 노선 저장 체인 다시 · 노선 · 초기값 · 추천 저장본 그대로 · 잠금 · 실패 되돌림)
- 걷음 — 추천 모달(_Recommend · _Help · _Map · CSS) · 좌측 「노선 추천」 칸 · saveRecommendedRoute · UI_RouteReset(서버 as_initial 길 · 시험은 남김)
- 부품 상자 — createPickMenu(아이콘 고르기 메뉴 · 판 = body fixed · ✓)
- 낡은 시험 고침 · 지움 — 모달 · 좌측 칸 · [적용] 화면 배선을 보던 시험(283 · 346_3 · 349 · 364_4 · 383_1 · 390_5 · 418 · 421 · 421_2 · 476_2 · 509_2 · 509_5 · 551_2 · 600_3 · b05_routeedit_initial · design_view_parity · modal_top · 133_1 · 133_2) · test_508_2 · helper_283 지움(모달 전용)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 21:30:49 +09:00

413 lines
16 KiB
TypeScript

/* =============================================================================
* B05_Profile_UI_RouteEdit_Layers.ts
* 평면 노선 편집 지도 겹(PLAN 624-3 · 요구 530 · 결정 A② · A③ · A⑦ · A⑨) — 공용 겹 줄 · 범례(`A00_Common/map`)를 B05 평면에.
* 겹 목록 · 처음 켬 · 취향 = 공용 페이지 표 「b05」(띠 · 올린 임도망도 숨김 · 처음 = 등고선 · 하천만).
* 등고선 · 하천 = B05 자기 자료(LAS 5 m · 1 m · 도엽 · 집기 · 자물쇠 그대로 · `_Gl` 이 그림 · 노선 띠 300 m 자르기는 이 둘만) ·
* 켬끔은 GL 에서만(옛 캔버스 = 늘 보임).
* 그 밖 겹 = GL 만(A⑦) · 띠로 안 자르는 둘째 GL 칸(맨 밑 · 켠 겹이 있을 때만 섬) · 꼴 = 공용 `mapLayerSpecs` ·
* 카메라 = `_Gl` 과 같은 화면 따라가기(`glCameraOf`).
* 받기 = 켤 때 한 번(서버 `/plan/…` · 624-1 임도 프로젝트 · 지형 = 설계 지형) · 도엽 선 겹 = 프로젝트 범위 도엽 조각 ·
* 실패 = 그 겹만 끔 + 알림 · 범례 = 켠 겹의 받은 자료 줄(공용 `legendRows`) · 급경사 ⚙ · 위성 밝기.
* 부르는 곳 = `_Chrome`(겹 줄 · 범례 자리) · `_Gl`(따라가기 · 켬끔).
* ========================================================================== */
import { showToast } from "@ui/ui_template_elements";
import { createGlMap, GL_FONT, satelliteTileSource, type GlData } from "@ui/ui_template_gl_map";
import { themeColor } from "@ui/ui_template_palette";
import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import type { Normalizer } from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
import { glCameraOf } from "../B04_PreProcess/logic/B04_PreProcess_UI_MapGl_Frame";
import { forestSources, soilColor, tileOnly } from "../A00_Common/map/map_forest_layers";
import {
fetchPlanCadastral,
fetchPlanLandslideZones,
fetchPlanLines,
fetchPlanRaster,
fetchPlanSheetTile,
fetchPlanSheetTiles,
fetchPlanSoils,
fetchPlanZeroOrder,
fetchPlanZones,
type GeoJsonCollection,
} from "../A00_Common/map/map_layer_api";
import { createMapLayerBar } from "../A00_Common/map/map_layer_bar";
import {
FOREST_ROAD_KINDS,
WATER_KEYS,
glImage,
glTagged,
loadImage,
shadeImage,
} from "../A00_Common/map/map_layer_data";
import {
LAYERS,
LINE_LAYERS,
ZONE_COLORS,
type MapLayerKey,
} from "../A00_Common/map/map_layer_list";
import { createMapLegend, legendRows, type LegendData } from "../A00_Common/map/map_legend";
import { MAP_THEME_NAMES, mapLayerSpecs } from "../A00_Common/map/map_layer_specs";
import { createSatLightRow, readSatLight } from "../A00_Common/map/map_sat_light";
import {
openSlopeBands,
paintSlope,
readSlopeBands,
slopeGear,
slopeValues,
writeSlopeBands,
} from "../A00_Common/map/map_slope_bands";
/** B05 자기 자료로 그리는 겹(`_Gl` · 띠 자르기). */
const OWN: readonly MapLayerKey[] = ["contour", "stream"];
const isOwn = (key: MapLayerKey): boolean => OWN.includes(key);
/** 겹 → 도엽 조각 이름(서버 `TILE_NAMES` · D03 `SHEET_TILE_NAMES` 와 같음). */
const SHEET_NAMES: Partial<Record<MapLayerKey, string[]>> = {
water: WATER_KEYS,
road: ["도로중심선"],
forestRoad: ["도로중심선"],
building: ["건물"],
place: ["지명"],
};
/** 도엽이 이보다 많으면 선 겹 숨김(D03 `FULL_MAX` 와 같음). */
const SHEET_MAX = 12;
/** 지도 칸 둘레 여유(px · `.ui-gl-map` inset · B04 `createTmGl` 과 같음). */
const GL_MARGIN_PX = 8;
export interface RouteEditLayers {
/** 겹 줄(도구줄 아래 줄). */
bar: HTMLElement;
/** 켠 겹 범례(상태 글판 안). */
legend: HTMLElement;
/** B05 자기 겹 켬끔 — 등고선 · 하천을 그리나(옛 캔버스 = 늘 참). */
own: () => { contour: boolean; stream: boolean };
/** GL 켬끔 · 지도 범위가 바뀜 — 단추 · 범례 맞춤. */
sync: (glOn: boolean, meta: VWorldMeta | null) => void;
/** 그리기마다(GL 켬일 때만) — 켠 겹이 있으면 둘째 칸을 세우고 화면을 따라감. */
follow: (
meta: VWorldMeta,
normalizer: Normalizer,
toScreen: (nx: number, ny: number) => [number, number],
width: number,
height: number,
) => void;
/** GL 끔 · 떠남 — 둘째 칸 버림. */
park: () => void;
/** 검증 창구 — 켬끔 · 받은 겹 · 둘째 칸 · 겹마다 그린 지물 수. */
state: () => {
on: Record<MapLayerKey, boolean>;
loaded: MapLayerKey[];
map: boolean;
gl: Record<string, number>;
sheets: number;
};
}
export function createRouteEditLayers(opts: {
projectId: string;
/** 지도 칸(둘째 GL 칸을 맨 앞에 둠). */
wrap: HTMLElement;
/** 그래픽 카드 단추 — `_Gl` 켬끔. */
onGl: () => void;
/** 장면 다시 그리기. */
redraw: () => void;
}): RouteEditLayers {
const { projectId, wrap } = opts;
let glOn = false;
let meta: VWorldMeta | null = null;
let light = readSatLight();
const gl = createGlMap<MapLayerKey>({
specs: () => mapLayerSpecs(light, null),
fixed: { satellite: satelliteTileSource(projectId), ...forestSources(projectId) },
themeNames: MAP_THEME_NAMES,
onFail: (why) => showToast(`지도 겹을 그래픽 카드로 못 그림 — ${why}`, "info"),
});
const layerBar = createMapLayerBar({
page: "b05",
glOn,
onToggle: (key) => {
void ensure(key);
sync(glOn, meta);
opts.redraw();
},
onGl: opts.onGl,
});
const { on } = layerBar;
layerBar.root.classList.add("b05-routeedit__layers");
const legendBox = createMapLegend("b05-routeedit__maplegend");
const data: Partial<LegendData> = {};
const loaded = new Set<MapLayerKey>();
const pending = new Map<string, Promise<void>>();
let hillshade: { plain: GlData; shade: GlData } | null = null;
let size = "";
let enabled = false;
/** 급경사 범위(603 · D03 과 같은 취향) — 바꾸면 다시 칠함. */
let slopeBands = readSlopeBands();
let slopeValuesNow: ImageData | null = null;
const gear = slopeGear(() =>
openSlopeBands(slopeBands, (bands) => {
slopeBands = bands;
writeSlopeBands(bands);
void paintSlopeNow();
}),
);
async function paintSlopeNow(): Promise<void> {
const got = data.slope;
if (!got || !slopeValuesNow) return;
const bands = slopeBands;
const painted = await paintSlope(slopeValuesNow, bands);
if (bands !== slopeBands) return URL.revokeObjectURL(painted.url);
data.slope = { raster: got.raster, bands };
gl.setData("slope", glImage(got.raster, painted.url));
sync(glOn, meta);
opts.redraw();
}
const lightRow = createSatLightRow(light, (level) => {
light = level;
if (gl.map()?.getLayer("satellite"))
gl.map()!.setPaintProperty("satellite", "raster-brightness-max", level);
opts.redraw();
});
function sync(nextGl: boolean, nextMeta: VWorldMeta | null): void {
const first = !meta && nextMeta;
glOn = nextGl;
meta = nextMeta;
layerBar.sync({ ready: !!meta, glOn });
// 자기 겹(등고선 · 하천) = GL 에서만 켬끔 · 옛 캔버스는 늘 그림
for (const key of OWN) {
const button = layerBar.root.querySelector<HTMLButtonElement>(`[data-layer="${key}"]`);
if (!button || glOn) continue;
button.disabled = true;
button.title = `${LAYERS.find((def) => def.key === key)!.title} — 옛 그림(그래픽 카드 끔)엔 늘 보임`;
}
// 그 밖 겹 = GL 만(A⑦)
for (const def of LAYERS) {
if (isOwn(def.key)) continue;
gl.setVisible(def.key, on[def.key]);
const button = layerBar.root.querySelector<HTMLButtonElement>(`[data-layer="${def.key}"]`);
if (button && meta && !glOn) {
button.disabled = true;
button.title = `${def.title} — 옛 그림(그래픽 카드 끔)엔 안 보임`;
}
}
if (hillshade) gl.setData("hillshade", on.satellite ? hillshade.shade : hillshade.plain);
const shown = glOn ? on : ({} as Record<MapLayerKey, boolean>);
legendBox.show(meta ? legendRows(shown, data) : [], glOn && on.satellite ? [lightRow] : []);
if (glOn && on.slope && data.slope) legendBox.root.firstElementChild?.append(gear);
if (first) void Promise.all(LAYERS.map((def) => ensure(def.key)));
}
/** 켠 겹 자료 한 번 — 실패 = 그 겹만 끔 + 알림(취향은 안 바꿈). */
function ensure(key: MapLayerKey): Promise<void> {
if (!meta || !on[key] || isOwn(key) || tileOnly(key) || key === "landslide") return done();
if (loaded.has(key)) return done();
const slot = key === "forestRoad" ? "road" : key;
if (loaded.has(slot as MapLayerKey)) return done();
const running = pending.get(slot);
if (running) return running;
const at = meta;
const job = fill(key, at)
.then(() => {
loaded.add(key);
loaded.add(slot as MapLayerKey);
sync(glOn, meta);
opts.redraw();
})
.catch((error: unknown) => {
for (const def of LAYERS)
if (def.key === key || (def.key === "forestRoad" ? "road" : def.key) === slot)
on[def.key] = false;
sync(glOn, meta);
opts.redraw();
const text = error instanceof Error ? error.message : String(error);
showToast(`${LAYERS.find((def) => def.key === key)!.title} 받지 못함 — ${text}`, "error");
})
.finally(() => pending.delete(slot));
pending.set(slot, job);
return job;
}
const done = (): Promise<void> => Promise.resolve();
/** 프로젝트 범위 도엽 — 한 번 셈 · 조각은 이름마다 한 번. */
let sheetList: Promise<string[]> | null = null;
const sheetCache = new Map<string, Promise<GeoJsonCollection>>();
async function sheetCollection(names: string[], at: VWorldMeta): Promise<GeoJsonCollection> {
sheetList ??= fetchPlanSheetTiles(projectId, [
at.lon_min,
at.lat_min,
at.lon_max,
at.lat_max,
]).then((got) => {
if (got.count > SHEET_MAX) throw new Error(`도엽 ${got.count}장 — 넓어서 선 겹 숨김`);
return got.sheets;
});
const sheets = await sheetList.catch((error: unknown) => {
sheetList = null;
throw error;
});
const parts = await Promise.all(
sheets.flatMap((sheet) =>
names.map((name) => {
const key = `${sheet}/${name}`;
if (!sheetCache.has(key))
sheetCache.set(
key,
fetchPlanSheetTile(projectId, sheet, name).catch(() => ({ features: [] })),
);
return sheetCache.get(key)!;
}),
),
);
return { features: parts.flatMap((part) => part.features ?? []) };
}
async function fill(key: MapLayerKey, at: VWorldMeta): Promise<void> {
const names = SHEET_NAMES[key];
if (names) {
const collection = await sheetCollection(names, at);
if (key === "road" || key === "forestRoad") {
const small = (collection.features ?? []).filter((feature) =>
FOREST_ROAD_KINDS.has(String(feature.properties?.["도로구분"])),
);
data.road = { small: { features: small } };
return gl.setData("road", collection);
}
if (key === "water") data.water = collection;
if (key === "building") data.building = collection;
if (key !== "place") return gl.setData(key, collection);
const places = (collection.features ?? []).flatMap((feature) => {
const name = feature.properties?.["명칭"];
return feature.geometry?.type === "Point" && typeof name === "string" && name
? [{ type: "Feature", properties: { name }, geometry: feature.geometry }]
: [];
});
await document.fonts
.load(`600 24px "${GL_FONT}"`, places.map((p) => p.properties.name).join("") || "가")
.catch(() => null);
return gl.setData("place", { features: places });
}
if (key === "hillshade") {
const raster = await fetchPlanRaster(projectId, "hillshade");
const image = await loadImage(raster.image);
const plain = glImage(raster);
if (plain) hillshade = { plain, shade: glImage(raster, shadeImage(image))! };
return;
}
if (key === "slope") {
const raster = await fetchPlanRaster(projectId, "slope");
slopeValuesNow = slopeValues(await loadImage(raster.image));
data.slope = { raster, bands: slopeBands };
return paintSlopeNow();
}
if (key === "cadastral") return gl.setData("cadastral", await fetchPlanCadastral(projectId));
if (key === "zones") {
const zones = await fetchPlanZones(projectId);
data.zones = { summary: zones.summary };
return gl.setData(
"zones",
glTagged(zones, (props) =>
themeColor(ZONE_COLORS[String(props["kind"])] ?? "--draw-warning", "#eab308"),
),
);
}
if (key === "soils") {
// 그림 = 조각(622-2) · 범례 = 받은 범위 요약
const soils = await fetchPlanSoils(projectId);
data.soils = {
kinds: soils.summary.map((soil) => ({ color: soilColor(soil.code) })),
summary: soils.summary,
};
return;
}
if (key === "landslideZones") {
const info = await fetchPlanLandslideZones(projectId);
data.landslideZones = { info };
return gl.setData("landslideZones", {
features: [
...(info.parcels.features ?? []),
...info.points.map((point) => ({
type: "Feature",
properties: {},
geometry: { type: "Point", coordinates: [point.lon, point.lat] },
})),
],
});
}
if (key === "faults" || key === "lineaments" || key === "zeroOrder") {
const kind = LINE_LAYERS[key].kind;
const slow = window.setTimeout(
() => showToast(`${LINE_LAYERS[key].label} 셈 중 — 처음 한 번만`, "info"),
600,
);
try {
const found = kind
? await fetchPlanLines(projectId, kind).then((lines) => ({
collection: lines as GeoJsonCollection,
...lines.summary,
}))
: await fetchPlanZeroOrder(projectId);
data[key] = { count: found.count, length_m: found.length_m };
return gl.setData(key, found.collection);
} finally {
window.clearTimeout(slow);
}
}
}
const wanted = (): boolean => glOn && LAYERS.some((def) => !isOwn(def.key) && on[def.key]);
function park(): void {
if (!enabled) return;
enabled = false;
gl.enable(null);
}
return {
bar: layerBar.root,
legend: legendBox.root,
own: () => ({ contour: !glOn || on.contour, stream: !glOn || on.stream }),
sync,
follow(at, normalizer, toScreen, width, height) {
if (!wanted() || width <= 0 || height <= 0) return park();
if (!enabled) {
enabled = gl.enable(wrap);
size = "";
if (!enabled) return;
}
const container = gl.container();
if (!container) return;
// 맨 밑(띠로 자른 B05 칸 아래) — 그 칸을 다시 켜면 앞에 붙으므로 그릴 때마다 맞춤
if (wrap.firstElementChild !== container) wrap.prepend(container);
const now = `${width}x${height}`;
if (now !== size) {
size = now;
container.style.width = `${width + 2 * GL_MARGIN_PX}px`;
container.style.height = `${height + 2 * GL_MARGIN_PX}px`;
gl.resize();
}
const camera = glCameraOf(at, normalizer, toScreen, width, height);
if (!camera) return;
gl.camera(camera.center, camera.zoom, camera.bearing);
container.style.transform = `matrix(${camera.css.join(", ")}, 0, 0)`;
},
park,
state: () => ({
on: { ...on },
loaded: [...loaded],
map: enabled,
gl: Object.fromEntries(
(gl.map()?.getStyle().layers ?? [])
.filter((layer) => layer.layout?.visibility !== "none")
.map((layer) => [
layer.id,
layer.type === "raster"
? 1
: gl.map()!.queryRenderedFeatures({ layers: [layer.id] }).length,
]),
),
sheets: sheetCache.size,
}),
};
}