- E07_StabArea 화면: 좌 토층 물성(E02 물성 고름 · c · φ 범위) · 강우(지표 포화 · 강우 강도) · 띠 폭 · 격자 · [기본값] · [해석] · 진행률 - 가운데 지도: SINMAP 등급 · 안전율 Fs 색 · 노선 띠 · 측점 · 범례 · 셀 툴팁 · 우 요약 · 등급별 면적 · 측점별 위험 표(누름 = 지도 그 측점) - logic/E07_StabArea_Raster: 저장본 base64 격자 풀기 · 색 · RGBA - 공용 틀: STAB_STEP_ROUTES · group_workflow 단계 줄에 광역 위험 · 사전 ui_template_locale_e07 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
297 lines
10 KiB
TypeScript
297 lines
10 KiB
TypeScript
/* =============================================================================
|
|
* E07_StabArea_UI_Map.ts
|
|
* E07 가운데 평면 지도(PLAN 536-2) — 등급(SI) · 안전율 색 격자 · 노선 띠 · 노선 · 측점 · 고른 측점 ·
|
|
* 방위 · 축척 · 셀 툴팁. 휠 = 확대 · 끌기 = 옮김 · 두 번 누름 = 맞춤. 색 · 풀기 = logic/E07_StabArea_Raster.
|
|
* ========================================================================== */
|
|
|
|
import { el } from "@ui/ui_template_elements";
|
|
import { t, type LocaleKey } from "@ui/ui_template_locale";
|
|
import { CLASS_KEYS, cellIndex, rasterRgba, type Decoded } from "./logic/E07_StabArea_Raster";
|
|
|
|
export type MapLayer = "cls" | "fs";
|
|
|
|
export interface StationPoint {
|
|
chainage_m: number;
|
|
label: string | null;
|
|
x: number;
|
|
y: number;
|
|
}
|
|
|
|
export interface AreaMap {
|
|
root: HTMLElement;
|
|
setRoute: (line: number[][], stations: StationPoint[]) => void;
|
|
setBand: (halfWidthM: number) => void;
|
|
setResult: (data: Decoded | null) => void;
|
|
setLayer: (layer: MapLayer) => void;
|
|
focus: (chainage: number) => 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 createAreaMap(): AreaMap {
|
|
const root = el("div", { className: "e07-map" });
|
|
const canvas = el("canvas", { className: "e07-map__canvas" });
|
|
const tip = el("div", { className: "e07-map__tip" });
|
|
const empty = el("p", { className: "e07-map__empty", text: t("E07_Map_Empty") });
|
|
tip.hidden = true;
|
|
root.append(canvas, tip, empty);
|
|
const ctx = canvas.getContext("2d")!;
|
|
|
|
let line: number[][] = [];
|
|
let stations: StationPoint[] = [];
|
|
let band = 100;
|
|
let data: Decoded | null = null;
|
|
let layer: MapLayer = "cls";
|
|
let image: HTMLCanvasElement | null = null;
|
|
let picked: number | null = null;
|
|
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 (!data) return;
|
|
const { nx, ny } = data.grid;
|
|
const off = document.createElement("canvas");
|
|
off.width = nx;
|
|
off.height = ny;
|
|
off.getContext("2d")!.putImageData(new ImageData(rasterRgba(data, layer), nx, ny), 0, 0);
|
|
image = off;
|
|
}
|
|
|
|
function fit(): void {
|
|
const xs = line.map((p) => p[0]);
|
|
const ys = line.map((p) => p[1]);
|
|
if (!xs.length || !width || !height) return;
|
|
const pad = band;
|
|
const [x0, x1] = [Math.min(...xs) - pad, Math.max(...xs) + pad];
|
|
const [y0, y1] = [Math.min(...ys) - pad, Math.max(...ys) + pad];
|
|
const scale = Math.min(width / Math.max(x1 - x0, 1), height / Math.max(y1 - y0, 1)) * 0.92;
|
|
view = { cx: (x0 + x1) / 2, cy: (y0 + y1) / 2, scale };
|
|
}
|
|
|
|
function path(): void {
|
|
ctx.beginPath();
|
|
line.forEach((p, i) =>
|
|
i ? ctx.lineTo(...toScreen(p[0], p[1])) : ctx.moveTo(...toScreen(p[0], p[1])),
|
|
);
|
|
}
|
|
|
|
function drawRoute(text: string, accent: string): void {
|
|
if (line.length < 2) return;
|
|
ctx.lineJoin = ctx.lineCap = "round";
|
|
// 노선 띠 — 중심선 ± band(옅은 띠)
|
|
ctx.globalAlpha = 0.12;
|
|
ctx.strokeStyle = accent;
|
|
ctx.lineWidth = Math.max(2 * band * view.scale, 1);
|
|
path();
|
|
ctx.stroke();
|
|
ctx.globalAlpha = 1;
|
|
ctx.strokeStyle = text;
|
|
ctx.lineWidth = 2;
|
|
path();
|
|
ctx.stroke();
|
|
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;
|
|
const on = picked !== null && Math.abs(s.chainage_m - picked) < 1e-6;
|
|
ctx.fillStyle = on ? accent : text;
|
|
ctx.beginPath();
|
|
ctx.arc(px, py, on ? 6 : i % every === 0 ? 3 : 1.5, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
if ((on || i % every === 0) && s.label) ctx.fillText(shortLabel(s.label), px + 7, py - 9);
|
|
});
|
|
}
|
|
|
|
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);
|
|
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 (data && image) {
|
|
const g = data.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);
|
|
drawFurniture(text);
|
|
empty.hidden = Boolean(data);
|
|
}
|
|
|
|
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 i = data ? cellIndex(data.grid, mx, my) : -1;
|
|
if (i < 0 || !data!.cls[i]) return void (tip.hidden = true);
|
|
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,
|
|
);
|
|
const m = data!.m[i];
|
|
tip.textContent = [
|
|
t(`E07_Class_${CLASS_KEYS[data!.cls[i] - 1]}` as LocaleKey),
|
|
`SI ${data!.si[i].toFixed(3)} · Fs ${data!.fs[i].toFixed(3)}`,
|
|
`${t("E07_Tip_Slope")} ${data!.slope[i].toFixed(1)}°${Number.isNaN(m) ? "" : ` · m ${m.toFixed(2)}`}`,
|
|
near ? `${t("E07_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 };
|
|
},
|
|
data: () => data,
|
|
layer: () => layer,
|
|
};
|
|
(window as unknown as { __e07Map?: typeof probe }).__e07Map = probe;
|
|
|
|
return {
|
|
root,
|
|
setRoute(nextLine, nextStations) {
|
|
line = nextLine;
|
|
stations = nextStations;
|
|
fit();
|
|
draw();
|
|
},
|
|
setBand(halfWidthM) {
|
|
band = halfWidthM;
|
|
draw();
|
|
},
|
|
setResult(next) {
|
|
data = next;
|
|
buildImage();
|
|
draw();
|
|
},
|
|
setLayer(next) {
|
|
layer = next;
|
|
buildImage();
|
|
draw();
|
|
},
|
|
focus(chainage) {
|
|
picked = chainage;
|
|
const s = stations.find((x) => Math.abs(x.chainage_m - chainage) < 1e-6);
|
|
if (s) view = { ...view, cx: s.x, cy: s.y };
|
|
draw();
|
|
},
|
|
dispose() {
|
|
resize.disconnect();
|
|
theme.disconnect();
|
|
if ((window as unknown as { __e07Map?: unknown }).__e07Map === probe)
|
|
delete (window as unknown as { __e07Map?: unknown }).__e07Map;
|
|
},
|
|
};
|
|
}
|