Files
Aislo/E04_Stab3D/E04_Stab3D_UI_Map.ts
T

375 lines
13 KiB
TypeScript

/* =============================================================================
* E04_Stab3D_UI_Map.ts
* E04 가운데 평면 지도(PLAN 533-3) — 셀별 최소 안전율 · 임계 체적 색 지도 · 임계 활동면 평면 윤곽 ·
* 최소 위치 · 노선 · 측점 · 고른 구간 · 방위 · 축척 · 셀 툴팁. 휠 = 확대 · 끌기 = 옮김 · 두 번 누름 = 맞춤.
* 색 · 범례 · 셀 찾기 = logic/E04_Stab3D_Raster · 윤곽 셀 = logic/E04_Stab3D_Mesh(cutCells).
* ========================================================================== */
import { el } from "@ui/ui_template_elements";
import { t } from "@ui/ui_template_locale";
import { cutCells } from "./logic/E04_Stab3D_Mesh";
import {
FS_STEPS,
VOLUME_STEPS,
cellAt,
rasterRgba,
type Result3d,
} from "./logic/E04_Stab3D_Raster";
export type MapLayer = "fs" | "volume";
export interface StationPoint {
chainage_m: number;
label: string | null;
x: number;
y: number;
}
export interface PlanMap {
root: HTMLElement;
setRoute: (line: number[][], stations: StationPoint[]) => void;
setRange: (start: number, end: number) => void;
setResult: (result: Result3d | null) => void;
setLayer: (layer: MapLayer) => void;
dispose: () => void;
}
interface View {
cx: number;
cy: number;
scale: number; // px / m
}
const shortLabel = (label: string): string => label.replace(/^STA\./, "").replace(/\.0+$/, "");
const css = (name: string, fallback: string): string =>
getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback;
export function createPlanMap(): PlanMap {
const root = el("div", { className: "e04-map" });
const canvas = el("canvas", { className: "e04-map__canvas" });
const tip = el("div", { className: "e04-map__tip" });
const empty = el("p", { className: "e04-map__empty", text: t("E04_Map_Empty") });
tip.hidden = true;
root.append(canvas, tip, empty);
const ctx = canvas.getContext("2d")!;
let line: number[][] = [];
let stations: StationPoint[] = [];
let range: [number, number] | null = null;
let result: Result3d | null = null;
let layer: MapLayer = "fs";
let image: HTMLCanvasElement | null = null;
let outline: Set<number> = new Set();
let view: View = { cx: 0, cy: 0, scale: 1 };
let width = 0;
let height = 0;
const toScreen = (x: number, y: number): [number, number] => [
width / 2 + (x - view.cx) * view.scale,
height / 2 - (y - view.cy) * view.scale,
];
const toModel = (px: number, py: number): [number, number] => [
view.cx + (px - width / 2) / view.scale,
view.cy - (py - height / 2) / view.scale,
];
function buildImage(): void {
image = null;
if (!result) return;
const { grid } = result;
const values = layer === "fs" ? result.f : result.v;
const steps = layer === "fs" ? FS_STEPS : VOLUME_STEPS;
const off = document.createElement("canvas");
off.width = grid.nx;
off.height = grid.ny;
const rgba = rasterRgba(grid, values, steps);
off.getContext("2d")!.putImageData(new ImageData(rgba, grid.nx, grid.ny), 0, 0);
image = off;
}
/** 맞춤 — 결과 격자 · 없으면 고른 구간 노선 · 없으면 노선 전체 */
function fit(): void {
let xs: number[] = [];
let ys: number[] = [];
if (result) {
const g = result.grid;
xs = [g.x0 - g.dx / 2, g.x0 + (g.nx - 0.5) * g.dx];
ys = [g.y0 - g.dx / 2, g.y0 + (g.ny - 0.5) * g.dx];
} else {
const picked = range
? line.filter((p) => p[2] >= range![0] - 1e-6 && p[2] <= range![1] + 1e-6)
: line;
const use = picked.length >= 2 ? picked : line;
xs = use.map((p) => p[0]);
ys = use.map((p) => p[1]);
if (range && picked.length >= 2) {
xs.push(Math.min(...xs) - 40, Math.max(...xs) + 40);
ys.push(Math.min(...ys) - 40, Math.max(...ys) + 40);
}
}
if (!xs.length || !width || !height) return;
const [x0, x1, y0, y1] = [Math.min(...xs), Math.max(...xs), Math.min(...ys), Math.max(...ys)];
const scale = Math.min(width / Math.max(x1 - x0, 1), height / Math.max(y1 - y0, 1)) * 0.9;
view = { cx: (x0 + x1) / 2, cy: (y0 + y1) / 2, scale };
}
function drawRoute(text: string, accent: string): void {
if (line.length < 2) return;
ctx.lineJoin = ctx.lineCap = "round";
ctx.strokeStyle = text;
ctx.lineWidth = 2;
ctx.beginPath();
line.forEach((p, i) =>
i ? ctx.lineTo(...toScreen(p[0], p[1])) : ctx.moveTo(...toScreen(p[0], p[1])),
);
ctx.stroke();
if (range) {
const part = line.filter((p) => p[2] >= range![0] - 1e-6 && p[2] <= range![1] + 1e-6);
ctx.strokeStyle = accent;
ctx.lineWidth = 5;
ctx.beginPath();
part.forEach((p, i) =>
i ? ctx.lineTo(...toScreen(p[0], p[1])) : ctx.moveTo(...toScreen(p[0], p[1])),
);
ctx.stroke();
}
// 측점 — 화면 간격이 좁으면 다섯 개마다 이름
// 이름 = "STA.0+020.000" → "0+020" · 이름끼리 70 px 이상 떨어지게 1 · 2 · 5 · 10 … 개마다
const gap = stations.length > 1 ? 20 * view.scale : 999;
const every = [1, 2, 5, 10, 25, 50, 100].find((n) => n * gap >= 70) ?? 100;
ctx.font = "11px sans-serif";
ctx.textBaseline = "middle";
stations.forEach((s, i) => {
const [px, py] = toScreen(s.x, s.y);
if (px < -50 || py < -50 || px > width + 50 || py > height + 50) return;
ctx.fillStyle = text;
ctx.beginPath();
ctx.arc(px, py, i % every === 0 ? 3 : 1.5, 0, Math.PI * 2);
ctx.fill();
if (i % every === 0 && s.label) ctx.fillText(shortLabel(s.label), px + 6, py - 8);
});
}
/** 임계 활동면 평면 윤곽 — 잘린 셀 무리의 바깥 변 */
function drawOutline(color: string): void {
if (!result || !outline.size) return;
const { grid } = result;
const h = grid.dx / 2;
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.setLineDash([6, 4]);
ctx.beginPath();
for (const index of outline) {
const ix = index % grid.nx;
const iy = Math.floor(index / grid.nx);
const x = grid.x0 + ix * grid.dx;
const y = grid.y0 + iy * grid.dx;
const edges: [number, number, number, number, number][] = [
[index - 1, x - h, y - h, x - h, y + h],
[index + 1, x + h, y - h, x + h, y + h],
[index - grid.nx, x - h, y - h, x + h, y - h],
[index + grid.nx, x - h, y + h, x + h, y + h],
];
for (const [next, ax, ay, bx, by] of edges) {
const sameRow =
next === index - 1 || next === index + 1 ? Math.floor(next / grid.nx) === iy : true;
if (sameRow && outline.has(next)) continue;
ctx.moveTo(...toScreen(ax, ay));
ctx.lineTo(...toScreen(bx, by));
}
}
ctx.stroke();
ctx.setLineDash([]);
}
function drawMarker(text: string): void {
if (!result) return;
const { best } = result;
const [px, py] = toScreen(best.x, best.y);
ctx.lineWidth = 2;
ctx.strokeStyle = "#ffffff";
ctx.beginPath();
ctx.arc(px, py, 9, 0, Math.PI * 2);
ctx.stroke();
ctx.strokeStyle = "#000000";
ctx.beginPath();
ctx.arc(px, py, 11, 0, Math.PI * 2);
ctx.stroke();
ctx.font = "bold 12px sans-serif";
ctx.fillStyle = text;
ctx.fillText(`Fs ${best.f.toFixed(3)}`, px + 14, py);
}
function drawFurniture(text: string): void {
// 방위(오른쪽 위)
ctx.fillStyle = text;
ctx.strokeStyle = text;
ctx.beginPath();
ctx.moveTo(width - 24, 14);
ctx.lineTo(width - 30, 34);
ctx.lineTo(width - 18, 34);
ctx.closePath();
ctx.fill();
ctx.font = "bold 12px sans-serif";
ctx.textAlign = "center";
ctx.fillText("N", width - 24, 46);
// 축척(오른쪽 아래) — 1 · 2 · 5 × 10ⁿ m
const target = 100 / view.scale;
const pow = 10 ** Math.floor(Math.log10(target));
const meters = [1, 2, 5, 10].map((k) => k * pow).find((m) => m >= target) ?? target;
const px = meters * view.scale;
ctx.fillStyle = css("--color-surface", "#ffffff");
ctx.globalAlpha = 0.85;
ctx.fillRect(width - 24 - px, height - 42, px + 16, 34);
ctx.globalAlpha = 1;
ctx.fillStyle = text;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(width - 16 - px, height - 16);
ctx.lineTo(width - 16, height - 16);
ctx.stroke();
ctx.fillText(`${meters} m`, width - 16 - px / 2, height - 26);
ctx.textAlign = "start";
}
function draw(): void {
const dpr = window.devicePixelRatio || 1;
width = root.clientWidth;
height = root.clientHeight;
if (!width || !height) return;
if (canvas.width !== Math.round(width * dpr) || canvas.height !== Math.round(height * dpr)) {
canvas.width = Math.round(width * dpr);
canvas.height = Math.round(height * dpr);
}
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, width, height);
const text = css("--color-text", "#1f2937");
const accent = css("--color-primary", "#6d28d9");
if (result && image) {
const g = result.grid;
const [left, top] = toScreen(g.x0 - g.dx / 2, g.y0 + (g.ny - 0.5) * g.dx);
ctx.imageSmoothingEnabled = false;
ctx.drawImage(image, left, top, g.nx * g.dx * view.scale, g.ny * g.dx * view.scale);
}
drawRoute(text, accent);
drawOutline(text);
drawMarker(text);
drawFurniture(text);
empty.hidden = Boolean(result);
}
// 조작 — 휠 확대(커서 자리 고정) · 끌기 옮김 · 두 번 누름 맞춤 · 셀 툴팁
let drag: { x: number; y: number; view: View } | null = null;
canvas.addEventListener("wheel", (event) => {
event.preventDefault();
const rect = canvas.getBoundingClientRect();
const [mx, my] = toModel(event.clientX - rect.left, event.clientY - rect.top);
const k = Math.exp(-event.deltaY * 0.0015);
view = { scale: view.scale * k, cx: mx - (mx - view.cx) / k, cy: my - (my - view.cy) / k };
draw();
});
canvas.addEventListener("pointerdown", (event) => {
drag = { x: event.clientX, y: event.clientY, view: { ...view } };
canvas.setPointerCapture(event.pointerId);
});
canvas.addEventListener("pointerup", () => (drag = null));
canvas.addEventListener("dblclick", () => {
fit();
draw();
});
canvas.addEventListener("pointerleave", () => (tip.hidden = true));
canvas.addEventListener("pointermove", (event) => {
if (drag) {
view = {
...drag.view,
cx: drag.view.cx - (event.clientX - drag.x) / view.scale,
cy: drag.view.cy + (event.clientY - drag.y) / view.scale,
};
draw();
return;
}
const rect = canvas.getBoundingClientRect();
const [mx, my] = toModel(event.clientX - rect.left, event.clientY - rect.top);
const cell = result ? cellAt(result.grid, mx, my) : null;
const index = cell && result ? cell.iy * result.grid.nx + cell.ix : -1;
const f = index >= 0 ? result!.f[index] : null;
if (f === null) return void (tip.hidden = true);
const v = result!.v[index];
const near = stations.reduce<StationPoint | null>(
(best, s) =>
!best || (s.x - mx) ** 2 + (s.y - my) ** 2 < (best.x - mx) ** 2 + (best.y - my) ** 2
? s
: best,
null,
);
tip.textContent = [
`${t("E04_Layer_FS")} ${f.toFixed(3)}`,
`${t("E04_Layer_Volume")} ${v === null ? "-" : `${Math.round(v).toLocaleString()} m³`}`,
near ? `${t("E04_Tip_Station")} ${near.label ?? near.chainage_m.toFixed(1)}` : "",
]
.filter(Boolean)
.join("\n");
tip.style.left = `${event.clientX - rect.left + 14}px`;
tip.style.top = `${event.clientY - rect.top + 14}px`;
tip.hidden = false;
});
const resize = new ResizeObserver(() => {
const first = !width;
draw();
if (first) {
fit();
draw();
}
});
resize.observe(root);
const theme = new MutationObserver(draw);
theme.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] });
// 검증 창구 — 모델 좌표 → 화면(client) 좌표 · 지금 결과
const probe = {
project: (x: number, y: number) => {
const rect = canvas.getBoundingClientRect();
const [px, py] = toScreen(x, y);
return { x: rect.left + px, y: rect.top + py };
},
result: () => result,
layer: () => layer,
};
(window as unknown as { __e04Map?: typeof probe }).__e04Map = probe;
return {
root,
setRoute(nextLine, nextStations) {
line = nextLine;
stations = nextStations;
fit();
draw();
},
setRange(start, end) {
range = [start, end];
if (!result) fit();
draw();
},
setResult(next) {
result = next;
outline = new Set(next ? cutCells(next.grid, next.z, next.best).keys() : []);
buildImage();
fit();
draw();
},
setLayer(next) {
layer = next;
buildImage();
draw();
},
dispose() {
resize.disconnect();
theme.disconnect();
if ((window as unknown as { __e04Map?: unknown }).__e04Map === probe)
delete (window as unknown as { __e04Map?: unknown }).__e04Map;
},
};
}