- 좌측 목록 · 횡단 카드 제목 = 공용 stationLabel(누가, 측점 목록 추정 간격, 소수 한 자리) - 평면 위험 띠 = 공용 attachSvgZoomPan(휠 · 가운데 끌기 · 두 번 누름 원배율) · 측점 글 솎음(logic labelStride · planLabelKeys) · 선 굵기 · 글 크기 고정 · 배율은 다시 그려도 유지 - .e03-page 행 셋(auto auto minmax) — 화면 높이 900 에서 띠 · 본문 겹침 고침 - 대표 단면 별 = 누르면 바로 PUT representative · 뒤에서 E05 보고서 갱신 길(GET report · POST refresh) · [대표 단면 저장] 단추 · 캐시 뺌 - 시험 532_3 logic TS 에 솎음 걸음 · 글 열쇠 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
174 lines
6.6 KiB
TypeScript
174 lines
6.6 KiB
TypeScript
/* =============================================================================
|
|
* E03_StabSection_UI_Plan.ts
|
|
* E03 위 평면 위험 띠(PLAN 532-3) — 측점 중심을 이은 노선을 그 조건 판정 색으로(이웃 측점 사이
|
|
* 가운데서 끊음 · 북쪽 위 · 같은 축척) · 고른 측점 표시 · 누르면 가장 가까운 측점.
|
|
* 범례 = 판정마다 아이콘 + 글 + 측점 수. 계산은 logic `bandSegments` · `planFit`.
|
|
* 확대 · 이동 = 공용 `attachSvgZoomPan`(휠 · 가운데 끌기 · 두 번 누름 원배율 · 요구 390) · 측점 글 솎음
|
|
* = logic `labelStride` · `planLabelKeys` · 배율 · 폭은 바깥 `PlanView` 가 쥠(다시 지어도 살아남음).
|
|
* ========================================================================== */
|
|
|
|
import { createBadge } from "@ui/ui_template_badge";
|
|
import { attachSvgZoomPan, type ZoomPanState } from "../A00_Common/b_svg_zoom_pan";
|
|
import { stationLabel } from "../common_util/common_util_station";
|
|
import { el } from "@ui/ui_template_elements";
|
|
import { t, type LocaleKey } from "@ui/ui_template_locale";
|
|
import {
|
|
bandSegments,
|
|
labelStride,
|
|
planFit,
|
|
planLabelKeys,
|
|
verdictOf,
|
|
type Condition,
|
|
type Point,
|
|
type StationRow,
|
|
} from "./logic/E03_StabSection_Logic";
|
|
import { card } from "./E03_StabSection_UI_Parts";
|
|
import { svgEl } from "./E03_StabSection_UI_Section";
|
|
|
|
const H = 96;
|
|
const PAD = 10;
|
|
const LABEL_GAP = 64; // 측점 글 사이 최소 화면 간격(px)
|
|
|
|
/** 띠 배율 · 그때 폭 — 폭이 바뀌면 원배율로 */
|
|
export interface PlanView {
|
|
zoom?: ZoomPanState;
|
|
width?: number;
|
|
}
|
|
|
|
export function buildPlan(
|
|
rows: StationRow[],
|
|
condition: Condition,
|
|
active: string | null,
|
|
interval: number,
|
|
state: PlanView,
|
|
onSelect: (key: string) => void,
|
|
): HTMLElement {
|
|
const box = card("e03-plan");
|
|
const head = el("div", { className: "e03-card__head" });
|
|
head.append(el("h3", { className: "e03-card__title", text: t("E03_Plan") }));
|
|
const legend = el("div", { className: "e03-legend", attrs: { "data-role": "plan-legend" } });
|
|
const counts = { ok: 0, ng: 0, none: 0 };
|
|
rows.forEach((r) => (counts[verdictOf(r, condition)] += 1));
|
|
for (const v of ["ng", "ok", "none"] as const) {
|
|
if (!counts[v]) continue;
|
|
const item = createBadge(
|
|
`${t(`E03_Verdict_${v}` as LocaleKey)} ${counts[v]}`,
|
|
v === "ok" ? "success" : v === "ng" ? "danger" : "neutral",
|
|
{ kind: "lamp" },
|
|
);
|
|
item.classList.add("e03-verdict", `e03-verdict--${v}`);
|
|
legend.append(item);
|
|
}
|
|
head.append(legend);
|
|
box.append(head);
|
|
|
|
const host = el("div");
|
|
box.append(host);
|
|
const ro = new ResizeObserver(() => draw());
|
|
const draw = (): void => {
|
|
if (!host.isConnected) return void ro.disconnect(); // 다시 지은 띠 — 옛 것은 손 뗌
|
|
const W = Math.max(host.clientWidth, 300);
|
|
const segs = bandSegments(rows, condition);
|
|
const all: Point[] = segs.flatMap((s) => s.points);
|
|
const svg = svgEl("svg", { viewBox: `0 0 ${W} ${H}`, "data-role": "plan" });
|
|
host.replaceChildren(svg);
|
|
if (!all.length) return;
|
|
const fit = planFit(all, W, H, PAD);
|
|
const scr = (p: Point): Point => [fit.k * p[0] + fit.tx, -fit.k * p[1] + fit.ty];
|
|
const layer = svgEl("g", { "data-role": "plan-layer" }) as SVGGElement;
|
|
for (const seg of segs) {
|
|
layer.append(
|
|
svgEl("polyline", {
|
|
points: seg.points.map((p) => scr(p).join(",")).join(" "),
|
|
class: `e03-plan__seg e03-plan__seg--${seg.verdict}`,
|
|
"data-verdict": seg.verdict,
|
|
}),
|
|
);
|
|
}
|
|
const centers = rows
|
|
.filter((r) => r.center && r.center[0] !== null && r.center[1] !== null)
|
|
.sort((a, b) => a.chainage_m - b.chainage_m);
|
|
const at = (r: StationRow): Point => scr([r.center![0]!, r.center![1]!]);
|
|
// 측점 간격 하나의 화면 길이(원배율) — 중심 이은 길이 ÷ 누가 길이 × 간격
|
|
const span =
|
|
centers.length > 1 ? centers[centers.length - 1].chainage_m - centers[0].chainage_m : 0;
|
|
let len = 0;
|
|
for (let i = 1; i < centers.length; i += 1) {
|
|
const [a, b] = [at(centers[i - 1]), at(centers[i])];
|
|
len += Math.hypot(b[0] - a[0], b[1] - a[1]);
|
|
}
|
|
const intervalPx = span > 0 ? (len / span) * interval : 0;
|
|
const maxScale = Math.min(500, Math.max(8, Math.ceil(240 / Math.max(intervalPx, 1e-3))));
|
|
const picked = centers.find((r) => r.key === active);
|
|
const pick = picked
|
|
? svgEl("circle", {
|
|
cx: at(picked)[0],
|
|
cy: at(picked)[1],
|
|
r: 6,
|
|
class: "e03-plan__pick",
|
|
"data-key": picked.key,
|
|
})
|
|
: null;
|
|
const labels = svgEl("g", { class: "e03-plan__labels" }) as SVGGElement;
|
|
layer.append(labels, ...(pick ? [pick] : []));
|
|
svg.append(layer);
|
|
let stride = 0;
|
|
const apply = (s: ZoomPanState): void => {
|
|
state.zoom = s;
|
|
labels.style.setProperty("--e03-label-k", String(1 / s.scale)); // 글 · 표시는 확대 안 함
|
|
pick?.setAttribute("r", String(6 / s.scale));
|
|
const next = intervalPx > 0 ? labelStride(intervalPx * s.scale, LABEL_GAP) : 100;
|
|
if (next === stride) return;
|
|
stride = next;
|
|
const keep = new Set(planLabelKeys(centers, interval, stride, active));
|
|
labels.replaceChildren(
|
|
...centers
|
|
.filter((r) => keep.has(r.key))
|
|
.map((r) =>
|
|
svgEl(
|
|
"text",
|
|
{
|
|
x: at(r)[0],
|
|
y: at(r)[1],
|
|
dy: "-0.9em",
|
|
class: "e03-label e03-plan__label",
|
|
"data-key": r.key,
|
|
},
|
|
stationLabel(r.chainage_m, interval, 1),
|
|
),
|
|
),
|
|
);
|
|
};
|
|
const initial = state.width === W ? state.zoom : undefined;
|
|
state.width = W;
|
|
attachSvgZoomPan({
|
|
svg,
|
|
layer,
|
|
widthPx: W,
|
|
heightPx: H,
|
|
pad: { left: 0, right: 0, top: 0, bottom: 0 },
|
|
wheelZoom: true,
|
|
maxScale,
|
|
initial,
|
|
onChange: apply,
|
|
});
|
|
apply(initial ?? { scale: 1, tx: 0, ty: 0 });
|
|
svg.addEventListener("click", (ev) => {
|
|
const box2 = svg.getBoundingClientRect();
|
|
const px = ((ev.clientX - box2.left) / box2.width) * W;
|
|
const py = ((ev.clientY - box2.top) / box2.height) * H;
|
|
const s = state.zoom ?? { scale: 1, tx: 0, ty: 0 };
|
|
let best: StationRow | null = null;
|
|
let dist = Infinity;
|
|
for (const r of centers) {
|
|
const [x, y] = at(r);
|
|
const d = Math.hypot(s.scale * x + s.tx - px, s.scale * y + s.ty - py);
|
|
if (d < dist) [best, dist] = [r, d];
|
|
}
|
|
if (best && dist < 30) onSelect(best.key);
|
|
});
|
|
};
|
|
ro.observe(host);
|
|
return box;
|
|
}
|