Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
/* =============================================================================
|
||||
* B08_Quantity_Milestone_Layout.ts
|
||||
* 거리이정표 다이어그램 자리 계산(PLAN 244-2) — 화면 없음 · 형 import 만(시험이 node 로 돌림).
|
||||
*
|
||||
* 상자 = 공사지(시점 · 중점 한 컨테이너 · 가운데) · 기준점 · 업체들
|
||||
* 업체 자리 = 기준점(거침 · 없으면 공사지) 기준 지도 방위 — 둘 다 경위도가 있으면 방위각 ·
|
||||
* 없으면 사용자가 고른 8방 · 고르지 않았으면 빈 방위를 차례로 · 같은 방위 여럿 = 옆으로 나란히
|
||||
* 기준점 자리 = 공사지 기준 같은 규칙(경위도 · 8방 · 기본 서)
|
||||
* 선 = 업체 → 기준점(또는 시점) · 기준점 → 시점 · 시점 → 중점 — 글 = 「도로 구분 km」 줄마다
|
||||
* 화면 = 북 위(SVG y 아래로 = 남)
|
||||
* ========================================================================== */
|
||||
|
||||
import type { Dir, Leg, MilestoneData } from "./B08_Quantity_Milestone_Calc";
|
||||
|
||||
export interface DiagramBox {
|
||||
id: string;
|
||||
kind: "site" | "start" | "mid" | "base" | "route";
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface DiagramLine {
|
||||
from: string;
|
||||
to: string;
|
||||
x1: number;
|
||||
y1: number;
|
||||
x2: number;
|
||||
y2: number;
|
||||
labels: string[];
|
||||
}
|
||||
|
||||
export interface DiagramLayout {
|
||||
boxes: DiagramBox[];
|
||||
lines: DiagramLine[];
|
||||
view: [number, number, number, number];
|
||||
}
|
||||
|
||||
type LonLat = [number, number] | null | undefined;
|
||||
|
||||
/** 8방 → 방위각(북 0 · 시계 방향) */
|
||||
export const DIR_DEG: Record<Dir, number> = {
|
||||
N: 0,
|
||||
NE: 45,
|
||||
E: 90,
|
||||
SE: 135,
|
||||
S: 180,
|
||||
SW: 225,
|
||||
W: 270,
|
||||
NW: 315,
|
||||
};
|
||||
const AUTO: Dir[] = ["N", "NW", "W", "SW", "S", "SE", "E", "NE"];
|
||||
|
||||
const SITE = { w: 240, h: 96 };
|
||||
const NODE = { w: 72, h: 32 };
|
||||
const BOX = { w: 148, h: 44 };
|
||||
const R_BASE = 300;
|
||||
const R_ROUTE = 260;
|
||||
const SPREAD = 64;
|
||||
|
||||
/** 경위도 두 점 → 방위각(도 · 북 0 · 시계) — 짧은 거리 평면 근사 */
|
||||
export function bearingDeg(from: [number, number], to: [number, number]): number {
|
||||
const dx = (to[0] - from[0]) * Math.cos((((from[1] + to[1]) / 2) * Math.PI) / 180);
|
||||
const dy = to[1] - from[1];
|
||||
return ((Math.atan2(dx, dy) * 180) / Math.PI + 360) % 360;
|
||||
}
|
||||
|
||||
/** 자리 방위 — 경위도 둘 다 → 방위각 · 아니면 8방 · 없으면 null */
|
||||
export function placeDeg(
|
||||
own: { dir: Dir | null; lon?: number | null; lat?: number | null },
|
||||
anchor: LonLat,
|
||||
): number | null {
|
||||
if (own.lon != null && own.lat != null && anchor) return bearingDeg(anchor, [own.lon, own.lat]);
|
||||
return own.dir ? DIR_DEG[own.dir] : null;
|
||||
}
|
||||
|
||||
export const legText = (legs: Leg[]): string[] =>
|
||||
legs.map((leg) => `${leg.road} ${leg.km == null ? "-" : String(leg.km)}km`);
|
||||
|
||||
const unit = (deg: number): [number, number] => {
|
||||
const rad = (deg * Math.PI) / 180;
|
||||
return [Math.sin(rad), -Math.cos(rad)];
|
||||
};
|
||||
|
||||
export function layoutMilestone(
|
||||
data: MilestoneData,
|
||||
site: { start_lonlat?: LonLat; mid_lonlat?: LonLat } | null,
|
||||
midKm: number,
|
||||
): DiagramLayout {
|
||||
const boxes: DiagramBox[] = [];
|
||||
const lines: DiagramLine[] = [];
|
||||
const center = new Map<string, [number, number]>();
|
||||
const put = (box: DiagramBox): void => {
|
||||
boxes.push(box);
|
||||
center.set(box.id, [box.x + box.w / 2, box.y + box.h / 2]);
|
||||
};
|
||||
const line = (from: string, to: string, labels: string[]): void => {
|
||||
const [x1, y1] = center.get(from)!;
|
||||
const [x2, y2] = center.get(to)!;
|
||||
lines.push({ from, to, x1, y1, x2, y2, labels });
|
||||
};
|
||||
|
||||
// 공사지 = 가운데 컨테이너 · 안에 시점(왼) · 중점(오른)
|
||||
put({
|
||||
id: "site",
|
||||
kind: "site",
|
||||
x: -SITE.w / 2,
|
||||
y: -SITE.h / 2,
|
||||
w: SITE.w,
|
||||
h: SITE.h,
|
||||
label: "공사지",
|
||||
});
|
||||
put({
|
||||
id: "start",
|
||||
kind: "start",
|
||||
x: -SITE.w / 2 + 16,
|
||||
y: -6,
|
||||
w: NODE.w,
|
||||
h: NODE.h,
|
||||
label: "시점",
|
||||
});
|
||||
put({
|
||||
id: "mid",
|
||||
kind: "mid",
|
||||
x: SITE.w / 2 - 16 - NODE.w,
|
||||
y: -6,
|
||||
w: NODE.w,
|
||||
h: NODE.h,
|
||||
label: "중점",
|
||||
});
|
||||
line(
|
||||
"start",
|
||||
"mid",
|
||||
legText([{ road: data.site.mid_road, km: Math.round(midKm * 1000) / 1000 }]),
|
||||
);
|
||||
|
||||
const startLL = site?.start_lonlat ?? null;
|
||||
const useBase = data.base.enabled;
|
||||
const baseLL: LonLat =
|
||||
data.base.lon != null && data.base.lat != null ? [data.base.lon, data.base.lat] : null;
|
||||
let baseDeg = 270;
|
||||
if (useBase) {
|
||||
baseDeg = placeDeg(data.base, startLL) ?? 270;
|
||||
const [ux, uy] = unit(baseDeg);
|
||||
const cx = ux * R_BASE;
|
||||
const cy = uy * R_BASE;
|
||||
put({
|
||||
id: "base",
|
||||
kind: "base",
|
||||
x: cx - BOX.w / 2,
|
||||
y: cy - BOX.h / 2,
|
||||
w: BOX.w,
|
||||
h: BOX.h,
|
||||
label: data.base.name || "기준점",
|
||||
});
|
||||
line("base", "start", legText(data.base.legs));
|
||||
}
|
||||
|
||||
// 업체 — 기준점(거침) · 공사지 기준 방위 · 같은 방위끼리 옆으로 나란히
|
||||
const groups = new Map<
|
||||
string,
|
||||
Array<{ id: string; label: string; anchor: string; deg: number; legs: Leg[] }>
|
||||
>();
|
||||
const used = new Set<string>();
|
||||
const keyOf = (anchor: string, deg: number): string => `${anchor}:${Math.round(deg) % 360}`;
|
||||
const items = data.routes.map((r) => {
|
||||
const anchor = useBase && r.via_base ? "base" : "site";
|
||||
return { r, anchor, deg: placeDeg(r, anchor === "base" ? baseLL : startLL) };
|
||||
});
|
||||
for (const it of items) if (it.deg !== null) used.add(keyOf(it.anchor, it.deg));
|
||||
let auto = 0;
|
||||
/** 방위 안 고른 업체 — 빈 8방 차례(기준점 쪽 = 공사지 반대편부터 · 기준점 · 공사지로 향하는 쪽 뺌) */
|
||||
const free = (anchor: string): number => {
|
||||
const turn = anchor === "base" ? baseDeg : 0;
|
||||
const blocked = anchor === "base" ? baseDeg + 180 : useBase ? baseDeg : -1;
|
||||
const cands = AUTO.map((d) => (DIR_DEG[d] + turn) % 360).filter(
|
||||
(c) => keyOf(anchor, c) !== keyOf(anchor, blocked),
|
||||
);
|
||||
return cands.find((c) => !used.has(keyOf(anchor, c))) ?? cands[auto++ % cands.length];
|
||||
};
|
||||
for (const { r, anchor, deg } of items) {
|
||||
const d = deg ?? free(anchor);
|
||||
used.add(keyOf(anchor, d));
|
||||
const key = keyOf(anchor, d);
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key)!.push({ id: r.id, label: r.name || "업체", anchor, deg: d, legs: r.legs });
|
||||
}
|
||||
for (const group of groups.values()) {
|
||||
group.forEach((g, k) => {
|
||||
const [ax, ay] = center.get(g.anchor === "base" ? "base" : "site")!;
|
||||
const [ux, uy] = unit(g.deg);
|
||||
const off = (k - (group.length - 1) / 2) * SPREAD;
|
||||
const reach = g.anchor === "site" ? R_ROUTE + SITE.w / 4 : R_ROUTE;
|
||||
const cx = ax + ux * reach - uy * off;
|
||||
const cy = ay + uy * reach + ux * off;
|
||||
put({
|
||||
id: g.id,
|
||||
kind: "route",
|
||||
x: cx - BOX.w / 2,
|
||||
y: cy - BOX.h / 2,
|
||||
w: BOX.w,
|
||||
h: BOX.h,
|
||||
label: g.label,
|
||||
});
|
||||
line(g.id, g.anchor === "base" ? "base" : "start", legText(g.legs));
|
||||
});
|
||||
}
|
||||
|
||||
const pad = 48;
|
||||
const xs = boxes.flatMap((b) => [b.x, b.x + b.w]);
|
||||
const ys = boxes.flatMap((b) => [b.y, b.y + b.h]);
|
||||
const minX = Math.min(...xs) - pad;
|
||||
const minY = Math.min(...ys) - pad;
|
||||
return {
|
||||
boxes,
|
||||
lines,
|
||||
view: [minX, minY, Math.max(...xs) + pad - minX, Math.max(...ys) + pad - minY],
|
||||
};
|
||||
}
|
||||
@@ -43,3 +43,74 @@
|
||||
align-self: flex-start;
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* 244-2 다이어그램 — 상자 · 선 · km 글(테마 변수) */
|
||||
.b08-milestone__diagram {
|
||||
overflow: auto;
|
||||
max-height: 48vh;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.b08-milestone__diagram svg {
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.b08-milestone__line {
|
||||
stroke: var(--color-text-secondary);
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.b08-milestone__box rect {
|
||||
fill: var(--color-surface);
|
||||
stroke: var(--color-text-secondary);
|
||||
stroke-width: 1.2;
|
||||
}
|
||||
|
||||
.b08-milestone__box text,
|
||||
.b08-milestone__plus text {
|
||||
fill: var(--color-text);
|
||||
}
|
||||
|
||||
.b08-milestone__box.is-site rect {
|
||||
fill: var(--color-surface-raised);
|
||||
stroke-dasharray: 4 3;
|
||||
}
|
||||
|
||||
.b08-milestone__box.is-base rect,
|
||||
.b08-milestone__box.is-start rect,
|
||||
.b08-milestone__box.is-mid rect {
|
||||
stroke: var(--color-accent);
|
||||
}
|
||||
|
||||
.b08-milestone__box.is-route {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.b08-milestone__box.is-route:hover rect,
|
||||
.b08-milestone__box.is-route:focus-visible rect {
|
||||
stroke: var(--color-accent);
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.b08-milestone__plus {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.b08-milestone__plus circle {
|
||||
fill: var(--color-surface);
|
||||
stroke: var(--color-accent);
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.b08-milestone__km {
|
||||
fill: var(--color-text);
|
||||
font-size: 12px;
|
||||
paint-order: stroke;
|
||||
stroke: var(--color-surface);
|
||||
stroke-width: 4px;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
* [거리이정표] = 표(항목 · 업체 이름 · 목적지 ▾ · 2차포장 · 1차포장 · 비포장 · 포장 · 비포장 · 합계 · 비고)
|
||||
* 표 합 = `…_Milestone_Calc` TS 짝(조작 즉시) · [저장] 때 서버가 파이썬 짝으로 다시 셈
|
||||
* 입력 = 더블클릭(243) · 업체 이름을 고치면 그 경로 이름(하위 탭)도
|
||||
* 위쪽 다이어그램 = `…_UI_Milestone_Diagram`(244-2 · 상자 누름 = 경로 탭 · [+] = 항목 추가)
|
||||
* 경로 탭 = `…_UI_Milestone_Route` · 데이터 · [저장] = `…_UI_Milestone_State`(좌측 패널 [저장] 한 줄)
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -13,6 +14,7 @@ import "./B08_Quantity_UI_Milestone.css";
|
||||
import { createDataTable, type DataColumn } from "@ui/ui_template_data_table";
|
||||
import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
|
||||
import { computeMilestone, type MilestoneRoute } from "./B08_Quantity_Milestone_Calc";
|
||||
import { createDiagram } from "./B08_Quantity_UI_Milestone_Diagram";
|
||||
import {
|
||||
liveMilestone,
|
||||
loadMilestone,
|
||||
@@ -61,8 +63,9 @@ export function addRoute(projectId: string, init: Partial<MilestoneRoute> = {}):
|
||||
return id;
|
||||
}
|
||||
|
||||
/** [거리이정표] 하위 탭 — 표(+ 244-2 다이어그램 자리) */
|
||||
function tableView(projectId: string): MilestoneView {
|
||||
/** [거리이정표] 하위 탭 — 위 다이어그램 + 표 */
|
||||
function tableView(projectId: string, open: (id: string) => void): MilestoneView {
|
||||
const diagram = createDiagram(projectId, open, (init) => addRoute(projectId, init));
|
||||
let keys: Array<{ route: string; index: number }> = [];
|
||||
const table = createDataTable({
|
||||
columns: COLUMNS,
|
||||
@@ -97,16 +100,20 @@ function tableView(projectId: string): MilestoneView {
|
||||
table.root.dataset.b08Milestone = "table";
|
||||
const none = el("p", {
|
||||
className: "ui-general-block__empty",
|
||||
text: "경로가 없습니다 — 하위 탭 줄 [+ 추가]로 업체를 넣으세요.",
|
||||
text: "경로가 없습니다 — 다이어그램 공사지 · 기준점 [+] 또는 하위 탭 줄 [+ 추가]로 업체를 넣으세요.",
|
||||
});
|
||||
const refresh = (): void => {
|
||||
const rows = computeMilestone(liveMilestone(projectId), siteInfo()?.mid_default_m ?? null);
|
||||
keys = rows.map(({ route, index }) => ({ route, index }));
|
||||
none.hidden = rows.length > 0;
|
||||
table.setRows(rows.map((r) => ({ ...r, dest: `${DEST_TEXT[r.dest]} ▾` })));
|
||||
diagram.refresh();
|
||||
};
|
||||
refresh();
|
||||
return { root: el("div", { className: "b08-milestone", children: [none, table.root] }), refresh };
|
||||
return {
|
||||
root: el("div", { className: "b08-milestone", children: [diagram.root, none, table.root] }),
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
|
||||
export function renderMilestone(host: HTMLElement, projectId: string): void {
|
||||
@@ -173,7 +180,7 @@ export function renderMilestone(host: HTMLElement, projectId: string): void {
|
||||
function open(id: string): void {
|
||||
current = id;
|
||||
drawTabs();
|
||||
body = id === "table" ? tableView(projectId) : routeView(projectId, id);
|
||||
body = id === "table" ? tableView(projectId, open) : routeView(projectId, id);
|
||||
view.replaceChildren(body.root);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
/* =============================================================================
|
||||
* B08_Quantity_UI_Milestone_Diagram.ts
|
||||
* [거리이정표] 하위 탭 위쪽 다이어그램(PLAN 244-2) — SVG · 자리 = `…_Milestone_Layout`.
|
||||
*
|
||||
* 상자 = 공사지(시점 · 중점 한 컨테이너) · 기준점 · 업체들 · 선마다 「도로 구분 km」 글
|
||||
* 업체 상자 누름 = 그 경로 하위 탭으로 · 공사지 · 기준점 상자 [+] = 항목 추가(작은 창 → 경로 하위 탭 생김 ·
|
||||
* 같은 업체 이름이면 그 경로에 표 줄만 더함) · 조작 = 캐시(`…_UI_Milestone_State`)
|
||||
* ========================================================================== */
|
||||
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import { createButton, createInputField, createSelectField, el } from "@ui/ui_template_elements";
|
||||
import { midKm, type Dest, type Dir, type MilestoneRoute } from "./B08_Quantity_Milestone_Calc";
|
||||
import { layoutMilestone, type DiagramBox } from "./B08_Quantity_Milestone_Layout";
|
||||
import {
|
||||
dirOptions,
|
||||
liveMilestone,
|
||||
putMilestone,
|
||||
siteInfo,
|
||||
} from "./B08_Quantity_UI_Milestone_State";
|
||||
|
||||
const NS = "http://www.w3.org/2000/svg";
|
||||
|
||||
function svg<K extends keyof SVGElementTagNameMap>(
|
||||
tag: K,
|
||||
attrs: Record<string, string | number>,
|
||||
text?: string,
|
||||
): SVGElementTagNameMap[K] {
|
||||
const node = document.createElementNS(NS, tag);
|
||||
for (const [k, v] of Object.entries(attrs)) node.setAttribute(k, String(v));
|
||||
if (text !== undefined) node.textContent = text;
|
||||
return node;
|
||||
}
|
||||
|
||||
/** [+] 작은 창 — 항목 · 업체 이름 · 목적지 · 방위 → 새 경로(같은 이름 = 그 경로에 줄) */
|
||||
export function openAddItem(
|
||||
projectId: string,
|
||||
viaBase: boolean,
|
||||
addRoute: (init: Partial<MilestoneRoute>) => string,
|
||||
): void {
|
||||
openModal({
|
||||
title: viaBase ? "항목 추가 — 기준점 거침" : "항목 추가 — 공사지로 바로",
|
||||
closeLabel: "닫기",
|
||||
mount: (body, close, actions) => {
|
||||
const item = createInputField({ label: "항목", compact: true, placeholder: "예 장비(20톤)" });
|
||||
const company = createInputField({ label: "업체 이름", compact: true });
|
||||
const dest = createSelectField({
|
||||
label: "목적지",
|
||||
compact: true,
|
||||
options: [
|
||||
{ value: "start", text: "시점" },
|
||||
{ value: "mid", text: "중점" },
|
||||
],
|
||||
});
|
||||
const dir = createSelectField({
|
||||
label: viaBase ? "방위(기준점에서 본)" : "방위(공사지에서 본)",
|
||||
compact: true,
|
||||
options: dirOptions,
|
||||
});
|
||||
item.input.dataset.b08MilestoneAdd = "item";
|
||||
company.input.dataset.b08MilestoneAdd = "company";
|
||||
const ok = createButton({
|
||||
label: "추가",
|
||||
variant: "filled",
|
||||
onClick: () => {
|
||||
const name = company.input.value.trim();
|
||||
if (!name) return company.setError("업체 이름을 넣으세요.");
|
||||
const row = { item: item.input.value.trim(), dest: dest.select.value as Dest, note: "" };
|
||||
const data = liveMilestone(projectId);
|
||||
const same = data.routes.find((r) => r.name.trim() === name);
|
||||
if (same) {
|
||||
same.rows.push(row);
|
||||
putMilestone(projectId, data);
|
||||
} else
|
||||
addRoute({
|
||||
name,
|
||||
via_base: viaBase,
|
||||
dir: (dir.select.value || null) as Dir | null,
|
||||
rows: [row],
|
||||
});
|
||||
close();
|
||||
},
|
||||
});
|
||||
actions.append(ok);
|
||||
body.append(item.root, company.root, dest.root, dir.root);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 다이어그램 — refresh() 로 캐시 값 다시 그림 */
|
||||
export function createDiagram(
|
||||
projectId: string,
|
||||
openTab: (routeId: string) => void,
|
||||
addRoute: (init: Partial<MilestoneRoute>) => string,
|
||||
): { root: HTMLElement; refresh: () => void } {
|
||||
const host = el("div", { className: "b08-milestone__diagram" });
|
||||
host.dataset.b08Milestone = "diagram";
|
||||
|
||||
const plus = (box: DiagramBox, viaBase: boolean): SVGGElement => {
|
||||
const g = svg("g", { class: "b08-milestone__plus", role: "button", tabindex: 0 });
|
||||
const cx = box.x + box.w - 2;
|
||||
const cy = box.y + 2;
|
||||
g.append(
|
||||
svg("circle", { cx, cy, r: 11 }),
|
||||
svg("text", { x: cx, y: cy, "text-anchor": "middle", "dominant-baseline": "central" }, "+"),
|
||||
svg("title", {}, "항목 추가"),
|
||||
);
|
||||
g.dataset.b08MilestonePlus = viaBase ? "base" : "site";
|
||||
const go = (e: Event): void => {
|
||||
e.stopPropagation();
|
||||
openAddItem(projectId, viaBase, addRoute);
|
||||
};
|
||||
g.addEventListener("click", go);
|
||||
g.addEventListener("keydown", (e) => e.key === "Enter" && go(e));
|
||||
return g;
|
||||
};
|
||||
|
||||
const refresh = (): void => {
|
||||
const data = liveMilestone(projectId);
|
||||
const site = siteInfo();
|
||||
const layout = layoutMilestone(data, site, midKm(data, site?.mid_default_m ?? null));
|
||||
const [vx, vy, vw, vh] = layout.view;
|
||||
const root = svg("svg", {
|
||||
viewBox: `${vx} ${vy} ${vw} ${vh}`,
|
||||
width: vw,
|
||||
height: vh,
|
||||
role: "img",
|
||||
"aria-label": "거리이정표 다이어그램",
|
||||
});
|
||||
root.style.maxWidth = "100%";
|
||||
root.style.height = "auto";
|
||||
for (const ln of layout.lines) {
|
||||
root.append(
|
||||
svg("line", { class: "b08-milestone__line", x1: ln.x1, y1: ln.y1, x2: ln.x2, y2: ln.y2 }),
|
||||
);
|
||||
}
|
||||
for (const box of layout.boxes) {
|
||||
const g = svg("g", { class: `b08-milestone__box is-${box.kind}` });
|
||||
g.dataset.b08MilestoneBox = box.id;
|
||||
g.append(
|
||||
svg("rect", { x: box.x, y: box.y, width: box.w, height: box.h, rx: 6 }),
|
||||
svg(
|
||||
"text",
|
||||
{
|
||||
x: box.x + box.w / 2,
|
||||
y: box.kind === "site" ? box.y + 18 : box.y + box.h / 2,
|
||||
"text-anchor": "middle",
|
||||
"dominant-baseline": "central",
|
||||
},
|
||||
box.label,
|
||||
),
|
||||
);
|
||||
if (box.kind === "route") {
|
||||
g.setAttribute("role", "button");
|
||||
g.setAttribute("tabindex", "0");
|
||||
g.addEventListener("click", () => openTab(box.id));
|
||||
g.addEventListener("keydown", (e) => e.key === "Enter" && openTab(box.id));
|
||||
}
|
||||
if (box.kind === "site") g.append(plus(box, false));
|
||||
if (box.kind === "base") g.append(plus(box, true));
|
||||
root.append(g);
|
||||
}
|
||||
// 선 글은 상자 위에(가리지 않게 맨 끝) — 줄마다 한 줄
|
||||
for (const ln of layout.lines) {
|
||||
const x = (ln.x1 + ln.x2) / 2;
|
||||
const y = (ln.y1 + ln.y2) / 2 - ((ln.labels.length - 1) * 16) / 2;
|
||||
const text = svg("text", { class: "b08-milestone__km", x, y, "text-anchor": "middle" });
|
||||
ln.labels.forEach((label, i) => text.append(svg("tspan", { x, dy: i ? 16 : 0 }, label)));
|
||||
root.append(text);
|
||||
}
|
||||
host.replaceChildren(root);
|
||||
};
|
||||
refresh();
|
||||
return { root: host, refresh };
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
"""244-2 B08 거리이정표 다이어그램 — 방위 자리(8방 · 경위도 방위각 · 빈 방위 차례 ·
|
||||
같은 방위 나란히) · 선 글(도로 구분 km) · 상자 누름 · [+] 항목 추가 길.
|
||||
|
||||
자리 계산 = `B08_Quantity_Milestone_Layout.ts` 를 node 로 돌림(화면 = 북 위 · SVG y 아래 = 남).
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE)
|
||||
|
||||
|
||||
def _layout(data, site=None, mid_km=0.75):
|
||||
src = (ROOT / "B08_Quantity" / "B08_Quantity_Milestone_Layout.ts").read_text(encoding="utf-8")
|
||||
body = (
|
||||
f"\nconst d = {json.dumps(data, ensure_ascii=False)};"
|
||||
f"\nconsole.log(JSON.stringify(layoutMilestone(d, {json.dumps(site)}, {mid_km})));"
|
||||
)
|
||||
result = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings",
|
||||
"--input-type=module-typescript", "-e", IMPORTS.sub("", src) + body],
|
||||
capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||||
) # fmt: skip
|
||||
assert result.returncode == 0, result.stderr
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def _route(rid, name, dir=None, via=True, legs=None, lon=None, lat=None):
|
||||
return {"id": rid, "name": name, "address": "", "via_base": via, "dir": dir, "lon": lon,
|
||||
"lat": lat, "legs": legs or [],
|
||||
"rows": [{"item": "", "dest": "start", "note": ""}]} # fmt: skip
|
||||
|
||||
|
||||
def _data(routes, base=None):
|
||||
return {
|
||||
"site": {"mid_station_m": None, "mid_road": "비포장"},
|
||||
"base": base or {"enabled": False, "name": "", "address": "", "dir": None, "legs": []},
|
||||
"routes": routes,
|
||||
}
|
||||
|
||||
|
||||
def _center(layout, bid):
|
||||
box = next(b for b in layout["boxes"] if b["id"] == bid)
|
||||
return box["x"] + box["w"] / 2, box["y"] + box["h"] / 2
|
||||
|
||||
|
||||
def test_공사지_컨테이너_시점_중점_선():
|
||||
lay = _layout(_data([]))
|
||||
kinds = {b["id"]: b["kind"] for b in lay["boxes"]}
|
||||
assert kinds == {"site": "site", "start": "start", "mid": "mid"}
|
||||
sx, _ = _center(lay, "start")
|
||||
mx, _ = _center(lay, "mid")
|
||||
assert sx < mx # 시점 왼 · 중점 오른 · 한 컨테이너 안
|
||||
site = next(b for b in lay["boxes"] if b["id"] == "site")
|
||||
for bid in ("start", "mid"):
|
||||
b = next(x for x in lay["boxes"] if x["id"] == bid)
|
||||
assert site["x"] <= b["x"] and b["x"] + b["w"] <= site["x"] + site["w"]
|
||||
assert lay["lines"] == [
|
||||
{**lay["lines"][0], "from": "start", "to": "mid", "labels": ["비포장 0.75km"]}
|
||||
]
|
||||
|
||||
|
||||
def test_8방_자리_기준점_없음_공사지_기준():
|
||||
lay = _layout(
|
||||
_data([_route("e", "동쪽", "E"), _route("n", "북쪽", "N"), _route("sw", "남서", "SW")])
|
||||
)
|
||||
cx, cy = _center(lay, "site")
|
||||
ex, ey = _center(lay, "e")
|
||||
nx, ny = _center(lay, "n")
|
||||
wx, wy = _center(lay, "sw")
|
||||
assert ex > cx + 200 and abs(ey - cy) < 1
|
||||
assert ny < cy - 200 and abs(nx - cx) < 1
|
||||
assert wx < cx and wy > cy
|
||||
line = next(ln for ln in lay["lines"] if ln["from"] == "e")
|
||||
assert line["to"] == "start" # 기준점 없으면 시점으로
|
||||
|
||||
|
||||
def test_기준점_기준_방위_선_글():
|
||||
base = {"enabled": True, "name": "병곡리 968-31", "address": "", "dir": "W",
|
||||
"legs": [{"road": "1차포장", "km": 0.6}, {"road": "비포장", "km": 0.8}]} # fmt: skip
|
||||
lay = _layout(
|
||||
_data(
|
||||
[
|
||||
_route("g", "거창군청", "N", legs=[{"road": "2차포장", "km": 23.8}]),
|
||||
_route("k", "김천역", "E", via=False),
|
||||
],
|
||||
base,
|
||||
), # fmt: skip
|
||||
)
|
||||
cx, cy = _center(lay, "site")
|
||||
bx, by = _center(lay, "base")
|
||||
assert bx < cx - 200 and abs(by - cy) < 1 # 기준점 = 공사지 서쪽
|
||||
gx, gy = _center(lay, "g")
|
||||
assert abs(gx - bx) < 1 and gy < by - 200 # 거창군청 = 기준점 북쪽
|
||||
kx, _ = _center(lay, "k")
|
||||
assert kx > cx + 200 # 기준점 안 거침 = 공사지 동쪽
|
||||
labels = {(ln["from"], ln["to"]): ln["labels"] for ln in lay["lines"]}
|
||||
assert labels[("g", "base")] == ["2차포장 23.8km"]
|
||||
assert labels[("base", "start")] == ["1차포장 0.6km", "비포장 0.8km"]
|
||||
assert labels[("k", "start")] == []
|
||||
assert next(b for b in lay["boxes"] if b["id"] == "base")["label"] == "병곡리 968-31"
|
||||
|
||||
|
||||
def test_경위도_있으면_방위각이_8방보다_앞섬():
|
||||
site = {"start_lonlat": [127.8, 35.7], "mid_lonlat": None}
|
||||
lay = _layout(_data([_route("e", "동쪽 좌표", "N", lon=127.9, lat=35.7)]), site)
|
||||
cx, cy = _center(lay, "site")
|
||||
ex, ey = _center(lay, "e")
|
||||
assert ex > cx + 200 and abs(ey - cy) < 1 # 고른 방위 N 보다 좌표(동) 먼저
|
||||
|
||||
|
||||
def test_빈_방위_차례_같은_방위_나란히():
|
||||
lay = _layout(
|
||||
_data(
|
||||
[_route("a", "가", "E"), _route("b", "나", "E"), _route("c", "다"), _route("d", "라")]
|
||||
)
|
||||
)
|
||||
(ax, ay), (bx, by) = _center(lay, "a"), _center(lay, "b")
|
||||
assert abs(ax - bx) < 1 and abs(ay - by) >= 60 # 같은 방위 = 옆으로 나란히
|
||||
cx, cy = _center(lay, "c")
|
||||
dx, dy = _center(lay, "d")
|
||||
sx, sy = _center(lay, "site")
|
||||
assert cy < sy - 200 and abs(cx - sx) < 1 # 고르지 않음 = 빈 방위 첫째(북)
|
||||
assert (dx, dy) != (cx, cy) and dx < sx # 다음 빈 방위(북서)
|
||||
boxes = [b for b in lay["boxes"] if b["kind"] == "route"]
|
||||
for i, p in enumerate(boxes): # 업체 상자끼리 안 겹침
|
||||
for q in boxes[i + 1 :]:
|
||||
assert (
|
||||
p["x"] + p["w"] <= q["x"] or q["x"] + q["w"] <= p["x"]
|
||||
or p["y"] + p["h"] <= q["y"] or q["y"] + q["h"] <= p["y"]
|
||||
) # fmt: skip
|
||||
|
||||
|
||||
def test_화면_상자_누름_더하기_창():
|
||||
ui = (ROOT / "B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts").read_text(encoding="utf-8")
|
||||
assert 'g.addEventListener("click", () => openTab(box.id));' in ui
|
||||
assert 'if (box.kind === "site") g.append(plus(box, false));' in ui
|
||||
assert 'if (box.kind === "base") g.append(plus(box, true));' in ui
|
||||
assert "openAddItem(projectId, viaBase, addRoute)" in ui and "openModal({" in ui
|
||||
assert "same.rows.push(row)" in ui # 같은 업체 이름 = 그 경로에 표 줄
|
||||
page = (ROOT / "B08_Quantity/B08_Quantity_UI_Milestone.ts").read_text(encoding="utf-8")
|
||||
assert "createDiagram(projectId, open, (init) => addRoute(projectId, init))" in page
|
||||
assert "children: [diagram.root, none, table.root]" in page and "diagram.refresh();" in page
|
||||
for name in ("Milestone_Layout.ts", "UI_Milestone_Diagram.ts"):
|
||||
assert (
|
||||
len((ROOT / f"B08_Quantity/B08_Quantity_{name}").read_text("utf-8").splitlines()) <= 700
|
||||
)
|
||||
Reference in New Issue
Block a user