- 계획노선 사용 범위: B02 등록에 시작·종료 누가거리 두 칸 추가, B01 수정 모달에서도 변경. projects.route_start_m·route_end_m 신설(015_route_range.sql). load_design_route 가 범위 절단 → 서피스 트림 순서로 적용. 시작 >= 종료는 화면·서버 양쪽에서 차단. 비우면 전 구간으로 종전과 같음. - 서피스 절단 여유 기본값 30m → 3m (SURFACE_ROUTE_EDGE_TRIM_M). - B04 지도·B05 배수유역도 줌 상한을 「화면 폭 20m」 기준으로 계산(고정 8배·16배 폐지). 4배를 넘으면 배경 그림 흐림 보간 해제. - 계획선 위 측점 눈금·번호 표기(측점번호+잔여거리). 관 마커와 겹치면 반대쪽으로 밀고, 되꺾임 구간에서 라벨이 겹치면 건너뜀. 그리기 코드는 두 화면 공용. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
545 lines
23 KiB
TypeScript
545 lines
23 KiB
TypeScript
/* =============================================================================
|
|
* 배수유역도 패널 부품 (B05)
|
|
*
|
|
* 레이어 목록·색·보관 키 같은 상수와, 상태를 갖지 않는 DOM 조각(표시 토글 버튼, 유역 제원
|
|
* 목록)을 모았다. 패널 본체(`B05_Profile_UI_Drainage_Panel.ts`)가 700줄 한계에 닿아
|
|
* 분리한 것으로, 여기 있는 것들은 패널의 내부 상태를 알지 못한다 — 전부 인자로 받는다.
|
|
* ========================================================================== */
|
|
|
|
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
|
import { themeColor } from "@ui/ui_template_palette";
|
|
import { DRAINAGE_SHEET_LAYERS, fetchCachedSheetLayer } from "../A00_Common/b_asset_cache";
|
|
import { fetchVWorldMeta, type VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
|
import {
|
|
computeMapRect,
|
|
computeRouteView,
|
|
type GeoJsonCollection,
|
|
type ViewState,
|
|
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
|
|
import { normalizeStrength, rampColor } from "../B04_PreProcess/B04_PreProcess_UI_FlowRamp";
|
|
import type { MapContextMenu, MapContextMenuItem } from "@ui/ui_template_context_menu";
|
|
import type { DetailBasin } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
|
import type { PipeEditor, PipePoint } from "./B05_Profile_UI_Drainage_Pipes";
|
|
|
|
function L(key: keyof typeof ui_locales): string {
|
|
return ui_locales[key][currentLanguageIndex];
|
|
}
|
|
|
|
/** 배수유역 산정의 근거가 되는 도엽 레이어. 3D는 쓰지 않는다(사용자 지시).
|
|
* 표고점은 유효 데이터가 적어 산정에서 제외했으므로 배경에도 띄우지 않는다(2026-07-31). */
|
|
export const DRAINAGE_LAYERS = DRAINAGE_SHEET_LAYERS;
|
|
export type DrainageLayer = (typeof DRAINAGE_LAYERS)[number];
|
|
|
|
/** 색 값의 정의처는 `ui_template_theme.css`(`--map-*`)다. 여기는 이름만 잇는다. */
|
|
const LAYER_COLOR_TOKENS: Record<DrainageLayer, [name: string, fallback: string]> = {
|
|
도엽_등고선: ["--map-sheet-contour", "#a5b4fc"],
|
|
도엽_하천중심선: ["--map-sheet-stream", "#2563eb"],
|
|
};
|
|
|
|
export const layerColor = (layer: DrainageLayer): string =>
|
|
themeColor(...LAYER_COLOR_TOKENS[layer]);
|
|
|
|
export const LAYER_LABEL_KEYS: Record<DrainageLayer, keyof typeof ui_locales> = {
|
|
도엽_등고선: "B05_Drainage_Layer_Contour",
|
|
도엽_하천중심선: "B05_Drainage_Layer_Stream",
|
|
};
|
|
|
|
/** 도엽 레이어가 아닌 표시 토글의 띠 색 — 지도에 그려지는 선 색과 맞춘다. */
|
|
export const arrowToggleColor = (): string => themeColor("--map-flow-arrow", "#7c3aed");
|
|
export const upstreamToggleColor = (): string => themeColor("--map-upstream-toggle", "#1d4ed8");
|
|
export const strengthToggleColor = (): string => themeColor("--map-flow-ramp-5", "#dc2626");
|
|
export const hotspotToggleColor = (): string => themeColor("--map-flow-ramp-4", "#f97316");
|
|
/** 위성사진은 선이 아니라 배경이라 맞출 선 색이 없다 — 중립 회색을 띠 색으로 쓴다. */
|
|
export const satelliteToggleColor = (): string => themeColor("--map-satellite-toggle", "#64748b");
|
|
|
|
export const COLLAPSED_KEY = "b05-route-drainage-collapsed";
|
|
/** 드래그로 조절한 패널 폭(px) 보관 키 — 브라우저 세션 동안만 유지한다. */
|
|
export const WIDTH_KEY = "b05-route-drainage-width";
|
|
/** 지도가 담기는 최소 폭(px). CSS의 min-width와 같은 값. */
|
|
export const MIN_PANEL_WIDTH = 320;
|
|
/** 상한은 하단 패널 폭의 70%까지(사용자 지시) — 종단면도가 최소한 30%는 남아야 한다. */
|
|
export const MAX_PANEL_WIDTH_RATIO = 0.7;
|
|
|
|
/** 유역 오버레이 파스텔 색상 8종(정의처: `--map-basin-1`~`-8`). 번호 순으로 돌려쓴다. */
|
|
const BASIN_COLOR_FALLBACKS = [
|
|
"rgba(167, 216, 199, 0.45)",
|
|
"rgba(247, 208, 168, 0.45)",
|
|
"rgba(186, 199, 240, 0.45)",
|
|
"rgba(241, 183, 199, 0.45)",
|
|
"rgba(214, 226, 168, 0.45)",
|
|
"rgba(202, 186, 227, 0.45)",
|
|
"rgba(168, 214, 232, 0.45)",
|
|
"rgba(240, 219, 168, 0.45)",
|
|
] as const;
|
|
|
|
/** 유역 번호(1부터)에 대응하는 채움색. 8종을 넘어가면 처음부터 다시 쓴다. */
|
|
export function basinColor(index: number): string {
|
|
const slot = (index - 1) % BASIN_COLOR_FALLBACKS.length;
|
|
return themeColor(`--map-basin-${slot + 1}`, BASIN_COLOR_FALLBACKS[slot]);
|
|
}
|
|
|
|
/** 면적을 사람이 읽는 문구로. 1ha 이상은 ha로 줄인다. */
|
|
export function formatArea(areaM2: number): string {
|
|
return areaM2 >= 10000 ? `${(areaM2 / 10000).toFixed(2)}ha` : `${Math.round(areaM2)}㎡`;
|
|
}
|
|
|
|
/** 제목 우측 표시 토글 — 등고선·세류·흐름 화살표·상류 세류가 모두 같은 양식을 쓴다. */
|
|
export function addLayerToggle(
|
|
container: HTMLElement,
|
|
label: string,
|
|
color: string,
|
|
initial: boolean,
|
|
onToggle: (next: boolean) => void,
|
|
title?: string,
|
|
): HTMLButtonElement {
|
|
const button = document.createElement("button");
|
|
button.type = "button";
|
|
button.className = "b05-drainage__layer-button" + (initial ? " is-active" : "");
|
|
button.textContent = label;
|
|
button.style.setProperty("--b05-layer-color", color);
|
|
button.setAttribute("aria-pressed", String(initial));
|
|
if (title) button.title = title;
|
|
let active = initial;
|
|
button.addEventListener("click", () => {
|
|
active = !active;
|
|
button.classList.toggle("is-active", active);
|
|
button.setAttribute("aria-pressed", String(active));
|
|
onToggle(active);
|
|
});
|
|
container.append(button);
|
|
return button;
|
|
}
|
|
|
|
/** 유역 제원 목록을 다시 그린다. 항목을 누르면 `onPick`으로 번호를 돌려준다. */
|
|
/** 유역 추천을 한 조각 문구로 — 배관이면 규격 스냅 관경, BOX암거·세월교면 검토 표기.
|
|
* 추천이 아직 없는(강우량표 전) 유역은 "미정"이다. */
|
|
function recommendationOf(basin: DetailBasin): string {
|
|
if (basin.recommended_facility === "ford_bridge") return L("B05_Drainage_Basin_Bridge");
|
|
if (basin.recommended_facility === "box_culvert") return L("B05_Drainage_Basin_RecBox");
|
|
if (basin.recommended_diameter_mm == null) return L("B05_Drainage_Basin_Undecided");
|
|
return L("B05_Drainage_Basin_RecPipe").replace("{d}", String(basin.recommended_diameter_mm));
|
|
}
|
|
|
|
export function renderBasinRows(
|
|
container: HTMLElement,
|
|
basins: ReadonlyArray<DetailBasin>,
|
|
selected: number | null,
|
|
onPick: (index: number) => void,
|
|
): void {
|
|
container.textContent = "";
|
|
container.hidden = basins.length === 0;
|
|
basins.forEach((basin) => {
|
|
const row = document.createElement("button");
|
|
row.type = "button";
|
|
row.className = "b05-drainage__basin" + (selected === basin.index ? " is-selected" : "");
|
|
const badge = document.createElement("span");
|
|
badge.className = "b05-drainage__basin-index";
|
|
badge.textContent = String(basin.index);
|
|
badge.style.background = basinColor(basin.index);
|
|
const metrics = document.createElement("span");
|
|
metrics.className = "b05-drainage__basin-metrics";
|
|
// 유효직경은 강우량표(rainfall_table.json)가 생기기 전까지 null → "미정" 표기.
|
|
// 값이 있으면 어떤 시설이든 `계산 유효직경 → 추천` 한 형식으로 적는다 — 세월교만
|
|
// 순서가 뒤집혀 있으면 같은 열을 훑을 때 읽는 방향이 달라진다(2026-08-17 사용자 지적).
|
|
// 추천 근거는 유량뿐이고 지형 조건은 툴팁으로 안내한다.
|
|
const pipe =
|
|
basin.pipe_diameter_mm === null
|
|
? L("B05_Drainage_Basin_Undecided")
|
|
: `Ø${Math.round(basin.pipe_diameter_mm)}mm → ${recommendationOf(basin)}`;
|
|
metrics.textContent = L("B05_Drainage_Basin_Metrics")
|
|
.replace("{area}", formatArea(basin.area_m2))
|
|
.replace("{relief}", basin.relief_m.toFixed(1))
|
|
.replace("{flow}", String(Math.round(basin.flow_length_m)))
|
|
.replace("{pipe}", pipe);
|
|
row.title = L("B05_Drainage_Basin_Chainage").replace("{chainage}", basin.chainage_m.toFixed(1));
|
|
// 산출 근거(도달시간·강우강도·설계유량)는 툴팁 둘째 줄로 붙인다 — 행이 길어지지 않게.
|
|
if (basin.tc_minutes != null && basin.design_flow_m3s != null) {
|
|
row.title +=
|
|
"\n" +
|
|
L("B05_Drainage_Basin_Basis")
|
|
.replace("{tc}", String(basin.tc_minutes))
|
|
.replace("{i}", String(basin.intensity_mm_hr ?? "-"))
|
|
.replace("{q}", String(basin.design_flow_m3s));
|
|
}
|
|
// 추천 근거의 한계를 같은 툴팁에 밝힌다 — 유량만 보고 고른 값이다.
|
|
row.title += "\n" + L("B05_Drainage_Basin_RecNote");
|
|
row.append(badge, metrics);
|
|
row.addEventListener("click", () => onPick(basin.index));
|
|
container.append(row);
|
|
});
|
|
}
|
|
|
|
/** 사업지 좌표계(m) → 화면 px 변환기. 흐름 화살표와 강도 색칠이 같은 식을 쓴다 —
|
|
* 둘이 어긋나면 색은 계획선 위인데 화살표만 밀린 것처럼 보인다. */
|
|
// 미터 좌표 → 화면 좌표 변환기는 B04 지도와 공용이다(정의처: MapRender).
|
|
export { createMetricProjector } from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
|
|
|
|
/** 배관 우클릭 메뉴를 지도 뷰포트에 붙인다 — 마커 위면 삭제, 계획선 위면 추가.
|
|
|
|
* 배관 편집 토글을 없애면서 추가·삭제를 이쪽으로 옮겼다(2026-08-01 사용자 지시). 계획선을
|
|
* 그냥 누르는 것으로 추가하면 유역을 고르려 할 때마다 배관이 생기기 때문이다. */
|
|
export function bindPipeContextMenu(
|
|
viewport: HTMLElement,
|
|
menu: MapContextMenu,
|
|
editor: PipeEditor,
|
|
viewOf: () => ViewState,
|
|
/** 빈 자리 메뉴 항목 — 사이드 「구조물 배치」와 같은 구조물군 → 종류 2단 목록
|
|
* (2026-08-18 일원화, 종단그래프 메뉴와 동일). 없으면 옛 [배관 추가]만 띄운다. */
|
|
structureMenuItems?: (chainageM: number) => MapContextMenuItem[],
|
|
): void {
|
|
viewport.addEventListener("contextmenu", (event) => {
|
|
if (menu.contains(event.target)) {
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
const rect = viewport.getBoundingClientRect();
|
|
const x = event.clientX - rect.left;
|
|
const y = event.clientY - rect.top;
|
|
const view = viewOf();
|
|
const hit = editor.hitAt(view, x, y);
|
|
if (hit !== null) {
|
|
event.preventDefault();
|
|
menu.open(x, y, [
|
|
[
|
|
L("B05_Drainage_Menu_Delete"),
|
|
() => {
|
|
editor.select(hit);
|
|
editor.deleteSelected();
|
|
},
|
|
],
|
|
]);
|
|
return;
|
|
}
|
|
// 계획선에서 먼 자리는 브라우저 기본 메뉴를 그대로 둔다.
|
|
if (!editor.canAddAt(view, x, y)) return;
|
|
event.preventDefault();
|
|
// 빈 자리 — 종단그래프와 같은 구조물군 → 종류 2단 메뉴(2026-08-18 일원화).
|
|
// 선택 = 폼 자동 지정(사이드 addAt 경로, A군은 임시 배치까지).
|
|
const chainage = editor.chainageAt(view, x, y);
|
|
if (structureMenuItems && chainage !== null) {
|
|
menu.open(x, y, structureMenuItems(chainage));
|
|
return;
|
|
}
|
|
menu.open(x, y, [[L("B05_Drainage_Menu_Add"), () => void editor.addAt(view, x, y)]]);
|
|
});
|
|
}
|
|
|
|
/** 유입 집중점 마커 — 계획선 위에서 물이 특히 많이 모이는 자리. 크기·색은 유입면적 로그 스케일.
|
|
* B04 지도와 같은 색띠를 쓴다(같은 값을 다르게 보여 주면 안 된다). */
|
|
export function drawHotspots(
|
|
context: CanvasRenderingContext2D,
|
|
toScreen: (x: number, y: number) => [number, number],
|
|
samples: ReadonlyArray<{ x: number; y: number }>,
|
|
spots: ReadonlyArray<{ chainage: number; area: number }>,
|
|
maximum: number,
|
|
): void {
|
|
if (spots.length === 0 || samples.length === 0) return;
|
|
context.save();
|
|
spots.forEach((spot) => {
|
|
const index = Math.min(samples.length - 1, Math.max(0, Math.round(spot.chainage)));
|
|
const [x, y] = toScreen(samples[index].x, samples[index].y);
|
|
const ratio = normalizeStrength(spot.area, maximum);
|
|
const radius = 3 + 5 * ratio;
|
|
context.beginPath();
|
|
context.arc(x, y, radius, 0, Math.PI * 2);
|
|
context.fillStyle = rampColor(ratio);
|
|
context.fill();
|
|
context.lineWidth = 1.2;
|
|
context.strokeStyle = themeColor("--map-halo", "rgba(255, 255, 255, 0.9)");
|
|
context.stroke();
|
|
});
|
|
context.restore();
|
|
}
|
|
|
|
/** 화면 좌표 폴리곤 안에 점이 있는지(홀짝 규칙). 유역을 눌러 고를 때 쓴다. */
|
|
export function pointInRing(ring: ReadonlyArray<[number, number]>, x: number, y: number): boolean {
|
|
let inside = false;
|
|
for (let index = 0, previous = ring.length - 1; index < ring.length; previous = index++) {
|
|
const [xi, yi] = ring[index];
|
|
const [xj, yj] = ring[previous];
|
|
if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) inside = !inside;
|
|
}
|
|
return inside;
|
|
}
|
|
|
|
/** 표시 토글 한 줄을 통째로 만든다 — 위성/도엽/화살표/강도/집중점/상류세류 6종.
|
|
* 패널이 700줄 한계에 닿아 옮겼다. 상태는 갖지 않고 켜고 끌 때 넘겨받은 함수만 부른다. */
|
|
export function mountDrainageToggles(
|
|
container: HTMLElement,
|
|
handlers: {
|
|
initial: {
|
|
satellite: boolean;
|
|
arrows: boolean;
|
|
strength: boolean;
|
|
hotspots: boolean;
|
|
upstream: boolean;
|
|
};
|
|
onSatellite: (next: boolean) => void;
|
|
onSheetLayer: (layer: DrainageLayer, next: boolean) => void;
|
|
onArrows: (next: boolean) => void;
|
|
onStrength: (next: boolean) => void;
|
|
onHotspots: (next: boolean) => void;
|
|
onUpstream: (next: boolean) => void;
|
|
},
|
|
): void {
|
|
// 배경 위성사진 — 등고선 앞에 둔다(2026-08-01 사용자 지시). 사진이 어두워 유역 채움색이
|
|
// 묻힐 때 끄고 본다. 캔버스가 아니라 배경 이미지라 표시 여부만 직접 바꾼다.
|
|
// 기본 꺼짐(2026-08-18 사용자 지시) — 초기값은 패널이 정한다.
|
|
addLayerToggle(
|
|
container,
|
|
L("B05_Drainage_Layer_Satellite"),
|
|
satelliteToggleColor(),
|
|
handlers.initial.satellite,
|
|
handlers.onSatellite,
|
|
L("B05_Drainage_Layer_Satellite_Tip"),
|
|
);
|
|
DRAINAGE_LAYERS.forEach((layer) => {
|
|
addLayerToggle(container, L(LAYER_LABEL_KEYS[layer]), layerColor(layer), true, (next) =>
|
|
handlers.onSheetLayer(layer, next),
|
|
);
|
|
});
|
|
// 흐름 화살표 — 도면이 지저분해질 때 끄기 위한 토글(등고선·세류와 같은 줄·같은 양식).
|
|
addLayerToggle(
|
|
container,
|
|
L("B05_Drainage_Layer_Arrows"),
|
|
arrowToggleColor(),
|
|
handlers.initial.arrows,
|
|
handlers.onArrows,
|
|
L("B05_Drainage_Layer_Arrows_Tip"),
|
|
);
|
|
// 유입 강도 색칠 — 노선 1m 구간별 상류 면적. B04 지도와 같은 색띠를 쓴다.
|
|
addLayerToggle(
|
|
container,
|
|
L("B05_Drainage_Layer_Strength"),
|
|
strengthToggleColor(),
|
|
handlers.initial.strength,
|
|
handlers.onStrength,
|
|
L("B05_Drainage_Layer_Strength_Tip"),
|
|
);
|
|
// 유입 집중점 — 관을 어디에 둘지 판단하는 근거. 관 마커와 겹쳐 읽기 어려우므로 기본 꺼짐.
|
|
addLayerToggle(
|
|
container,
|
|
L("B05_Drainage_Layer_Hotspots"),
|
|
hotspotToggleColor(),
|
|
handlers.initial.hotspots,
|
|
handlers.onHotspots,
|
|
L("B05_Drainage_Layer_Hotspots_Tip"),
|
|
);
|
|
// 상류 세류선 강조 — 유역 판정의 기준선이라 항상 같은 굵기·색으로 얹는다.
|
|
addLayerToggle(
|
|
container,
|
|
L("B05_Drainage_Layer_Upstream"),
|
|
upstreamToggleColor(),
|
|
handlers.initial.upstream,
|
|
handlers.onUpstream,
|
|
L("B05_Drainage_Layer_Upstream_Tip"),
|
|
);
|
|
}
|
|
|
|
/** 밖에서 바뀐 관 목록을 현재 목록과 맞춘다. 같으면 null — 되먹임 고리를 끊는 지점이다.
|
|
* 다르면 새 목록을 돌려주되, 원래 생성 사유는 자리로 맞춰 이어 붙인다(기본/자동/수동 표시 보존). */
|
|
export function reconcilePipes(
|
|
before: ReadonlyArray<PipePoint>,
|
|
chainages: ReadonlyArray<number>,
|
|
): PipePoint[] | null {
|
|
const next = [...chainages].map((value) => Math.round(value * 100) / 100).sort((a, b) => a - b);
|
|
const current = before
|
|
.map((pipe) => Math.round(pipe.chainage_m * 100) / 100)
|
|
.sort((a, b) => a - b);
|
|
if (next.length === current.length && next.every((value, index) => value === current[index])) {
|
|
return null;
|
|
}
|
|
return next.map((chainage) => {
|
|
const matched = before.find((pipe) => Math.abs(pipe.chainage_m - chainage) < 0.51);
|
|
return { chainage_m: chainage, reason: matched?.reason ?? "confirmed" };
|
|
});
|
|
}
|
|
|
|
/** 배경지도 메타와 도엽 레이어를 한 번에 받아 온다. 한 레이어가 실패해도 나머지는 살린다.
|
|
* 상태를 만지지 않는 I/O 전용 — 패널이 700줄 한계에 닿아 떼어냈다. */
|
|
export async function fetchDrainageLayers(
|
|
projectId: string,
|
|
onProgress: (ratio: number) => void,
|
|
): Promise<{
|
|
meta: VWorldMeta;
|
|
layers: Array<readonly [DrainageLayer, GeoJsonCollection | null]>;
|
|
}> {
|
|
const meta = await fetchVWorldMeta(projectId, "satellite");
|
|
onProgress(1 / 3);
|
|
const layers = await Promise.all(
|
|
DRAINAGE_LAYERS.map(async (layer) => {
|
|
try {
|
|
// 도엽 레이어는 표시용 사본이라 보관함에 담아 두고 새로고침 때 그대로 쓴다.
|
|
return [layer, await fetchCachedSheetLayer<GeoJsonCollection>(projectId, layer)] as const;
|
|
} catch {
|
|
return [layer, null] as const;
|
|
}
|
|
}),
|
|
);
|
|
return { meta, layers };
|
|
}
|
|
|
|
/** 노선 전체가 보이도록 배율·중심을 맞춘다. 노선이 없으면 배경 전체(배율 1)를 그대로 본다.
|
|
* B05는 노선 주변 유역을 보는 화면이라 도로에 맞춰 확대한 상태로 연다(2026-08-01 사용자 지시). */
|
|
export function fitViewToRoute(
|
|
meta: VWorldMeta | null,
|
|
routePoints: ReadonlyArray<{ x: number; y: number }>,
|
|
width: number,
|
|
height: number,
|
|
): { scale: number; offsetX: number; offsetY: number } {
|
|
if (!meta || routePoints.length < 2) return { scale: 1, offsetX: 0, offsetY: 0 };
|
|
let minX = Infinity;
|
|
let minY = Infinity;
|
|
let maxX = -Infinity;
|
|
let maxY = -Infinity;
|
|
routePoints.forEach((point) => {
|
|
if (point.x < minX) minX = point.x;
|
|
if (point.x > maxX) maxX = point.x;
|
|
if (point.y < minY) minY = point.y;
|
|
if (point.y > maxY) maxY = point.y;
|
|
});
|
|
const view = computeRouteView(
|
|
meta,
|
|
{ x_min: minX, x_max: maxX, y_min: minY, y_max: maxY },
|
|
Math.max(width, 1),
|
|
Math.max(height, 1),
|
|
);
|
|
return { scale: view.scale, offsetX: view.offsetX, offsetY: view.offsetY };
|
|
}
|
|
|
|
/** 한 프레임의 화면 상태 — 크기가 바뀌었을 때 보던 자리를 지키는 데 쓴다. */
|
|
export interface ViewAnchor {
|
|
width: number;
|
|
height: number;
|
|
scale: number;
|
|
offsetX: number;
|
|
offsetY: number;
|
|
}
|
|
|
|
/**
|
|
* 패널 크기가 바뀌어도 **배율과 보던 자리를 그대로** 두고 지도를 더 보여 준다.
|
|
*
|
|
* `computeMapRect()`는 지도를 뷰포트에 맞춰 넣으므로(contain), 패널을 늘리면 지도가 함께
|
|
* 확대돼 방금 보던 지점이 어디로 갔는지 알 수 없게 된다(2026-08-02 사용자 지시).
|
|
* 그래서 화면 변환 계수(ax·bx)가 그대로가 되도록 배율·오프셋을 되계산한다. 늘어난 만큼은
|
|
* 확대가 아니라 **더 넓은 범위**로 채워진다.
|
|
*/
|
|
export function preserveViewOnResize(
|
|
meta: VWorldMeta | null,
|
|
previous: ViewAnchor,
|
|
nextWidth: number,
|
|
nextHeight: number,
|
|
): { scale: number; offsetX: number; offsetY: number } {
|
|
const before = computeMapRect(meta, previous.width, previous.height);
|
|
const after = computeMapRect(meta, nextWidth, nextHeight);
|
|
if (!(before.width > 0) || !(after.width > 0)) {
|
|
return { scale: previous.scale, offsetX: previous.offsetX, offsetY: previous.offsetY };
|
|
}
|
|
// 이전 프레임의 변환 계수.
|
|
const ax = before.width * previous.scale;
|
|
const bx =
|
|
before.x * previous.scale + (previous.width / 2) * (1 - previous.scale) + previous.offsetX;
|
|
const by =
|
|
before.y * previous.scale + (previous.height / 2) * (1 - previous.scale) + previous.offsetY;
|
|
// 같은 계수를 유지하는 새 배율·오프셋. mapRect가 지도 비율을 지키므로 x·y가 함께 맞는다.
|
|
const scale = ax / after.width;
|
|
return {
|
|
scale,
|
|
offsetX: bx - after.x * scale - (nextWidth / 2) * (1 - scale),
|
|
offsetY: by - after.y * scale - (nextHeight / 2) * (1 - scale),
|
|
};
|
|
}
|
|
|
|
/** 뷰포트 크기 변화를 지켜보며 배율을 지킨다. 반환값은 정리용 `ResizeObserver`. */
|
|
export function observeViewportSize(
|
|
viewport: HTMLElement,
|
|
ports: {
|
|
meta: () => VWorldMeta | null;
|
|
anchor: () => ViewAnchor;
|
|
apply: (next: { scale: number; offsetX: number; offsetY: number }) => void;
|
|
redraw: () => void;
|
|
},
|
|
): ResizeObserver {
|
|
let last: { width: number; height: number } | null = null;
|
|
const observer = new ResizeObserver(() => {
|
|
const rect = viewport.getBoundingClientRect();
|
|
const width = Math.max(1, Math.floor(rect.width));
|
|
const height = Math.max(1, Math.floor(rect.height));
|
|
if (last && (last.width !== width || last.height !== height)) {
|
|
const previous = ports.anchor();
|
|
ports.apply(
|
|
preserveViewOnResize(
|
|
ports.meta(),
|
|
{ ...previous, width: last.width, height: last.height },
|
|
width,
|
|
height,
|
|
),
|
|
);
|
|
}
|
|
last = { width, height };
|
|
ports.redraw();
|
|
});
|
|
observer.observe(viewport);
|
|
return observer;
|
|
}
|
|
|
|
/** 캔버스 버퍼를 뷰포트 크기·DPR에 맞춘다. 실제로 바뀔 때만 재할당한다(재할당 시 내용이 지워진다). */
|
|
export function fitCanvasToViewport(
|
|
canvas: HTMLCanvasElement,
|
|
width: number,
|
|
height: number,
|
|
previous: { width: number; height: number; dpr: number },
|
|
): { width: number; height: number; dpr: number } {
|
|
const dpr = window.devicePixelRatio || 1;
|
|
if (width === previous.width && height === previous.height && dpr === previous.dpr) {
|
|
return previous;
|
|
}
|
|
canvas.width = Math.floor(width * dpr);
|
|
canvas.height = Math.floor(height * dpr);
|
|
canvas.style.width = `${width}px`;
|
|
canvas.style.height = `${height}px`;
|
|
return { width, height, dpr };
|
|
}
|
|
|
|
/** 진행률(0~1, 모르면 null)과 문구를 로딩 서클에 전달한다. label이 null이면 서클을 감춘다. */
|
|
export function createProgressReporter(progress: {
|
|
root: HTMLElement;
|
|
set: (ratio: number | null, label: string) => void;
|
|
}): (ratio: number | null, label: string | null) => void {
|
|
return (ratio, label) => {
|
|
progress.root.hidden = label === null;
|
|
if (label !== null) progress.set(ratio, label);
|
|
};
|
|
}
|
|
|
|
/** 배관 마커 색 — 같은 누가거리 유역의 파스텔색(불투명). 유역이 없으면 회색. */
|
|
export function pipeMarkerColor(basins: ReadonlyArray<DetailBasin>, chainageM: number): string {
|
|
const basin = basins.find((item) => Math.abs(item.chainage_m - chainageM) < 0.51);
|
|
if (!basin) return themeColor("--map-pipe-orphan", "#e5e7eb");
|
|
return basinColor(basin.index).replace(/0\.45\)$/, "1)");
|
|
}
|
|
|
|
/** 배관 마커가 받는 유역 번호. 마커가 없거나 대응 유역이 없으면 null. */
|
|
export function basinIndexOfPipe(
|
|
basins: ReadonlyArray<DetailBasin>,
|
|
pipe: { chainage_m: number } | null,
|
|
): number | null {
|
|
if (!pipe) return null;
|
|
const basin = basins.find((item) => Math.abs(item.chainage_m - pipe.chainage_m) < 0.51);
|
|
return basin ? basin.index : null;
|
|
}
|
|
|
|
/** 관 개수·세부유역 수·종단 Z 출처 한 줄. 유역이 없으면 줄 자체를 감춘다. */
|
|
export function summaryText(
|
|
element: HTMLElement,
|
|
pipeCount: number,
|
|
basinCount: number,
|
|
zSource: string,
|
|
): void {
|
|
element.textContent = L("B05_Drainage_Summary")
|
|
.replace("{pipes}", String(pipeCount))
|
|
.replace("{basins}", String(basinCount))
|
|
.replace("{source}", zSource || "-");
|
|
element.hidden = basinCount === 0;
|
|
}
|