diff --git a/A00_Common/group_workflow.ts b/A00_Common/group_workflow.ts index 0fa1d7e21..209a42f25 100644 --- a/A00_Common/group_workflow.ts +++ b/A00_Common/group_workflow.ts @@ -31,8 +31,14 @@ const FLOWS: readonly GroupFlow[] = [ }, { routes: STAB_STEP_ROUTES, - labels: ["DE_Step_StabGround", "DE_Step_StabSection", "DE_Step_Stab3D", "DE_Step_StabReport"], - icons: ["๐Ÿชจ", "๐Ÿ“", "๐ŸงŠ", "๐Ÿ“„"], + labels: [ + "DE_Step_StabGround", + "DE_Step_StabSection", + "DE_Step_Stab3D", + "DE_Step_StabReport", + "DE_Step_StabArea", + ], + icons: ["๐Ÿชจ", "๐Ÿ“", "๐ŸงŠ", "๐Ÿ“„", "๐Ÿ—บ๏ธ"], }, ]; diff --git a/E07_StabArea/E07_StabArea_UI_Map.ts b/E07_StabArea/E07_StabArea_UI_Map.ts new file mode 100644 index 000000000..b2a77b581 --- /dev/null +++ b/E07_StabArea/E07_StabArea_UI_Map.ts @@ -0,0 +1,296 @@ +/* ============================================================================= + * 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( + (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; + }, + }; +} diff --git a/E07_StabArea/E07_StabArea_UI_Page.ts b/E07_StabArea/E07_StabArea_UI_Page.ts new file mode 100644 index 000000000..f31216547 --- /dev/null +++ b/E07_StabArea/E07_StabArea_UI_Page.ts @@ -0,0 +1,478 @@ +/* ============================================================================= + * E07_StabArea_UI_Page.ts + * E07 ๊ด‘์—ญ ์ž์—ฐ์‚ฌ๋ฉด ์œ„ํ—˜ ์ง€๋„(์š”๊ตฌ 345 ยท 351 ยท ์š”๊ตฌ์„œ 4-2 2์ฐจ ยท PLAN 536-2) โ€” B05~B09 ํ‹€(group_workflow). + * ์ขŒ = ํ† ์ธต ๋ฌผ์„ฑ(E02 ๋ฌผ์„ฑ ๊ณ ๋ฆ„ ยท ๋ฒ”์œ„) ยท ๊ฐ•์šฐ(์ง€ํ‘œ ํฌํ™” ยท ๊ฐ•์šฐ ๊ฐ•๋„) ยท ๋…ธ์„  ๋  ยท [ํ•ด์„] ยท ์ง„ํ–‰๋ฅ  + * ๊ฐ€์šด๋ฐ = ํ‰๋ฉด ์ง€๋„(SINMAP ๋“ฑ๊ธ‰ ยท ์•ˆ์ „์œจ) ยท ๋ฒ”๋ก€ + * ์šฐ = ์š”์•ฝ(๋“ฑ๊ธ‰๋ณ„ ๋ฉด์  ยท ์œ„ํ—˜ ๋ฉด์ ) ยท ์ธก์ ๋ณ„ ์œ„ํ—˜ ํ‘œ(๋ˆ„๋ฆ„ = ์ง€๋„ ๊ทธ ์ธก์ ) + * ๊ณ„์‚ฐ = ์„œ๋ฒ„ ๋‹จ๋…(POST โ€ฆ/stab/area/analyze ยท ๋’ค์—์„œ) ยท ํ™”๋ฉด์€ ๊ฐ’ ยท ๊ทธ๋ฆผ๋งŒ. ์กฐ๊ฑด = sessionStorage. + * ========================================================================== */ + +import "./E07_StabArea_UI_Style.css"; +import { API_BASE_URL, CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend"; +import { attachCollapsible, createPanelCard } from "@ui/ui_template_collapsible"; +import { + createButton, + createInputField, + createSelectField, + el, + showToast, + type InputFieldHandle, +} from "@ui/ui_template_elements"; +import { t, type LocaleKey } from "@ui/ui_template_locale"; +import { createSidePanels } from "@ui/ui_template_side_panels"; +import { mountGroupStep } from "../A00_Common/group_workflow"; +import { createAreaMap, type MapLayer, type StationPoint } from "./E07_StabArea_UI_Map"; +import { + CLASS_COLORS, + CLASS_KEYS, + FS_STEPS, + RISK_CLASS, + decode, + type AreaResult, +} from "./logic/E07_StabArea_Raster"; + +interface Material { + id: string; + name: string; + gamma_t: number; + gamma_sat: number; + c: number; + phi: number; +} + +type Settings = Record; + +interface Overview { + editable: boolean; + defaults: Settings; + materials: Material[]; + line: number[][]; + stations: StationPoint[]; + result: AreaResult | null; +} + +interface Progress { + running: boolean; + percent: number; + message: string | null; + error: string | null; +} + +const NUMBER_FIELDS: [string, LocaleKey, "soil" | "rain" | "band"][] = [ + ["gamma_t", "E07_GammaT", "soil"], + ["gamma_sat", "E07_GammaSat", "soil"], + ["c", "E07_C", "soil"], + ["c_hi", "E07_CHi", "soil"], + ["phi", "E07_Phi", "soil"], + ["phi_hi", "E07_PhiHi", "soil"], + ["c_root", "E07_CRoot", "soil"], + ["soil_depth_m", "E07_Depth", "soil"], + ["rain_mm_h", "E07_Rain", "rain"], + ["k_m_s", "E07_K", "rain"], + ["band_m", "E07_Band", "band"], + ["cell_m", "E07_Cell", "band"], +]; + +const cacheKey = (projectId: string): string => `e07_settings:${projectId}`; +const shortLabel = (label: string | null, ch: number): string => + (label ?? `${ch.toFixed(1)} m`).replace(/^STA\./, "").replace(/\.0+$/, ""); + +async function api(path: string, init?: RequestInit): Promise { + const response = await fetch(`${API_BASE_URL}${path}`, { + credentials: "include", + headers: { "Content-Type": "application/json" }, + ...init, + }); + const body = (await response.json().catch(() => ({}))) as T & { detail?: unknown }; + if (!response.ok) { + const detail = body.detail; + const text = Array.isArray(detail) + ? detail.map((d) => (d as { msg?: string }).msg ?? "").join(" ยท ") + : detail; + throw new Error(typeof text === "string" && text ? text : `HTTP ${response.status}`); + } + return body; +} + +function swatch(color: string): HTMLElement { + const chip = el("i", { className: "e07-chip" }); + chip.style.background = color; + return chip; +} + +function legend(layer: MapLayer): HTMLElement { + const box = el("div", { className: "e07-legend" }); + box.append(el("strong", { text: t(layer === "cls" ? "E07_Legend_Class" : "E07_Legend_FS") })); + const rows = + layer === "cls" + ? CLASS_KEYS.map((k, i) => ({ + color: CLASS_COLORS[i], + label: t(`E07_Class_${k}` as LocaleKey), + })) + : FS_STEPS.map((s, i) => ({ + color: s.color, + label: + i === 0 + ? `< ${s.below.toFixed(1)}` + : s.below === Infinity + ? `โ‰ฅ ${FS_STEPS[i - 1].below.toFixed(1)}` + : `${FS_STEPS[i - 1].below.toFixed(1)} โ€“ ${s.below.toFixed(1)}`, + })); + for (const row of rows) { + const item = el("span", { className: "e07-legend__row" }); + item.append(swatch(row.color), el("span", { text: row.label })); + box.append(item); + } + return box; +} + +export function renderE07StabArea(root: HTMLElement): void { + const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); + const panel = el("div", { className: "ui-sidebar-panel e07-panel" }); + const main = el("div", { className: "e07-page" }); + mountGroupStep(root, ROUTES.E07_STAB_AREA, { mainContent: main, leftPanel: panel }); + if (!projectId) + return void main.append(el("p", { className: "e07-empty", text: t("E07_NoProject") })); + + // ---- ๊ฐ€์šด๋ฐ ---- + const map = createAreaMap(); + let layer: MapLayer = "cls"; + const legendSlot = el("div", { className: "e07-legend-slot" }); + const paintLegend = (): void => legendSlot.replaceChildren(legend(layer)); + const toggle = el("div", { className: "e07-toggle" }); + const layerButtons = ( + [ + ["E07_Layer_Class", "cls"], + ["E07_Layer_FS", "fs"], + ] as [LocaleKey, MapLayer][] + ).map(([key, value], i) => { + const button = createButton({ label: t(key), variant: "ghost", size: "sm" }); + button.dataset.value = value; + button.classList.toggle("is-on", i === 0); + button.addEventListener("click", () => { + layerButtons.forEach((b) => b.classList.toggle("is-on", b === button)); + layer = value; + map.setLayer(layer); + paintLegend(); + }); + return button; + }); + toggle.append(...layerButtons); + const stage = el("div", { className: "e07-stage" }); + stage.append(map.root, legendSlot); + const center = el("div", { className: "e07-center" }); + center.append(toggle, stage); + paintLegend(); + + // ---- ์šฐ ---- + const resultBody = el("div", { className: "e07-result" }); + const side = createSidePanels({ + prefKey: "e07-stab-area-result", + panels: [{ id: "result", title: t("E07_Result_Title"), body: resultBody }], + defaultOpen: "result", + defaultWidth: 380, + follow: false, + }); + const row = el("div", { className: "ui-side-panels-row e07-row" }); + row.append(center, side.root); + main.append(row); + + // ---- ์ขŒ ---- + const fields = new Map(); + const cards = { + soil: createPanelCard(t("E07_Card_Soil"), false), + rain: createPanelCard(t("E07_Card_Rain"), false), + band: createPanelCard(t("E07_Card_Band"), false), + }; + const material = createSelectField({ label: t("E07_Material"), options: [], compact: true }); + const scenario = createSelectField({ + label: t("E07_Scenario"), + options: [ + { value: "saturated", text: t("E07_Scenario_saturated") }, + { value: "rain", text: t("E07_Scenario_rain") }, + ], + compact: true, + }); + cards.soil.append(material.root); + cards.rain.append(scenario.root); + for (const [key, label, card] of NUMBER_FIELDS) { + const field = createInputField({ label: t(label), type: "number", compact: true }); + field.input.step = "any"; + field.input.dataset.key = key; + fields.set(key, field); + cards[card].append(field.root); + } + const progressBar = el("progress", { className: "e07-progress", attrs: { max: "100" } }); + const progressText = el("p", { className: "e07-progress__text" }); + const progressBox = el("div", { className: "e07-progress-box" }); + progressBox.append(progressBar, progressText); + progressBox.hidden = true; + const defaultsButton = createButton({ + label: t("E07_Defaults"), + variant: "ghost", + onClick: () => overview && write(overview.defaults), + }); + const analyzeButton = createButton({ + label: t("E07_Analyze"), + variant: "filled", + onClick: () => void analyze(false), + }); + const reanalyzeButton = createButton({ + label: t("E07_Reanalyze"), + variant: "ghost", + onClick: () => void analyze(true), + }); + const actions = el("div", { className: "ui-sidebar-actions" }); + actions.append(defaultsButton, reanalyzeButton, analyzeButton); + const readOnly = el("p", { className: "e07-hint", text: t("E07_ReadOnly") }); + readOnly.hidden = true; + panel.append( + el("p", { className: "e07-hint", text: t("E07_Hint") }), + readOnly, + cards.soil, + cards.rain, + cards.band, + progressBox, + actions, + ); + attachCollapsible(panel); + + // ---- ์ƒํƒœ ---- + let overview: Overview | null = null; + let running = false; + + function read(): Settings { + const out: Settings = { material_id: material.select.value || null }; + out.scenario = scenario.select.value; + for (const [key, field] of fields) out[key] = Number(field.input.value); + return out; + } + + function write(values: Settings): void { + for (const [key, field] of fields) field.input.value = String(values[key] ?? ""); + material.select.value = String(values.material_id ?? ""); + scenario.select.value = String(values.scenario ?? "saturated"); + paintScenario(); + remember(); + } + + function paintScenario(): void { + const rain = scenario.select.value === "rain"; + for (const key of ["rain_mm_h", "k_m_s"]) fields.get(key)!.root.hidden = !rain; + map.setBand(Number(fields.get("band_m")!.input.value) || 100); + } + + const remember = (): void => sessionStorage.setItem(cacheKey(projectId), JSON.stringify(read())); + panel.addEventListener("input", remember); + scenario.select.addEventListener("change", () => { + paintScenario(); + remember(); + }); + fields.get("band_m")!.input.addEventListener("input", paintScenario); + material.select.addEventListener("change", () => { + const m = overview?.materials.find((x) => x.id === material.select.value); + if (!m) return; + const set = (key: string, v: number) => (fields.get(key)!.input.value = String(v)); + const rangeC = Number(overview!.defaults.c_hi) - Number(overview!.defaults.c); + const rangePhi = Number(overview!.defaults.phi_hi) - Number(overview!.defaults.phi); + set("gamma_t", m.gamma_t); + set("gamma_sat", m.gamma_sat); + set("c", m.c); + set("c_hi", m.c + rangeC); + set("phi", m.phi); + set("phi_hi", m.phi + rangePhi); + remember(); + }); + + function paintButtons(): void { + const editable = Boolean(overview?.editable); + analyzeButton.disabled = reanalyzeButton.disabled = !editable || running || !overview; + readOnly.hidden = !overview || editable; + } + + function paintResult(): void { + const result = overview?.result; + if (!result) { + resultBody.replaceChildren(el("p", { className: "e07-empty", text: t("E07_Result_None") })); + return; + } + const s = result.summary; + const list = el("dl", { className: "e07-summary" }); + const pairs: [LocaleKey, string][] = [ + ["E07_Sum_FsMin", s.fs_min === null ? "-" : s.fs_min.toFixed(3)], + ["E07_Sum_Risk", `${Math.round(s.risk_area_m2).toLocaleString()} mยฒ`], + ["E07_Sum_RiskStations", `${s.risk_stations.length} / ${s.stations}`], + [ + "E07_Sum_Covered", + `${Math.round(s.covered_area_m2).toLocaleString()} / ${Math.round(s.band_area_m2).toLocaleString()} mยฒ`, + ], + [ + "E07_Sum_Time", + `${result.elapsed_s.toFixed(1)} s ยท ${result.computed_at.replace("T", " ")}`, + ], + ]; + for (const [key, value] of pairs) + list.append( + el("dt", { text: t(key) }), + el("dd", { text: value, attrs: { "data-field": key } }), + ); + const areas = el("table", { className: "e07-table e07-areas" }); + const total = Object.values(s.areas_m2).reduce((a, b) => a + b, 0) || 1; + areas.append( + el("tr", { + children: [ + el("th", { text: t("E07_Col_Class") }), + el("th", { text: t("E07_Col_Area") }), + el("th", { text: "%" }), + ], + }), + ...CLASS_KEYS.map((k, i) => { + const name = el("td"); + name.append( + swatch(CLASS_COLORS[i]), + el("span", { text: t(`E07_Class_${k}` as LocaleKey) }), + ); + return el("tr", { + attrs: { "data-class": k }, + children: [ + name, + el("td", { text: Math.round(s.areas_m2[k] ?? 0).toLocaleString() }), + el("td", { text: (((s.areas_m2[k] ?? 0) / total) * 100).toFixed(1) }), + ], + }); + }), + ); + const table = el("table", { className: "e07-table e07-stations" }); + table.append( + el("tr", { + children: [ + el("th", { text: t("E07_Col_Station") }), + el("th", { text: t("E07_Col_FsMin") }), + el("th", { text: t("E07_Col_SiMin") }), + el("th", { text: t("E07_Col_Worst") }), + el("th", { text: t("E07_Col_RiskArea") }), + ], + }), + ...result.stations.map((r) => { + const worst = el("td"); + if (r.worst_class) + worst.append( + swatch(CLASS_COLORS[r.worst_class - 1]), + el("span", { text: t(`E07_Class_${CLASS_KEYS[r.worst_class - 1]}` as LocaleKey) }), + ); + const tr = el("tr", { + className: (r.worst_class ?? 0) >= RISK_CLASS ? "is-risk" : "", + attrs: { "data-chainage": String(r.chainage_m) }, + children: [ + el("td", { text: shortLabel(r.label, r.chainage_m) }), + el("td", { text: r.fs_min === undefined ? "-" : r.fs_min.toFixed(2) }), + el("td", { text: r.si_min === undefined ? "-" : r.si_min.toFixed(2) }), + worst, + el("td", { + text: + r.risk_area_m2 === undefined ? "-" : Math.round(r.risk_area_m2).toLocaleString(), + }), + ], + }); + tr.addEventListener("click", () => { + table.querySelectorAll("tr.is-on").forEach((x) => x.classList.remove("is-on")); + tr.classList.add("is-on"); + map.focus(r.chainage_m); + }); + return tr; + }), + ); + const notes: HTMLElement[] = []; + if (result.stale) notes.push(el("p", { className: "e07-warn", text: t("E07_Result_Stale") })); + notes.push(el("p", { className: "e07-note", text: t("E07_Result_Note") })); + resultBody.replaceChildren( + list, + el("h4", { text: t("E07_Areas_Title") }), + areas, + el("h4", { text: t("E07_Stations_Title") }), + table, + ...notes, + ); + } + + async function refresh(): Promise { + overview = await api(`/projects/${projectId}/stab/area`); + material.setOptions( + overview.materials.map((m) => ({ value: m.id, text: m.name })), + String(overview.defaults.material_id ?? ""), + ); + map.setResult(overview.result ? decode(overview.result) : null); + paintButtons(); + paintResult(); + } + + async function poll(): Promise { + while (root.isConnected) { + const p = await api(`/projects/${projectId}/stab/area/progress`).catch(() => null); + if (!p) break; + running = p.running; + progressBox.hidden = !p.running; + progressBar.value = p.percent; + progressText.textContent = `${t("E07_Running")} ${p.percent}% ยท ${p.message ?? ""}`; + paintButtons(); + if (!p.running) { + if (p.error) showToast(p.error, "error"); + else showToast(t("E07_Done"), "success"); + await refresh(); + return; + } + await new Promise((resolve) => setTimeout(resolve, 500)); + } + } + + async function analyze(force: boolean): Promise { + try { + const body = await api<{ status: string }>(`/projects/${projectId}/stab/area/analyze`, { + method: "POST", + body: JSON.stringify({ ...read(), force }), + }); + if (body.status === "cached") { + showToast(t("E07_Cached"), "info"); + await refresh(); + return; + } + running = true; + paintButtons(); + progressBox.hidden = false; + void poll(); + } catch (error) { + showToast(error instanceof Error ? error.message : String(error), "error"); + } + } + + // ---- ์ฒ˜์Œ ---- + paintButtons(); + void (async () => { + try { + await refresh(); + } catch (error) { + main.replaceChildren( + el("p", { + className: "e07-empty", + text: error instanceof Error ? error.message : String(error), + }), + ); + return; + } + const ov = overview!; + const cached = JSON.parse( + sessionStorage.getItem(cacheKey(projectId)) ?? "null", + ) as Settings | null; + write(cached ?? (ov.result?.settings as Settings | undefined) ?? ov.defaults); + map.setRoute(ov.line, ov.stations); + const p = await api(`/projects/${projectId}/stab/area/progress`).catch(() => null); + if (p?.running) void poll(); + })(); + + const leave = new MutationObserver(() => { + if (root.isConnected && main.isConnected) return; + leave.disconnect(); + map.dispose(); + }); + leave.observe(document.body, { childList: true, subtree: true }); +} diff --git a/E07_StabArea/E07_StabArea_UI_Style.css b/E07_StabArea/E07_StabArea_UI_Style.css new file mode 100644 index 000000000..ee82afbc2 --- /dev/null +++ b/E07_StabArea/E07_StabArea_UI_Style.css @@ -0,0 +1,231 @@ +/* ============================================================================= + * E07_StabArea_UI_Style.css + * E07 ๊ด‘์—ญ ์œ„ํ—˜ ์ง€๋„ โ€” ์ขŒ์ธก ์กฐ๊ฑด ยท ์ง„ํ–‰ / ๊ฐ€์šด๋ฐ ์ง€๋„ ยท ๋ฒ”๋ก€ / ์šฐ์ธก ์š”์•ฝ ยท ์ธก์  ํ‘œ + * ์ƒ‰์€ ํ…Œ๋งˆ ํ† ํฐ๋งŒ(๋ฐ์Œ ยท ์–ด๋‘์›€ ๊ฐ™์ด) ยท ๋“ฑ๊ธ‰ ยท ์•ˆ์ „์œจ ์ƒ‰๋งŒ logic ๊ณ ์ •์ƒ‰ + * ========================================================================== */ + +.e07-page { + height: 100dvh; + box-sizing: border-box; +} + +.e07-row { + height: 100%; +} + +.e07-center { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + height: 100%; + min-height: 0; + padding: var(--spacing-12) 0 var(--spacing-12) var(--spacing-12); + box-sizing: border-box; +} + +.e07-toggle { + display: inline-flex; + gap: var(--spacing-4); +} + +.e07-toggle .ui-btn.is-on { + border-color: var(--color-primary); + background: var(--color-primary); + color: var(--color-primary-text); +} + +.e07-stage { + position: relative; + flex: 1 1 auto; + min-height: 320px; + border-radius: var(--radius-sm); + background: var(--color-surface-raised); + overflow: hidden; +} + +.e07-map { + position: absolute; + inset: 0; +} + +.e07-map__canvas { + display: block; + width: 100%; + height: 100%; + touch-action: none; + cursor: grab; +} + +.e07-map__tip { + position: absolute; + padding: var(--spacing-4) var(--spacing-8); + border-radius: var(--radius-sm); + background: var(--color-surface); + color: var(--color-text); + border: 1px solid var(--color-border); + font-size: 12px; + white-space: pre; + pointer-events: none; +} + +.e07-map__empty { + position: absolute; + inset: auto 0 45% 0; + margin: 0; + text-align: center; + color: var(--color-text-muted); + pointer-events: none; +} + +.e07-legend-slot { + position: absolute; + left: var(--spacing-8); + bottom: var(--spacing-8); + pointer-events: none; +} + +.e07-legend { + display: flex; + flex-direction: column; + gap: 2px; + padding: var(--spacing-8); + border-radius: var(--radius-sm); + background: var(--color-surface); + color: var(--color-text); + border: 1px solid var(--color-border); + font-size: 12px; +} + +.e07-legend__row { + display: flex; + align-items: center; + gap: var(--spacing-8); +} + +.e07-chip { + display: inline-block; + width: 14px; + height: 10px; + flex: none; + margin-right: var(--spacing-4); + border-radius: 2px; + border: 1px solid var(--color-border); + vertical-align: middle; +} + +/* ์ขŒ์ธก */ +.e07-hint, +.e07-note { + margin: 0 0 var(--spacing-8); + color: var(--color-text-muted); + font-size: 12px; + line-height: 1.5; +} + +.e07-panel .ui-field[hidden] { + display: none; +} + +.e07-progress-box { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + margin: var(--spacing-8) 0; +} + +.e07-progress-box[hidden] { + display: none; +} + +.e07-progress { + width: 100%; +} + +.e07-progress__text { + margin: 0; + color: var(--color-text); + font-size: 12px; +} + +/* ์šฐ์ธก */ +.e07-result { + padding: var(--spacing-12); + color: var(--color-text); +} + +.e07-result h4 { + margin: var(--spacing-16) 0 var(--spacing-8); + font-size: 13px; +} + +.e07-summary { + display: grid; + grid-template-columns: auto 1fr; + gap: var(--spacing-8) var(--spacing-12); + margin: 0; + font-size: 13px; +} + +.e07-summary dt { + color: var(--color-text-muted); +} + +.e07-summary dd { + margin: 0; + font-weight: 600; + text-align: right; +} + +.e07-table { + width: 100%; + border-collapse: collapse; + font-size: 12px; +} + +.e07-table th, +.e07-table td { + padding: var(--spacing-4) var(--spacing-8); + border-bottom: 1px solid var(--color-border); + text-align: right; + white-space: nowrap; +} + +.e07-table th:first-child, +.e07-table td:first-child, +.e07-stations td:nth-child(4) { + text-align: left; +} + +.e07-table th { + color: var(--color-text-muted); + font-weight: 600; +} + +.e07-stations tr[data-chainage] { + cursor: pointer; +} + +.e07-stations tr[data-chainage]:hover { + background: var(--color-surface-raised); +} + +.e07-stations tr.is-risk td { + color: var(--color-danger); + font-weight: 600; +} + +.e07-stations tr.is-on { + outline: 2px solid var(--color-primary); + outline-offset: -2px; +} + +.e07-warn { + margin: var(--spacing-12) 0 0; + color: var(--color-danger); + font-size: 12px; +} + +.e07-empty { + margin: var(--spacing-16); + color: var(--color-text-muted); +} diff --git a/E07_StabArea/draft/E07_StabArea_Route.ts b/E07_StabArea/draft/E07_StabArea_Route.ts new file mode 100644 index 000000000..08b2db164 --- /dev/null +++ b/E07_StabArea/draft/E07_StabArea_Route.ts @@ -0,0 +1,16 @@ +/* ============================================================================= + * E07_StabArea_Route.ts + * ๊ธธ ์„ ์–ธ(PLAN 536-2) โ€” ๋ผ์šฐํ„ฐ๊ฐ€ ํŽ˜์ด์ง€ ํด๋”๋งˆ๋‹ค draft/*_Route.ts ๋ฅผ glob ํ•ด ๋“ฑ๋ก. + * ๋กœ๊ทธ์ธ ยท ํšŒ์‚ฌ ยท ํ˜„์žฌ ํ”„๋กœ์ ํŠธ ํ•„์š” ยท ๊ผฌ๋ฆฌ๋ง ์—†์Œ ยท ๋ณธ๋ฌธ = E07 ๊ด‘์—ญ ์œ„ํ—˜ ์ง€๋„ ํŽ˜์ด์ง€ + * ========================================================================== */ + +import { ROUTES } from "@config/config_frontend"; +import type { DraftRoute } from "../../A00_Common/router"; + +export const ROUTE: DraftRoute = { + path: ROUTES.E07_STAB_AREA, + workflow: true, + project: true, + footerless: true, + load: async () => (await import("../E07_StabArea_UI_Page")).renderE07StabArea, +}; diff --git a/E07_StabArea/logic/E07_StabArea_Raster.ts b/E07_StabArea/logic/E07_StabArea_Raster.ts new file mode 100644 index 000000000..a9a00b8e7 --- /dev/null +++ b/E07_StabArea/logic/E07_StabArea_Raster.ts @@ -0,0 +1,153 @@ +/* ============================================================================= + * E07_StabArea_Raster.ts + * E07 ๊ด‘์—ญ ์œ„ํ—˜ ์ง€๋„ ํ™”๋ฉด ๊ณ„์‚ฐ(PLAN 536-2) โ€” ์ €์žฅ๋ณธ ๊ฒฉ์ž ํ’€๊ธฐ ยท ๋“ฑ๊ธ‰ ยท ์•ˆ์ „์œจ ์ƒ‰ ยท RGBA ยท ์…€ ์ฐพ๊ธฐ. + * ๊ฒฉ์ž ๊ผด = ์„œ๋ฒ„ `E07_StabArea_Router.py` ๋จธ๋ฆฌ ์ฃผ์„(fs ยท si uint16 ร—1000 ยท cls uint8 ยท slope ร—10 ยท + * m ร—100 ยท ์ž‘์€ ๋ base64 ยท ํ–‰ 0 = ๋‚จ์ชฝ). ์ˆœ์ˆ˜ ํ•จ์ˆ˜๋งŒ(DOM ยท fetch ๊ธˆ์ง€). + * ========================================================================== */ + +export interface Grid { + x0: number; + y0: number; + dx: number; + nx: number; + ny: number; +} + +export interface StationRow { + chainage_m: number; + label: string | null; + cells: number; + fs_min?: number; + si_min?: number; + worst_class?: number; + risk_area_m2?: number; + slope_max_deg?: number; +} + +export interface AreaSummary { + areas_m2: Record; + band_area_m2: number; + covered_area_m2: number; + risk_area_m2: number; + fs_min: number | null; + risk_stations: number[]; + stations: number; +} + +export interface AreaResult { + stale?: boolean; + computed_at: string; + elapsed_s: number; + settings: Record; + grid: Grid; + fs: string; + si: string; + cls: string; + slope: string; + m: string; + stations: StationRow[]; + summary: AreaSummary; +} + +/** ํ’€์–ด ๋‘” ๊ฒฉ์ž โ€” ์ž๋ฃŒ ์—†์Œ = NaN(fs ยท si ยท slope ยท m) ยท 0(cls) */ +export interface Decoded { + grid: Grid; + fs: Float32Array; + si: Float32Array; + cls: Uint8Array; + slope: Float32Array; + m: Float32Array; +} + +/** ๋“ฑ๊ธ‰ ์—ด์‡ (์„œ๋ฒ„ CLASS_KEYS ์ฐจ๋ก€ ยท 1~6) */ +export const CLASS_KEYS = ["stable", "moderate", "quasi", "lower", "upper", "defended"] as const; +export const RISK_CLASS = 4; + +/** ๋“ฑ๊ธ‰ ์ƒ‰ 1~6 โ€” SINMAP ๊ด€๋ก€(์ดˆ๋ก โ†’ ๋นจ๊ฐ•) */ +export const CLASS_COLORS = ["#1a9850", "#91cf60", "#d9ef8b", "#fee08b", "#fc8d59", "#d73027"]; + +/** ์•ˆ์ „์œจ ์ƒ‰ ๋‹จ๊ณ„ โ€” 1.0 ยท 1.2 ยท 1.5 ๊ฒฝ๊ณ„ */ +export const FS_STEPS: readonly { below: number; color: string }[] = [ + { below: 1.0, color: "#a50026" }, + { below: 1.2, color: "#f46d43" }, + { below: 1.5, color: "#fee08b" }, + { below: 2.0, color: "#a6d96a" }, + { below: Infinity, color: "#1a9850" }, +]; + +function bytes(text: string): Uint8Array { + const raw = atob(text); + const out = new Uint8Array(raw.length); + for (let i = 0; i < raw.length; i += 1) out[i] = raw.charCodeAt(i); + return out; +} + +function u16(text: string, scale: number): Float32Array { + const b = bytes(text); + const view = new DataView(b.buffer); + const out = new Float32Array(b.length / 2); + for (let i = 0; i < out.length; i += 1) { + const v = view.getUint16(i * 2, true); + out[i] = v === 65535 ? NaN : v / scale; + } + return out; +} + +export function decode(result: AreaResult): Decoded { + const m = bytes(result.m); + return { + grid: result.grid, + fs: u16(result.fs, 1000), + si: u16(result.si, 1000), + cls: bytes(result.cls), + slope: u16(result.slope, 10), + m: Float32Array.from(m, (v) => (v === 255 ? NaN : v / 100)), + }; +} + +function rgb(hex: string): [number, number, number] { + const n = Number.parseInt(hex.slice(1), 16); + return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; +} + +export function fsColor(value: number): string { + return (FS_STEPS.find((s) => value < s.below) ?? FS_STEPS[FS_STEPS.length - 1]).color; +} + +/** ๊ฒฉ์ž โ†’ RGBA(์ค„ 0 = ๋ถ์ชฝ ๋ ยท ์บ”๋ฒ„์Šค ๊ทธ๋ฆผ์šฉ) ยท ์ž๋ฃŒ ์—†์Œ = ํˆฌ๋ช… */ +export function rasterRgba(data: Decoded, layer: "cls" | "fs"): Uint8ClampedArray { + const { nx, ny } = data.grid; + const out = new Uint8ClampedArray(nx * ny * 4); + const classRgb = CLASS_COLORS.map(rgb); + const fsRgb = FS_STEPS.map((s) => rgb(s.color)); + for (let iy = 0; iy < ny; iy += 1) { + const row = ny - 1 - iy; + for (let ix = 0; ix < nx; ix += 1) { + const i = iy * nx + ix; + const k = data.cls[i]; + if (!k) continue; + const j = FS_STEPS.findIndex((s) => data.fs[i] < s.below); + const color = layer === "cls" ? classRgb[k - 1] : fsRgb[j < 0 ? fsRgb.length - 1 : j]; + const at = (row * nx + ix) * 4; + out[at] = color[0]; + out[at + 1] = color[1]; + out[at + 2] = color[2]; + out[at + 3] = 255; + } + } + return out; +} + +/** ๋ชจ๋ธ ์ขŒํ‘œ โ†’ ์…€ ๋ฒˆํ˜ธ(๊ฒฉ์ž ๋ฐ– = -1) */ +export function cellIndex(grid: Grid, x: number, y: number): number { + const ix = Math.round((x - grid.x0) / grid.dx); + const iy = Math.round((y - grid.y0) / grid.dx); + return ix < 0 || iy < 0 || ix >= grid.nx || iy >= grid.ny ? -1 : iy * grid.nx + ix; +} + +/** ๋“ฑ๊ธ‰๋ณ„ ์…€ ์ˆ˜(1~6) โ€” ๋ฒ”๋ก€ ยท ๊ฒ€์ฆ์šฉ */ +export function classCounts(data: Decoded): number[] { + const out = [0, 0, 0, 0, 0, 0]; + for (const k of data.cls) if (k) out[k - 1] += 1; + return out; +} diff --git a/config/config_frontend.ts b/config/config_frontend.ts index 9d295ec1c..f51c90f91 100644 --- a/config/config_frontend.ts +++ b/config/config_frontend.ts @@ -130,6 +130,7 @@ export const ROUTES = { E03_STAB_SECTION: "e03-stab-section", E04_STAB_3D: "e04-stab-3d", E05_STAB_REPORT: "e05-stab-report", + E07_STAB_AREA: "e07-stab-area", } as const; /** D ์˜ˆ์ •๋…ธ์„  ๋‹จ๊ณ„ ์ฃผ์†Œ(D02 โ†’ D04 ยท ๋“ฑ๋ก D01 ์€ ๋‹จ๊ณ„ ๋ฐ–) */ @@ -139,12 +140,13 @@ export const PLAN_STEP_ROUTES = [ ROUTES.D04_PLAN_CONFIRM, ] as const; -/** E ์•ˆ์ •์„ฑํ‰๊ฐ€ ๋‹จ๊ณ„ ์ฃผ์†Œ(E02 โ†’ E05 ยท ์ƒ์„ฑ E01 ์€ ๋‹จ๊ณ„ ๋ฐ–) */ +/** E ์•ˆ์ •์„ฑํ‰๊ฐ€ ๋‹จ๊ณ„ ์ฃผ์†Œ(E02 โ†’ E05 ยท ๊ด‘์—ญ E07 ยท ์ƒ์„ฑ E01 ์€ ๋‹จ๊ณ„ ๋ฐ–) */ export const STAB_STEP_ROUTES = [ ROUTES.E02_STAB_GROUND, ROUTES.E03_STAB_SECTION, ROUTES.E04_STAB_3D, ROUTES.E05_STAB_REPORT, + ROUTES.E07_STAB_AREA, ] as const; export type RouteKey = keyof typeof ROUTES; diff --git a/ui_template/ui_template_locale.ts b/ui_template/ui_template_locale.ts index a5309b16a..2d80685d3 100644 --- a/ui_template/ui_template_locale.ts +++ b/ui_template/ui_template_locale.ts @@ -16,6 +16,7 @@ * - ui_template_locale_b5.ts : B ๊ทธ๋ฃน ๋ (B08~B11) * - ui_template_locale_de.ts : D ์˜ˆ์ •๋…ธ์„  ยท E ์•ˆ์ •์„ฑํ‰๊ฐ€ ยท ๋Œ€์‹œ๋ณด๋“œ [+] ๊ณ ๋ฅด๊ธฐ * - ui_template_locale_e04.ts : E04 ์•ˆ์ •์„ฑํ‰๊ฐ€ 3D ํ•ด์„ + * - ui_template_locale_e07.ts : E07 ์•ˆ์ •์„ฑํ‰๊ฐ€ ๊ด‘์—ญ ์œ„ํ—˜ ์ง€๋„ * * ์ด ํŒŒ์ผ์˜ export ์‹œ๊ทธ๋‹ˆ์ฒ˜๋Š” ๋ถ„ํ•  ์ด์ „๊ณผ ๋™์ผํ•˜๋ฏ€๋กœ ์†Œ๋น„์ฒ˜ import ์ˆ˜์ •์€ ๋ถˆํ•„์š”ํ•˜๋‹ค. * ========================================================================== */ @@ -35,6 +36,7 @@ import { ui_locales_m2 } from "./ui_template_locale_m2"; import { ui_locales_de } from "./ui_template_locale_de"; import { ui_locales_e } from "./ui_template_locale_e"; import { ui_locales_e05 } from "./ui_template_locale_e05"; +import { ui_locales_e07 } from "./ui_template_locale_e07"; /** ์ง€์› ์–ธ์–ด ์ธ๋ฑ์Šค: 0 = ํ•œ๊ตญ์–ด, 1 = ์˜์–ด */ export const LANGUAGES = ["ko", "en"] as const; @@ -77,6 +79,7 @@ export const ui_locales = { ...ui_locales_de, ...ui_locales_e, ...ui_locales_e05, + ...ui_locales_e07, } as const; export type LocaleKey = keyof typeof ui_locales; diff --git a/ui_template/ui_template_locale_de.ts b/ui_template/ui_template_locale_de.ts index a08abd22d..c5a587294 100644 --- a/ui_template/ui_template_locale_de.ts +++ b/ui_template/ui_template_locale_de.ts @@ -17,6 +17,7 @@ export const ui_locales_de = { DE_Step_StabSection: ["2D ํ•ด์„", "2D Analysis"], DE_Step_Stab3D: ["3D ํ•ด์„", "3D Analysis"], DE_Step_StabReport: ["๋ณด๊ณ ์„œ", "Report"], + DE_Step_StabArea: ["๊ด‘์—ญ ์œ„ํ—˜", "Area Hazard"], DE_Step_Empty: ["์ค€๋น„ ์ค‘์ธ ๋‹จ๊ณ„์ž…๋‹ˆ๋‹ค.", "This step is not ready yet."], /* --- D04 ํ™•์ • ยท ์—…์ฒด ยท ๋‚ด๋ณด๋‚ด๊ธฐ ยท B03 ์˜ˆ์ •๋…ธ์„  ๊ฐ€์ ธ์˜ค๊ธฐ(531-3) --- */ diff --git a/ui_template/ui_template_locale_e07.ts b/ui_template/ui_template_locale_e07.ts new file mode 100644 index 000000000..036187a13 --- /dev/null +++ b/ui_template/ui_template_locale_e07.ts @@ -0,0 +1,83 @@ +/* ============================================================================= + * ui_template_locale_e07.ts + * ๋‹ค๊ตญ์–ด ์‚ฌ์ „ โ€” E07 ์•ˆ์ •์„ฑํ‰๊ฐ€ ๊ด‘์—ญ ์ž์—ฐ์‚ฌ๋ฉด ์œ„ํ—˜ ์ง€๋„(์š”๊ตฌ 345 ยท 351 ยท PLAN 536-2) + * + * ์‹ ๊ทœ ๋ฌธ๊ตฌ๋Š” ํ•ด๋‹น ์„น์…˜ ์ตœํ•˜๋‹จ์— ์ถ”๊ฐ€. + * ========================================================================== */ + +import type { LocaleEntry } from "./ui_template_locale_common"; + +export const ui_locales_e07 = { + /* --- E07 ๊ด‘์—ญ ์œ„ํ—˜ ์ง€๋„ --- */ + E07_NoProject: ["ํ”„๋กœ์ ํŠธ๋ฅผ ๋จผ์ € ๊ณ ๋ฅด์„ธ์š”.", "Pick a project first."], + E07_Hint: [ + "๋…ธ์„  ๋  ์•ˆ ์ž์—ฐ์‚ฌ๋ฉด์˜ ํ‘œ์ธต ๋ถ•๊ดด ์œ„ํ—˜ โ€” ๋ฌดํ•œ์‚ฌ๋ฉด ์•ˆ์ „์œจ๊ณผ SINMAP ์•ˆ์ • ์ง€์ˆ˜(SI)๋กœ ๋“ฑ๊ธ‰์„ ๋งค๊น๋‹ˆ๋‹ค. ์ง€ํ˜• = ๋ณต์‚ฌํ•œ ์›์ง€ํ˜•.", + "Shallow-landslide hazard of natural slopes in the route band โ€” infinite-slope Fs and SINMAP stability index (SI). Terrain = copied original DTM.", + ], + E07_ReadOnly: ["๋ณด๊ธฐ๋งŒ ํ—ˆ์šฉ๋œ ํ”„๋กœ์ ํŠธ์ž…๋‹ˆ๋‹ค.", "This project is view-only."], + E07_Card_Soil: ["ํ† ์ธต ๋ฌผ์„ฑ", "Soil layer"], + E07_Card_Rain: ["๊ฐ•์šฐ ยท ํฌํ™”", "Rain ยท saturation"], + E07_Card_Band: ["๋…ธ์„  ๋  ยท ๊ฒฉ์ž", "Route band ยท grid"], + E07_Material: ["๋ฌผ์„ฑ(E02 ์ง€๋ฐ˜ ์กฐ๊ฑด)", "Material (E02 ground)"], + E07_GammaT: ["ฮณt (kN/mยณ)", "ฮณt (kN/mยณ)"], + E07_GammaSat: ["ฮณsat (kN/mยณ)", "ฮณsat (kN/mยณ)"], + E07_C: ["c ์•„๋ž˜ (kPa)", "c low (kPa)"], + E07_CHi: ["c ์œ„ (kPa)", "c high (kPa)"], + E07_Phi: ["ฯ† ์•„๋ž˜ (ยฐ)", "ฯ† low (ยฐ)"], + E07_PhiHi: ["ฯ† ์œ„ (ยฐ)", "ฯ† high (ยฐ)"], + E07_CRoot: ["๋ฟŒ๋ฆฌ ์ ์ฐฉ (kPa)", "Root cohesion (kPa)"], + E07_Depth: ["ํ† ์ธต ๋‘๊ป˜ (m)", "Soil depth (m)"], + E07_Scenario: ["์‹œ๋‚˜๋ฆฌ์˜ค", "Scenario"], + E07_Scenario_saturated: ["์ง€ํ‘œ ํฌํ™” (m = 1)", "Fully saturated (m = 1)"], + E07_Scenario_rain: ["๊ฐ•์šฐ ๊ฐ•๋„ (์ •์ƒ๋ฅ˜)", "Rainfall (steady state)"], + E07_Rain: ["๊ฐ•์šฐ ๊ฐ•๋„ (mm/h)", "Rainfall (mm/h)"], + E07_K: ["ํˆฌ์ˆ˜๊ณ„์ˆ˜ (m/s)", "Hydraulic conductivity (m/s)"], + E07_Band: ["๋  ํญ ยฑ (m)", "Band half-width (m)"], + E07_Cell: ["๊ฒฉ์ž (m)", "Cell (m)"], + E07_Defaults: ["๊ธฐ๋ณธ๊ฐ’", "Defaults"], + E07_Analyze: ["ํ•ด์„", "Analyze"], + E07_Reanalyze: ["๋‹ค์‹œ ํ•ด์„", "Re-analyze"], + E07_Running: ["ํ•ด์„ ์ค‘", "Analyzing"], + E07_Done: ["๊ด‘์—ญ ํ•ด์„์„ ๋งˆ์ณค์Šต๋‹ˆ๋‹ค.", "Area analysis finished."], + E07_Cached: ["๊ฐ™์€ ์กฐ๊ฑด์˜ ๊ฒฐ๊ณผ๊ฐ€ ์žˆ์Šต๋‹ˆ๋‹ค.", "A result with the same inputs exists."], + E07_Layer_Class: ["๋“ฑ๊ธ‰ (SI)", "Class (SI)"], + E07_Layer_FS: ["์•ˆ์ „์œจ Fs", "Safety factor Fs"], + E07_Legend_Class: ["SINMAP ๋“ฑ๊ธ‰", "SINMAP class"], + E07_Legend_FS: ["๋ฌดํ•œ์‚ฌ๋ฉด Fs", "Infinite-slope Fs"], + E07_Class_stable: ["์•ˆ์ • (SI โ‰ฅ 1.5)", "Stable (SI โ‰ฅ 1.5)"], + E07_Class_moderate: ["๋ณดํ†ต ์•ˆ์ • (โ‰ฅ 1.25)", "Moderately stable (โ‰ฅ 1.25)"], + E07_Class_quasi: ["์ค€์•ˆ์ • (โ‰ฅ 1.0)", "Quasi-stable (โ‰ฅ 1.0)"], + E07_Class_lower: ["์œ„ํ—˜ ํ•˜ํ•œ (โ‰ฅ 0.5)", "Lower threshold (โ‰ฅ 0.5)"], + E07_Class_upper: ["์œ„ํ—˜ ์ƒํ•œ (> 0)", "Upper threshold (> 0)"], + E07_Class_defended: ["๋ถˆ์•ˆ์ • (SI = 0)", "Defended (SI = 0)"], + E07_Map_Empty: [ + "[ํ•ด์„] ์„ ๋ˆ„๋ฅด๋ฉด ์œ„ํ—˜ ์ง€๋„๊ฐ€ ๋‚˜์˜ต๋‹ˆ๋‹ค.", + "Press [Analyze] to draw the hazard map.", + ], + E07_Tip_Slope: ["๊ฒฝ์‚ฌ", "Slope"], + E07_Tip_Station: ["๊ฐ€๊นŒ์šด ์ธก์ ", "Nearest station"], + E07_Result_Title: ["๊ด‘์—ญ ๊ฒฐ๊ณผ", "Area result"], + E07_Result_None: ["์•„์ง ํ•ด์„ ๊ฒฐ๊ณผ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.", "No result yet."], + E07_Result_Stale: [ + "์—”์ง„์ด ๋ฐ”๋€Œ์–ด ๊ฒฐ๊ณผ๊ฐ€ ์˜›๊ฒƒ์ž…๋‹ˆ๋‹ค โ€” [๋‹ค์‹œ ํ•ด์„].", + "The engine changed โ€” re-analyze.", + ], + E07_Result_Note: [ + "๋ฌธํ—Œ ์ถ”์ •์น˜ ยท ํ˜„์žฅ ์‹œํ—˜ ์—†์Œ. SI = c ยท ฯ† ๋ฒ”์œ„ ์•ˆ์—์„œ Fs > 1 ์ผ ํ™•๋ฅ (SI โ‰ฅ 1 ์ด๋ฉด ๊ฐ€์žฅ ๋‚˜์œ ๊ฐ’์˜ Fs). ์ง€ํ˜• ๊ฒฉ์ž ๋ ๋ฐ– ์ƒ๋ฅ˜๋Š” ์ง‘์ˆ˜๋ฉด์ ์— ์•ˆ ๋“ค์–ด๊ฐ‘๋‹ˆ๋‹ค.", + "Literature values, no field tests. SI = probability of Fs > 1 over the c ยท ฯ† ranges (worst-case Fs when SI โ‰ฅ 1). Upslope area beyond the terrain grid is not counted.", + ], + E07_Sum_FsMin: ["๋  ์•ˆ ์ตœ์†Œ Fs", "Min Fs in band"], + E07_Sum_Risk: ["์œ„ํ—˜ ๋ฉด์  (SI < 1)", "Hazard area (SI < 1)"], + E07_Sum_RiskStations: ["์œ„ํ—˜ ์ธก์ ", "Hazard stations"], + E07_Sum_Covered: ["์ง€ํ˜• ์žˆ๋Š” ๋  / ๋ ", "Band with terrain / band"], + E07_Sum_Time: ["ํ•ด์„", "Computed"], + E07_Areas_Title: ["๋“ฑ๊ธ‰๋ณ„ ๋ฉด์ ", "Area by class"], + E07_Stations_Title: ["์ธก์ ๋ณ„ ์œ„ํ—˜ (๋ˆ„๋ฅด๋ฉด ์ง€๋„๋กœ)", "Hazard by station (click to locate)"], + E07_Col_Class: ["๋“ฑ๊ธ‰", "Class"], + E07_Col_Area: ["๋ฉด์  (mยฒ)", "Area (mยฒ)"], + E07_Col_Station: ["์ธก์ ", "Station"], + E07_Col_FsMin: ["์ตœ์†Œ Fs", "Min Fs"], + E07_Col_SiMin: ["์ตœ์†Œ SI", "Min SI"], + E07_Col_Worst: ["๋‚˜์œ ๋“ฑ๊ธ‰", "Worst class"], + E07_Col_RiskArea: ["์œ„ํ—˜ mยฒ", "Hazard mยฒ"], +} as const satisfies Record;