feat(D03): 노선분석 지도 바탕 겹 켜고 끔 — 위성 · 등고선(계곡선 굵게) · 세류 · 지적 · 도로 · 급경사 (PLAN 569-2 · 요구 395-2)
- 새 D03_PlanAnalysis_UI_Layers.ts — 왼쪽 위 겹 단추 한 줄 · 처음 켬 = 위성 · 등고선 · 세류 · 고른 값 aislo:pref:d03.layers 기억 · 켤 때만 받음 · 급경사 범례 - UI_Map — 등고선 그리기를 겹으로 옮김 · ScreenMap.view 더함 - Api — 569-1 서버 겹 세 길(지적 · 도엽 겹 · 급경사 그림) 부름 - theme.css 그림 색 일곱(지도 겹 넷 · 급경사 셋) · test_551_2 그림 값 개수 17 → 24(새 값 일곱) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,12 +1,14 @@
|
||||
/* =============================================================================
|
||||
* D03_PlanAnalysis_Api.ts
|
||||
* D03 분석 화면 서버 요청(PLAN 563-1) — 지형 받기 범위 · 받기 · 진행 ·
|
||||
* 사업 전체 지형면 한 벌(`models_plan/` · 격자) · 그은 선 정본(562-2 GET · PUT network · 응답에 D02 시점 · 종점).
|
||||
* 사업 전체 지형면 한 벌(`models_plan/` · 격자) · 그은 선 정본(562-2 GET · PUT network · 응답에 D02 시점 · 종점) ·
|
||||
* 지도 겹(569 — 지적도 · 도엽 겹 GeoJSON 경위도 · 급경사 그림).
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { fetchCachedBytes } from "../A00_Common/b_asset_cache";
|
||||
import { listSurfaceModels } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import type { GeoJsonCollection } from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
|
||||
import { type DtmGrid, parseDtmGrid } from "../common_util/common_util_surface_sampler";
|
||||
import type { PlanLine, PlanPiece, Vertex } from "./logic/D03_PlanAnalysis_Plan";
|
||||
|
||||
@@ -132,3 +134,19 @@ export async function fetchPlanSurface(projectId: string): Promise<PlanSurface |
|
||||
`?smooth=true&sig=${encodeURIComponent(stamp)}`;
|
||||
return { modelId: model.id, grid: parseDtmGrid(await fetchCachedBytes(projectId, url)) };
|
||||
}
|
||||
|
||||
/** 지도 겹 그림(569-1) — 작업 좌표계 m 상자 · data URL. */
|
||||
export interface PlanRaster {
|
||||
box_m: [number, number, number, number];
|
||||
image: string;
|
||||
}
|
||||
|
||||
export const fetchPlanCadastral = (projectId: string): Promise<GeoJsonCollection> =>
|
||||
request(`${base(projectId)}/layers/cadastral`);
|
||||
|
||||
/** 도엽 겹(`도로중심선` 등) — 없으면 서버가 받은 도엽에서 풀어 냄. */
|
||||
export const fetchPlanSheetLayer = (projectId: string, key: string): Promise<GeoJsonCollection> =>
|
||||
request(`${base(projectId)}/layers/sheet?key=${encodeURIComponent(key)}`);
|
||||
|
||||
export const fetchPlanRaster = (projectId: string, kind: "slope"): Promise<PlanRaster> =>
|
||||
request(`${base(projectId)}/layers/raster?kind=${kind}`);
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
/* =============================================================================
|
||||
* D03_PlanAnalysis_UI_Layers.ts
|
||||
* D03 평면 바탕 겹 켜고 끔(PLAN 569-2 · 요구 395-2) — 지도 왼쪽 위 아이콘 한 줄(도구줄과 따로).
|
||||
*
|
||||
* 겹 = 위성 · 도엽 등고선(계곡선 25 m 굵게 · 수치) · 세류선(가는 파랑) · 하천(굵은 파랑) · 지적도 ·
|
||||
* 기존 도로(도엽 도로중심선) · 급경사 색(15° · 35° · 40° · 범례 작은 칸).
|
||||
* 처음 켬 = 위성 · 등고선 · 세류 · 고른 값 = 화면 취향(`aislo:pref:d03.layers`) 기억.
|
||||
* 받기 = 켤 때만(한 번 받으면 다시 안 받음) · 지형을 안 받았으면 단추를 막음.
|
||||
* 그리기 차례(아래 → 위) = 위성(`scene.image`) → 급경사 → 지적 → 등고선 → 도로 → 세류 → 그은 선.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createIconButton } from "@ui/ui_template_button";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { createLegend } from "@ui/ui_template_flow_legend";
|
||||
import type { IconName } from "@ui/ui_template_icons";
|
||||
import { themeColor } from "@ui/ui_template_palette";
|
||||
import { createToolbar } from "@ui/ui_template_toolbar";
|
||||
import { fetchCachedSheetLayer } from "../A00_Common/b_asset_cache";
|
||||
import { readByKey, writeByKey } from "../A00_Common/b_page_state";
|
||||
import {
|
||||
fetchVWorldMeta,
|
||||
getVWorldMapUrl,
|
||||
type VWorldMeta,
|
||||
} from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import {
|
||||
createNormalizer,
|
||||
drawPreparedLabels,
|
||||
drawPreparedLayer,
|
||||
pickLevelStep,
|
||||
type GeoJsonCollection,
|
||||
type PreparedLayer,
|
||||
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
|
||||
import { prepareLayer } from "../B04_PreProcess/logic/B04_PreProcess_UI_MapRender_Prepare";
|
||||
import {
|
||||
fetchPlanCadastral,
|
||||
fetchPlanRaster,
|
||||
fetchPlanSheetLayer,
|
||||
type PlanRaster,
|
||||
} from "./D03_PlanAnalysis_Api";
|
||||
import type { PlanMap, PlanMapLayer, ScreenMap } from "./D03_PlanAnalysis_UI_Map";
|
||||
import type { Vertex } from "./logic/D03_PlanAnalysis_Plan";
|
||||
|
||||
export type PlanLayerKey = "satellite" | "contour" | "stream" | "cadastral" | "road" | "slope";
|
||||
|
||||
interface LayerDef {
|
||||
key: PlanLayerKey;
|
||||
/** 아이콘 — 없으면 글자 단추(`glyph`). */
|
||||
icon?: IconName;
|
||||
glyph?: string;
|
||||
title: string;
|
||||
on: boolean;
|
||||
}
|
||||
|
||||
const LAYERS: readonly LayerDef[] = [
|
||||
{ key: "satellite", glyph: "위성", title: "위성사진", on: true },
|
||||
{ key: "contour", icon: "mountain", title: "도엽 등고선(굵은 선 = 계곡선 25 m)", on: true },
|
||||
{ key: "stream", glyph: "세류", title: "세류선(가는 파랑) · 하천(굵은 파랑)", on: true },
|
||||
{ key: "cadastral", glyph: "지적", title: "지적도(참고용 · 법적 효력 없음)", on: false },
|
||||
{ key: "road", icon: "road", title: "기존 도로(도엽 도로중심선)", on: false },
|
||||
{ key: "slope", glyph: "경사", title: "급경사 색(15° · 35° · 40° 이상)", on: false },
|
||||
];
|
||||
const PREF_KEY = "aislo:pref:d03.layers";
|
||||
/** 등고선을 받아 올 그은 선 둘레(m). */
|
||||
const CONTOUR_PAD_M = 600;
|
||||
const SLOPE_STEPS = [
|
||||
{ label: "15°", token: "--draw-slope-15" },
|
||||
{ label: "35°", token: "--draw-slope-35" },
|
||||
{ label: "40°", token: "--draw-slope-40" },
|
||||
];
|
||||
|
||||
interface Prepared {
|
||||
contour: PreparedLayer;
|
||||
/** 세류 · 하천(소하천 · 지방하천 · 연결선). */
|
||||
stream: [PreparedLayer, PreparedLayer];
|
||||
cadastral: PreparedLayer;
|
||||
road: PreparedLayer;
|
||||
slope: { raster: PlanRaster; image: HTMLImageElement };
|
||||
}
|
||||
|
||||
export interface PlanLayers {
|
||||
bar: HTMLElement;
|
||||
legend: HTMLElement;
|
||||
/** 바탕 겹(아래 → 위 · 위성 뺌) — 그은 선 겹 아래에 둠. */
|
||||
layers: PlanMapLayer[];
|
||||
/** 지형(위성 틀)을 다시 읽음 — 처음 · 지형 받은 뒤. */
|
||||
load: () => Promise<void>;
|
||||
/** 검증 창구 — 켬끔 · 받은 겹. */
|
||||
state: () => { on: Record<PlanLayerKey, boolean>; loaded: PlanLayerKey[] };
|
||||
}
|
||||
|
||||
function readPref(): Record<PlanLayerKey, boolean> {
|
||||
const on = Object.fromEntries(LAYERS.map((def) => [def.key, def.on])) as Record<
|
||||
PlanLayerKey,
|
||||
boolean
|
||||
>;
|
||||
try {
|
||||
const saved = JSON.parse(readByKey(PREF_KEY) ?? "{}") as Partial<Record<PlanLayerKey, unknown>>;
|
||||
for (const def of LAYERS)
|
||||
if (typeof saved[def.key] === "boolean") on[def.key] = saved[def.key] as boolean;
|
||||
} catch {
|
||||
// 깨진 값 = 처음 값
|
||||
}
|
||||
return on;
|
||||
}
|
||||
|
||||
const loadImage = (src: string): Promise<HTMLImageElement> =>
|
||||
new Promise((done, fail) => {
|
||||
const image = new Image();
|
||||
image.onload = () => done(image);
|
||||
image.onerror = () => fail(new Error("그림을 읽지 못했습니다"));
|
||||
image.src = src;
|
||||
});
|
||||
|
||||
export function createPlanLayers(opts: {
|
||||
projectId: string;
|
||||
map: PlanMap;
|
||||
/** 등고선 받을 범위를 정할 점(시점 · 종점 · 그은 선). */
|
||||
points: () => Vertex[];
|
||||
}): PlanLayers {
|
||||
const { projectId, map } = opts;
|
||||
const on = readPref();
|
||||
const data: Partial<Prepared> = {};
|
||||
const pending = new Map<PlanLayerKey, Promise<void>>();
|
||||
let meta: VWorldMeta | null = null;
|
||||
let satellite: HTMLImageElement | null = null;
|
||||
|
||||
const buttons = new Map<PlanLayerKey, HTMLButtonElement>();
|
||||
for (const def of LAYERS) {
|
||||
const button = createIconButton({
|
||||
icon: def.icon,
|
||||
glyph: def.glyph,
|
||||
title: def.title,
|
||||
pressed: on[def.key],
|
||||
onClick: () => toggle(def.key),
|
||||
});
|
||||
button.dataset.layer = def.key;
|
||||
buttons.set(def.key, button);
|
||||
}
|
||||
const bar = createToolbar({
|
||||
glass: true,
|
||||
size: 32,
|
||||
label: "지도 겹",
|
||||
items: [...buttons.values()],
|
||||
});
|
||||
bar.classList.add("d03-plan__layers");
|
||||
const legend = document.createElement("div");
|
||||
legend.className = "d03-plan__legend";
|
||||
legend.title = "급경사 색 — 그 값 이상";
|
||||
legend.append(
|
||||
createLegend(SLOPE_STEPS.map((step) => ({ label: step.label, color: `var(${step.token})` }))),
|
||||
);
|
||||
|
||||
function sync(): void {
|
||||
for (const def of LAYERS) {
|
||||
const button = buttons.get(def.key)!;
|
||||
button.setAttribute("aria-pressed", String(on[def.key]));
|
||||
button.disabled = !meta;
|
||||
const title = meta ? def.title : `${def.title} — 지형 받기 뒤 보임`;
|
||||
button.title = title;
|
||||
button.setAttribute("aria-label", title);
|
||||
}
|
||||
legend.hidden = !meta || !on.slope || !data.slope;
|
||||
}
|
||||
|
||||
function toggle(key: PlanLayerKey): void {
|
||||
on[key] = !on[key];
|
||||
writeByKey(PREF_KEY, JSON.stringify(on));
|
||||
void ensure(key);
|
||||
showSatellite();
|
||||
sync();
|
||||
map.redraw();
|
||||
}
|
||||
|
||||
function showSatellite(): void {
|
||||
map.update({ image: on.satellite ? satellite : null });
|
||||
}
|
||||
|
||||
/** 켜진 겹의 자료를 받음(한 번). */
|
||||
function ensure(key: PlanLayerKey): Promise<void> {
|
||||
if (!meta || !on[key] || (key === "satellite" ? satellite : data[key as keyof Prepared]))
|
||||
return Promise.resolve();
|
||||
const running = pending.get(key);
|
||||
if (running) return running;
|
||||
const at = meta;
|
||||
const job = fetchLayer(key, at)
|
||||
.then(() => {
|
||||
if (meta !== at) return;
|
||||
showSatellite();
|
||||
sync();
|
||||
map.redraw();
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
const text = error instanceof Error ? error.message : String(error);
|
||||
showToast(`${LAYERS.find((def) => def.key === key)!.title} 받지 못함 — ${text}`, "error");
|
||||
})
|
||||
.finally(() => pending.delete(key));
|
||||
pending.set(key, job);
|
||||
return job;
|
||||
}
|
||||
|
||||
async function fetchLayer(key: PlanLayerKey, at: VWorldMeta): Promise<void> {
|
||||
const normalizer = createNormalizer(at);
|
||||
const prepare = (collection: GeoJsonCollection, labels?: string[]): PreparedLayer =>
|
||||
prepareLayer(collection, normalizer, labels);
|
||||
if (key === "satellite") {
|
||||
// 범위가 넓어지면 주소가 바뀌어 옛 그림을 안 씀
|
||||
const v = `${at.x_min}_${at.x_max}_${at.y_min}_${at.y_max}`;
|
||||
satellite = await loadImage(`${getVWorldMapUrl(projectId, "satellite")}&v=${v}`);
|
||||
} else if (key === "contour") {
|
||||
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 collection = await fetchCachedSheetLayer<GeoJsonCollection>(
|
||||
projectId,
|
||||
"도엽_등고선",
|
||||
"route",
|
||||
bbox.join(","),
|
||||
);
|
||||
data.contour = prepare(collection, ["등고수치"]);
|
||||
} else if (key === "stream") {
|
||||
const collection = await fetchCachedSheetLayer<GeoJsonCollection>(
|
||||
projectId,
|
||||
"도엽_하천중심선",
|
||||
);
|
||||
const pick = (small: boolean): GeoJsonCollection => ({
|
||||
...collection,
|
||||
features: (collection.features ?? []).filter(
|
||||
(feature) => (feature.properties?.["구분"] === "세류") === small,
|
||||
),
|
||||
});
|
||||
data.stream = [prepare(pick(true)), prepare(pick(false))];
|
||||
} else if (key === "cadastral") {
|
||||
data.cadastral = prepare(await fetchPlanCadastral(projectId));
|
||||
} else if (key === "road") {
|
||||
data.road = prepare(await fetchPlanSheetLayer(projectId, "도로중심선"));
|
||||
} else {
|
||||
const raster = await fetchPlanRaster(projectId, "slope");
|
||||
data.slope = { raster, image: await loadImage(raster.image) };
|
||||
}
|
||||
}
|
||||
|
||||
const stroke = (
|
||||
context: CanvasRenderingContext2D,
|
||||
map: ScreenMap,
|
||||
layer: PreparedLayer,
|
||||
color: string,
|
||||
width: number,
|
||||
everyM?: number,
|
||||
): void => {
|
||||
context.strokeStyle = color;
|
||||
context.lineWidth = width;
|
||||
drawPreparedLayer(context, layer, map.view, "dot", everyM);
|
||||
};
|
||||
|
||||
const layers: PlanMapLayer[] = [
|
||||
{
|
||||
draw: (context, map) => {
|
||||
const slope = on.slope && data.slope;
|
||||
if (!slope) return;
|
||||
const [x0, y0, x1, y1] = slope.raster.box_m;
|
||||
const [left, top] = map.toScreen([x0, y1]);
|
||||
const [right, bottom] = map.toScreen([x1, y0]);
|
||||
context.globalAlpha = 0.55;
|
||||
context.drawImage(slope.image, left, top, right - left, bottom - top);
|
||||
},
|
||||
},
|
||||
{
|
||||
draw: (context, map) => {
|
||||
if (!on.cadastral || !data.cadastral) return;
|
||||
context.globalAlpha = 0.8;
|
||||
stroke(context, map, data.cadastral, themeColor("--draw-map-parcel", "#facc15"), 0.8);
|
||||
},
|
||||
},
|
||||
{
|
||||
draw: (context, map) => {
|
||||
const contour = on.contour && data.contour;
|
||||
if (!contour) return;
|
||||
const color = themeColor("--draw-map-contour", "#ffd68c");
|
||||
context.globalAlpha = 0.7;
|
||||
stroke(context, map, contour, color, 0.8, pickLevelStep(contour, map.view, 5));
|
||||
stroke(context, map, contour, color, 1.6, 25); // 계곡선
|
||||
context.globalAlpha = 1;
|
||||
context.font = "10px system-ui, sans-serif";
|
||||
context.textAlign = "center";
|
||||
context.textBaseline = "middle";
|
||||
drawPreparedLabels(
|
||||
context,
|
||||
contour,
|
||||
map.view,
|
||||
color,
|
||||
25,
|
||||
map.uprightRad,
|
||||
"rgba(0, 0, 0, 0.6)",
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
draw: (context, map) => {
|
||||
if (!on.road || !data.road) return;
|
||||
stroke(context, map, data.road, "rgba(0, 0, 0, 0.55)", 3.2);
|
||||
stroke(context, map, data.road, themeColor("--draw-map-road", "#f8fafc"), 1.6);
|
||||
},
|
||||
},
|
||||
{
|
||||
draw: (context, map) => {
|
||||
if (!on.stream || !data.stream) return;
|
||||
const color = themeColor("--draw-map-stream", "#38bdf8");
|
||||
stroke(context, map, data.stream[0], color, 1);
|
||||
stroke(context, map, data.stream[1], color, 2.4);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
async function load(): Promise<void> {
|
||||
const next = await fetchVWorldMeta(projectId, "satellite").catch(() => null);
|
||||
const changed =
|
||||
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;
|
||||
if (changed) {
|
||||
satellite = null;
|
||||
for (const key of Object.keys(data)) delete data[key as keyof Prepared];
|
||||
}
|
||||
map.update({ meta });
|
||||
showSatellite();
|
||||
sync();
|
||||
await Promise.all(LAYERS.map((def) => ensure(def.key)));
|
||||
}
|
||||
|
||||
return {
|
||||
bar,
|
||||
legend,
|
||||
layers,
|
||||
load,
|
||||
state: () => ({
|
||||
on: { ...on },
|
||||
loaded: [
|
||||
...(satellite ? (["satellite"] as PlanLayerKey[]) : []),
|
||||
...(Object.keys(data) as PlanLayerKey[]),
|
||||
],
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
/* =============================================================================
|
||||
* D03_PlanAnalysis_UI_Map.ts
|
||||
* D03 메인 평면(PLAN 530-3 · 562-3) — 위성 + 도엽 등고선 바탕 · 위에 겹(그리기 · 거리재기) · 도구줄 여섯
|
||||
* D03 메인 평면(PLAN 530-3 · 562-3) — 위성 바탕 · 위에 겹(바탕 겹 569-2 · 그리기 · 거리재기) · 도구줄 여섯
|
||||
* (되돌리기 · 다시하기 · 3D · 좌회전 · 우회전 · 거리재기).
|
||||
*
|
||||
* 셈 · 그리기는 B 부품 그대로(import 만) — 좌표 `createMetricProjector` · `screenToMetric` · 맞춤
|
||||
* `computeRouteView` · 등고선 `drawPreparedLayer` · 휠 확대 · 가운데 단추 옮기기 `bindRouteEditNavigation` ·
|
||||
* `computeRouteView` · 휠 확대 · 가운데 단추 옮기기 `bindRouteEditNavigation` ·
|
||||
* 회전 `createMapRotation`(B05 그대로 · 단추 `[data-act="rotate-ccw|cw"]` · Ctrl+휠).
|
||||
* 왼쪽 단추 = 겹 몫(점 찍기 · 마크 · 재기) — 왼쪽 끌기 옮기기 없음(설정 창 규칙 = 가운데 단추 옮김 · 휠 방향).
|
||||
* 겹은 돌리기 전 그림 좌표로 그리고 받음 — 누른 자리는 `unrotate` 로 되돌려 넘김.
|
||||
@@ -18,12 +18,8 @@ import {
|
||||
computeMapRect,
|
||||
computeRouteView,
|
||||
createMetricProjector,
|
||||
drawPreparedLabels,
|
||||
drawPreparedLayer,
|
||||
normalizedToScreen,
|
||||
pickLevelStep,
|
||||
routeLineColor,
|
||||
type PreparedLayer,
|
||||
type ViewState,
|
||||
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
|
||||
import { bindRouteEditNavigation } from "../B05_Profile/B05_Profile_UI_RouteEdit_Input";
|
||||
@@ -49,7 +45,6 @@ const FAKE_MARGIN_M = 600;
|
||||
export interface PlanMapScene {
|
||||
meta: VWorldMeta | null;
|
||||
image: HTMLImageElement | null;
|
||||
contours: PreparedLayer | null;
|
||||
/** 처음 맞출 점(시점 · 종점 · 그은 선). */
|
||||
fit: Vertex[];
|
||||
}
|
||||
@@ -59,6 +54,8 @@ export interface ScreenMap {
|
||||
toScreen: (point: Vertex) => [number, number];
|
||||
toMetric: (px: number, py: number) => Vertex;
|
||||
pxPerMeter: number;
|
||||
/** B04 그리기 틀(`drawPreparedLayer` 등이 받음). */
|
||||
view: ViewState;
|
||||
/** 글자를 세울 각(라디안 · 안 돌렸으면 0). */
|
||||
uprightRad: number;
|
||||
/** (x, y) 둘레로 글자를 눈높이로 세워 그림(돌린 지도). */
|
||||
@@ -165,7 +162,7 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
|
||||
toolbar.classList.add("d03-plan__tools");
|
||||
root.append(canvas, toolbar);
|
||||
const context = canvas.getContext("2d")!;
|
||||
let scene: PlanMapScene = { meta: null, image: null, contours: null, fit: [] };
|
||||
let scene: PlanMapScene = { meta: null, image: null, fit: [] };
|
||||
let layers: PlanMapLayer[] = [];
|
||||
let meta: VWorldMeta | null = null;
|
||||
let fitted = false;
|
||||
@@ -189,6 +186,7 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
|
||||
toScreen: (point) => projector.toScreen(point[0], point[1]),
|
||||
toMetric: (px, py) => screenToMetric(meta!, view, px, py),
|
||||
pxPerMeter: projector.pxPerMeter,
|
||||
view,
|
||||
uprightRad: rotation.uprightRad(),
|
||||
upright: (ctx, x, y, paint) => {
|
||||
const angle = rotation.uprightRad();
|
||||
@@ -234,31 +232,6 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
|
||||
const [x1, y1] = normalizedToScreen(view, 1, 1);
|
||||
context.drawImage(scene.image, x0, y0, x1 - x0, y1 - y0);
|
||||
}
|
||||
if (scene.contours) {
|
||||
context.save();
|
||||
context.strokeStyle = "rgba(255, 214, 140, 0.7)";
|
||||
context.lineWidth = 0.8;
|
||||
drawPreparedLayer(
|
||||
context,
|
||||
scene.contours,
|
||||
view,
|
||||
"dot",
|
||||
pickLevelStep(scene.contours, view, 5),
|
||||
);
|
||||
context.font = "10px system-ui, sans-serif";
|
||||
context.textAlign = "center";
|
||||
context.textBaseline = "middle";
|
||||
drawPreparedLabels(
|
||||
context,
|
||||
scene.contours,
|
||||
view,
|
||||
"#fde68a",
|
||||
25,
|
||||
rotation.uprightRad(),
|
||||
"rgba(0, 0, 0, 0.6)",
|
||||
);
|
||||
context.restore();
|
||||
}
|
||||
const map = screenMap();
|
||||
for (const layer of layers) {
|
||||
context.save();
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* D03_PlanAnalysis_UI_Page.ts
|
||||
* D 예정노선 2단계 노선분석(요구서 389 3장 6~13 · PLAN 562-3 · 563-1) — B05~B09 틀(`mountGroupStep`).
|
||||
*
|
||||
* 메인 = 평면(위성 + 도엽 등고선 + 그은 선 · 측점 · 구역 마크) + 도구 여섯.
|
||||
* 메인 = 평면(바탕 겹 켜고 끔 — 위성 · 등고선 · 세류 · 지적 · 도로 · 급경사 + 그은 선 · 측점 · 구역 마크) + 도구 여섯.
|
||||
* 좌 = 지형(범위를 카카오 지도에 먼저 보임 → [이 범위 받기] · [범위 밖 더 받기]는 선이 받은 범위 밖일 때만) ·
|
||||
* 그리기(본선 · 갈래 · 구역 마크 · 제약 칸) · 구역 · 연도(조각마다 연도) · [저장] [초기화].
|
||||
*
|
||||
@@ -26,16 +26,9 @@ import {
|
||||
showLoadingOverlay,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { fetchCachedSheetLayer, DRAINAGE_SHEET_LAYERS } from "../A00_Common/b_asset_cache";
|
||||
import { clearState, readState, writeState } from "../A00_Common/b_page_state";
|
||||
import { mountGroupStep } from "../A00_Common/group_workflow";
|
||||
import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch";
|
||||
import { fetchVWorldMeta, getVWorldMapUrl } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import {
|
||||
createNormalizer,
|
||||
type GeoJsonCollection,
|
||||
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
|
||||
import { prepareLayer } from "../B04_PreProcess/logic/B04_PreProcess_UI_MapRender_Prepare";
|
||||
import { heightAt as gridHeightAt } from "../common_util/common_util_surface_sampler";
|
||||
import { stationLabel } from "../common_util/common_util_station";
|
||||
import {
|
||||
@@ -52,6 +45,7 @@ import {
|
||||
} from "./D03_PlanAnalysis_Api";
|
||||
import { openPlan3d } from "./D03_PlanAnalysis_UI_3D";
|
||||
import { createPlanDraw } from "./D03_PlanAnalysis_UI_Draw";
|
||||
import { createPlanLayers } from "./D03_PlanAnalysis_UI_Layers";
|
||||
import { createPlanMap, PLAN_STATION_INTERVAL_M } from "./D03_PlanAnalysis_UI_Map";
|
||||
import { createPlanMeasure } from "./D03_PlanAnalysis_UI_Measure";
|
||||
import { lineLength, stationChainages } from "./logic/D03_PlanAnalysis_Draw";
|
||||
@@ -67,8 +61,6 @@ import {
|
||||
} from "./logic/D03_PlanAnalysis_Plan";
|
||||
|
||||
const POLL_MS = 1000;
|
||||
/** 등고선을 받아 올 그은 선 둘레(m). */
|
||||
const CONTOUR_PAD_M = 600;
|
||||
/** 되돌리기 쌓는 수. */
|
||||
const UNDO_MAX = 100;
|
||||
/** 조각 연도 고를 수 — 사업연도 시작부터. */
|
||||
@@ -122,6 +114,12 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
on3d: open3d,
|
||||
onMeasure: () => setMeasure(!measuring),
|
||||
});
|
||||
const backdrop = createPlanLayers({
|
||||
projectId,
|
||||
map,
|
||||
points: () => [start, end, ...allVertices()],
|
||||
});
|
||||
map.root.append(backdrop.bar, backdrop.legend);
|
||||
const main = document.createElement("div");
|
||||
main.className = "d03-plan";
|
||||
main.append(map.root);
|
||||
@@ -210,7 +208,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
measuring = on;
|
||||
measure.clear();
|
||||
if (on) draw.setMode("idle");
|
||||
map.setLayers(measuring ? [draw.layer, measure.layer] : [draw.layer]);
|
||||
map.setLayers([...backdrop.layers, draw.layer, ...(measuring ? [measure.layer] : [])]);
|
||||
refresh();
|
||||
}
|
||||
|
||||
@@ -376,7 +374,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
const job = await watch();
|
||||
if (!job || !root.isConnected) return;
|
||||
failed = job.error;
|
||||
await Promise.all([loadSurface(), loadBackground()]);
|
||||
await Promise.all([loadSurface(), backdrop.load()]);
|
||||
refresh();
|
||||
}
|
||||
|
||||
@@ -417,33 +415,6 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBackground(): Promise<void> {
|
||||
const meta = await fetchVWorldMeta(projectId, "satellite").catch(() => null);
|
||||
if (!meta) return map.update({ meta: null, image: null, contours: null });
|
||||
const image = new Image();
|
||||
image.onload = () => map.redraw();
|
||||
// 범위가 넓어지면 주소가 바뀌어 옛 그림을 안 씀
|
||||
image.src = `${getVWorldMapUrl(projectId, "satellite")}&v=${meta.x_min}_${meta.x_max}_${meta.y_min}_${meta.y_max}`;
|
||||
const points = [start, end, ...allVertices()];
|
||||
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 contours = await fetchCachedSheetLayer<GeoJsonCollection>(
|
||||
projectId,
|
||||
DRAINAGE_SHEET_LAYERS[0],
|
||||
"route",
|
||||
bbox.join(","),
|
||||
)
|
||||
.then((collection) => prepareLayer(collection, createNormalizer(meta), ["등고수치"]))
|
||||
.catch(() => null);
|
||||
map.update({ meta, image, contours });
|
||||
}
|
||||
|
||||
/** 서버 응답 → 지금 값 · 저장본(빈 선 = 시점만). */
|
||||
function takeServer(view: PlanNetworkView): void {
|
||||
if (view.year_start) yearStart = view.year_start;
|
||||
@@ -536,6 +507,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
dirty,
|
||||
saveMs: () => lastSaveMs,
|
||||
heightAt: z,
|
||||
layers: backdrop.state,
|
||||
};
|
||||
const onKey = (event: KeyboardEvent): void => {
|
||||
if (!root.isConnected) return window.removeEventListener("keydown", onKey);
|
||||
@@ -557,7 +529,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
map.setLayers([draw.layer]);
|
||||
map.setLayers([...backdrop.layers, draw.layer]);
|
||||
|
||||
try {
|
||||
editable = !!(await fetchSessionUser())?.is_forest_service;
|
||||
@@ -578,7 +550,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
refresh();
|
||||
await loadSurface();
|
||||
refresh();
|
||||
await loadBackground();
|
||||
await backdrop.load();
|
||||
if ((await fetchPlanProgress(projectId)).terrain.running) await follow();
|
||||
} catch (error) {
|
||||
terrainText.textContent = `불러오지 못했습니다 — ${errorText(error)}`;
|
||||
|
||||
@@ -31,6 +31,22 @@
|
||||
right: var(--spacing-8);
|
||||
}
|
||||
|
||||
/* 569-2 바탕 겹 단추 — 왼쪽 위 · 급경사 범례 — 왼쪽 아래 */
|
||||
.d03-plan__layers {
|
||||
position: absolute;
|
||||
top: var(--spacing-8);
|
||||
left: var(--spacing-8);
|
||||
}
|
||||
|
||||
.d03-plan__legend {
|
||||
position: absolute;
|
||||
bottom: var(--spacing-8);
|
||||
left: var(--spacing-8);
|
||||
padding: var(--spacing-4) var(--spacing-8);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.d03-plan__hint {
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
@@ -95,7 +111,7 @@
|
||||
.d03-plan__3d-body > .b05-route__viewport {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
border-radius: var(--radius-md);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.d03-plan__3d-top {
|
||||
|
||||
@@ -124,7 +124,7 @@ def test_draw_values_are_literal_hex():
|
||||
assert draws, mode
|
||||
for name, value in draws.items():
|
||||
assert HEX.fullmatch(value), (mode, name, value)
|
||||
assert len([k for k in blocks["light"] if k.startswith("--draw-")]) == 17
|
||||
assert len([k for k in blocks["light"] if k.startswith("--draw-")]) == 24
|
||||
|
||||
|
||||
def test_drawing_css_uses_no_screen_tokens():
|
||||
|
||||
@@ -551,6 +551,13 @@ html.is-bm-route .m01lab-src__body * {
|
||||
--draw-surface: #f6f7fa;
|
||||
--draw-surface-raised: #ffffff; /* 그림 바탕 · 3D 바탕 */
|
||||
--draw-canvas: #ffffff; /* CAD 바탕 */
|
||||
--draw-map-contour: #ffd68c; /* 위성 위 겹(노선분석) — 도엽 등고선 */
|
||||
--draw-map-stream: #38bdf8; /* 세류 · 하천 */
|
||||
--draw-map-road: #f8fafc; /* 기존 도로 */
|
||||
--draw-map-parcel: #facc15; /* 지적 경계 */
|
||||
--draw-slope-15: #fde047; /* 급경사 색 15° 이상 */
|
||||
--draw-slope-35: #f97316; /* 35° 이상 */
|
||||
--draw-slope-40: #dc2626; /* 40° 이상 */
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
|
||||
Reference in New Issue
Block a user