Files
Aislo/ui_template/ui_template_provenance.ts
T
eomsangdonandClaude Opus 5 f952ac7ffd fix(git): 병합이 떨군 파일 22개와 되돌아간 파일 35개를 되살림
무슨 일이 있었나
랩탑 줄의 병합 `20ba886c`(Merge origin/main_desktop_1·main_laptop_1·sub_desktop_1 into
sub_laptop_1)가 우리 파일 22개를 떨구고 35개 파일의 내용을 옛것으로 되돌림. 손으로 지운
커밋은 없고 **병합 자체가 떨군 것**임. 그것이 `origin/dev`·`main_laptop_1`·`sub_laptop_1`·
`CODEX` 까지 퍼졌고(데스크탑 둘만 무사), 이 창의 병합 `d92c1f2b` 로 들어옴.

잃었던 것
- 공용 — `common_util_provenance.py` · `ui_template_provenance.ts`
- B08 — 근거 사전 · 좌측 패널 상자 모듈 · 토량환산계수 칸
- B09 — 근거 사전 셋
- B05 — 계획노선 편집 모듈 아홉 · 지형 라우터 · B04 지도 모듈
- 시험 셋과, 35개 파일 안의 최근 작업(환산계수 고르기 · 근거 호버 배선 등)

어떻게 되살렸나
`611a2b40`(병합 직전, 전부 온전)에서 `git show <커밋>:<경로>` 로 내용만 꺼내 되돌림.
이력은 안 건드림. ⚠ HEAD 에만 있던 「추가 816줄」은 랩탑의 새 작업이 아니라 **되살아난
옛 코드**였음(B05 편집은 모듈로 쪼개기 전 덩어리 · B08 라우터는 환산계수 고르기 전 옛
상수판). 되돌릴 시점 이후의 **진짜 새 커밋은 둘뿐**이라 그 둘만 패치로 다시 얹음 —
`9f827bf6`(리로드 빌드 고리 끊기, 데스크탑 보조) · `b9bca6b3`(B06 조정창 1px, 랩탑).
위키 여덟은 코덱스 몫이라 손대지 않음.

자체검증 — 양쪽 작업이 다 살아 있음을 짚어 확인: `main.py` 의 「개발 서버는 살려 둔다」 ·
`B05_Profile_Engine_Grade.py` 의 `plan_curve_length_limit_m` · `B08_..._EarthworkGrid.ts` 의
`attachProvenance`. `tsc --noEmit` 통과 · `pytest -q` **1317 passed, 28 skipped**
(되살리기 전에는 시험 둘이 수집 단계에서 깨져 있었음).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RANEBHns1S4tkmsYwewtk
2026-09-12 18:18:57 +09:00

288 lines
13 KiB
TypeScript

/* =============================================================================
* ui_template_provenance.ts
* 표 칸에 마우스를 올리면 **그 숫자가 어디서 와서 어떻게 나왔는지**를 띄우는 한 벌.
* B08 수량·B09 원가가 같이 쓴다 (PLAN 8-36 ②③).
*
* ⚠⚠ **개발 전용 — 사용자에게 보이지 않는다 (로직 보안).**
* 사전은 서버가 개발환경에서만 실어 보낸다(`common_util_provenance.provenance_payload`).
* 사전이 안 오면 이 모듈은 **아무것도 하지 않는다** — 화면에서 숨기는 것이 아니라
* 애초에 들고 있지 않은 것이 요점이다. 그래서 낱말도 번역하지 않는다(안 나간다).
*
* ⚠ 겉보기는 **칸 단위**지만 사전은 **열 단위**다. 칸마다 사전을 만들면 토적표 한 장이
* 6천 칸이라 응답이 붐는다. 줄마다 갈리는 것(폴백 사유 등)은 부르는 쪽이
* `resolveExtra` 로 얹는다.
* ========================================================================== */
/** 열 하나의 사전. 서버 `ColumnProvenance.as_dict()` 와 1:1. */
export interface ProvenanceColumn {
label: string;
tier: string;
formula?: string;
source?: string;
/** 고르는 자리의 채택 규칙(「안전관리비 A·B 중 작은 쪽」 따위). 없으면 칸이 안 뜨다. */
rule?: string;
code?: string;
}
/** 한 장(시트)의 사전. */
export interface ProvenanceSheet {
columns: Record<string, ProvenanceColumn>;
}
/** 응답에 실려 오는 사전 전체. 개발환경이 아니면 **칸 자체가 없다**(`undefined`). */
export interface ProvenancePayload {
sheets: Record<string, ProvenanceSheet>;
}
/** 등급 여섯(+미분류). 키는 서버와 같은 낱말이라야 한다 — 어긋나면 색도 카드도 빈다. */
const TIER_LABELS: Record<string, string> = {
input: "입력",
survey: "측량",
standard: "기준",
calc: "계산",
final: "최종",
blocked: "막힘",
excluded: "제외",
unclassified: "미분류",
};
/** 색칠을 켤지 — **포트별로 갈리는 sessionStorage** 에 둔다(창마다 취향이 다르다). */
const TINT_KEY = "aislo.provenance.tint";
const STYLE_ID = "ui-provenance-style";
const CARD_ID = "ui-provenance-card";
/**
* 칸에 심는 표시 — 열 키와 등급. 표를 그리는 쪽이 칸마다 한 번 부른다.
*
* `tier` 를 주면 **그 칸만 열 등급을 이긴다.** 같은 열이라도 줄마다 성격이 갈리는
* 자리가 있기 때문이다 — 원가계산서 「금액」은 중간줄(간접노무비 따위)이 `calc` 인데
* 마지막줄(총원가·도급금액·총계)은 `final` 이다(2026-09-12 데스크탑 보조 B09 배선).
* 칸 등급을 심지 않으면 열 등급이 그대로 선다.
*/
export function markProvenanceCell(cell: HTMLElement, columnKey: string, tier?: string): void {
cell.dataset.provCol = columnKey;
if (tier) cell.dataset.provTier = tier;
}
export function isProvenanceTinted(): boolean {
try {
return sessionStorage.getItem(TINT_KEY) === "on";
} catch {
return false;
}
}
function setTinted(root: HTMLElement, on: boolean): void {
root.classList.toggle("is-prov-tinted", on);
try {
sessionStorage.setItem(TINT_KEY, on ? "on" : "off");
} catch {
/* 저장이 막힌 창에서도 화면은 돌아야 한다 — 이번 화면에서만 켜진다. */
}
}
/**
* 색칠 토글 단추. **사전이 없으면 만들지 않는다**(부르는 쪽이 `payload` 를 보고 거른다).
*
* ⚠ 여섯 색이 늘 켜져 있으면 표가 알록달록해 실무 시트와 눈으로 대조를 못 한다.
* 그래서 **평소엔 꺼 두고** 이 단추로만 켠다.
*/
export function createProvenanceToggle(root: HTMLElement): HTMLElement {
const button = document.createElement("button");
button.type = "button";
button.className = "ui-prov-toggle";
const paint = (): void => {
const on = root.classList.contains("is-prov-tinted");
button.textContent = on ? "등급색 끄기" : "등급색 켜기";
button.classList.toggle("is-on", on);
};
setTinted(root, isProvenanceTinted());
paint();
button.addEventListener("click", () => {
setTinted(root, !root.classList.contains("is-prov-tinted"));
paint();
});
return button;
}
function line(card: HTMLElement, name: string, value: string): void {
if (!value) return;
const row = document.createElement("div");
row.className = "ui-prov-card__row";
const key = document.createElement("span");
key.className = "ui-prov-card__key";
key.textContent = name;
const body = document.createElement("span");
body.className = "ui-prov-card__value";
body.textContent = value;
row.append(key, body);
card.append(row);
}
function card(): HTMLElement {
let element = document.getElementById(CARD_ID);
if (!element) {
element = document.createElement("div");
element.id = CARD_ID;
element.className = "ui-prov-card";
document.body.append(element);
}
return element;
}
/** 카드를 마우스 옆에 둔다 — 화면 밖으로 나가면 반대쪽으로 접는다. */
function place(element: HTMLElement, x: number, y: number): void {
element.style.visibility = "hidden";
element.style.display = "block";
const box = element.getBoundingClientRect();
const left = x + 16 + box.width > window.innerWidth ? x - 16 - box.width : x + 16;
const top = y + 16 + box.height > window.innerHeight ? y - 16 - box.height : y + 16;
element.style.left = `${Math.max(4, left)}px`;
element.style.top = `${Math.max(4, top)}px`;
element.style.visibility = "visible";
}
/** 카드 한 장을 채운다. 값은 **화면에 적힌 글자 그대로** 보인다 — 자리수까지 같은 것이 요점. */
/** 카드 한 장을 채운다. 배지는 **칸 등급**을 먼저 본다 — 띄는 띄었는데 배지가 다른 말을
* 하면 읽는 사람이 둘 중 어느 것을 믿을지 모른다. */
function fill(
target: HTMLElement,
cell: HTMLElement,
column: ProvenanceColumn,
value: string,
extra: string[],
): void {
target.replaceChildren();
const head = document.createElement("div");
head.className = "ui-prov-card__head";
const title = document.createElement("span");
title.textContent = column.label;
const badge = document.createElement("span");
badge.className = "ui-prov-card__badge";
const tier = cell.dataset.provTier || column.tier;
badge.dataset.provTier = tier;
badge.textContent = TIER_LABELS[tier] ?? tier;
head.append(title, badge);
target.append(head);
line(target, "값", value);
line(target, "식", column.formula ?? "");
line(target, "원천", column.source ?? "");
line(target, "채택", column.rule ?? "");
line(target, "자리", column.code ?? "");
for (const note of extra) line(target, "줄 사유", note);
}
/**
* 표에 호버를 붙인다. 칸에 심어 둔 `data-prov-col` 로 사전을 찾는다.
*
* `resolveExtra` — 줄마다 갈리는 사유(폴백 안분 등)를 얹고 싶을 때 부르는 쪽이 준다.
* 사전에 없는 열은 **아무 일도 안 한다** — 빈 카드를 띄우면 「설명이 있다」는 거짓이 남는다.
*/
export function attachProvenance(
root: HTMLElement,
sheet: ProvenanceSheet | undefined,
resolveExtra?: (cell: HTMLElement, columnKey: string) => string[],
): void {
if (!sheet) return;
injectProvenanceStyles();
// 등급을 안 심은 칸은 **여기서 열 등급으로 채운다.**
// 색칠은 CSS 가 `data-prov-tier` 를 보고 하므로, 그 칸은 배지만 띄고 띄는 안 붙어
// 「색이 안 붙는 칸」이 생긴다. 부르는 쪽이 등급을 빼먹는 것은 흔한 일이라 여기서 맞춘다.
for (const cell of root.querySelectorAll<HTMLElement>("[data-prov-col]")) {
if (cell.dataset.provTier) continue;
const tier = sheet.columns[cell.dataset.provCol ?? ""]?.tier;
if (tier) cell.dataset.provTier = tier;
}
const hide = (): void => {
const element = document.getElementById(CARD_ID);
if (element) element.style.display = "none";
};
root.addEventListener("mouseover", (event) => {
const cell = (event.target as HTMLElement).closest<HTMLElement>("[data-prov-col]");
if (!cell || !root.contains(cell)) return;
const column = sheet.columns[cell.dataset.provCol ?? ""];
if (!column) return hide();
const target = card();
fill(
target,
cell,
column,
cell.textContent?.trim() ?? "",
resolveExtra?.(cell, cell.dataset.provCol ?? "") ?? [],
);
place(target, (event as MouseEvent).clientX, (event as MouseEvent).clientY);
});
root.addEventListener("mousemove", (event) => {
const element = document.getElementById(CARD_ID);
if (!element || element.style.display === "none") return;
place(element, (event as MouseEvent).clientX, (event as MouseEvent).clientY);
});
root.addEventListener("mouseleave", hide);
root.addEventListener("mouseout", (event) => {
const next = (event as MouseEvent).relatedTarget as HTMLElement | null;
if (!next || !next.closest?.("[data-prov-col]")) hide();
});
}
const CSS = `
/* 등급색 — 평소엔 꺼져 있고 토글로만 켠다. 칸 왼쪽 얇은 띠 + 아주 옅은 배경이라
숫자 읽기를 방해하지 않는다. 색은 테마 변수를 섞어 어두운 테마에서도 맞는다. */
.is-prov-tinted [data-prov-tier="input"] { box-shadow: inset 3px 0 0 var(--color-accent); background: color-mix(in srgb, var(--color-accent) 7%, transparent); }
.is-prov-tinted [data-prov-tier="survey"] { box-shadow: inset 3px 0 0 var(--color-info, #3b82f6); background: color-mix(in srgb, var(--color-info, #3b82f6) 7%, transparent); }
.is-prov-tinted [data-prov-tier="standard"] { box-shadow: inset 3px 0 0 var(--color-text-secondary); background: color-mix(in srgb, var(--color-text-secondary) 7%, transparent); }
.is-prov-tinted [data-prov-tier="calc"] { box-shadow: inset 3px 0 0 var(--color-success, #16a34a); background: color-mix(in srgb, var(--color-success, #16a34a) 7%, transparent); }
.is-prov-tinted [data-prov-tier="final"] { box-shadow: inset 3px 0 0 var(--color-warning, #c08a3e); background: color-mix(in srgb, var(--color-warning, #c08a3e) 10%, transparent); }
.is-prov-tinted [data-prov-tier="blocked"] { box-shadow: inset 3px 0 0 var(--color-danger, #dc2626); background: color-mix(in srgb, var(--color-danger, #dc2626) 8%, transparent); }
.is-prov-tinted [data-prov-tier="excluded"] { box-shadow: inset 3px 0 0 var(--color-muted, #9ca3af); background: repeating-linear-gradient(135deg, transparent, transparent 5px, color-mix(in srgb, var(--color-muted, #9ca3af) 12%, transparent) 5px, color-mix(in srgb, var(--color-muted, #9ca3af) 12%, transparent) 10px); }
.is-prov-tinted [data-prov-tier="unclassified"] { box-shadow: inset 3px 0 0 var(--color-border); }
.ui-prov-toggle {
font-size: 11px;
padding: 2px 8px;
color: var(--color-text-secondary);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-pills, 999px);
cursor: pointer;
}
.ui-prov-toggle.is-on { color: var(--color-accent); border-color: var(--color-accent); }
.ui-prov-card {
display: none;
position: fixed;
z-index: 9999;
max-width: 26rem;
padding: 8px 10px;
font-size: 11.5px;
line-height: 1.5;
color: var(--color-text);
background: var(--color-surface-raised);
border: 1px solid var(--color-border);
border-radius: var(--radius-cards, 8px);
box-shadow: 0 6px 18px rgb(0 0 0 / 18%);
pointer-events: none;
}
.ui-prov-card__head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 4px; font-weight: 600; }
.ui-prov-card__badge { flex: 0 0 auto; padding: 0 6px; font-size: 10.5px; font-weight: 500; border-radius: var(--radius-pills, 999px); border: 1px solid currentColor; }
.ui-prov-card__badge[data-prov-tier="input"] { color: var(--color-accent); }
.ui-prov-card__badge[data-prov-tier="survey"] { color: var(--color-info, #3b82f6); }
.ui-prov-card__badge[data-prov-tier="standard"] { color: var(--color-text-secondary); }
.ui-prov-card__badge[data-prov-tier="calc"] { color: var(--color-success, #16a34a); }
.ui-prov-card__badge[data-prov-tier="final"] { color: var(--color-warning, #c08a3e); }
.ui-prov-card__badge[data-prov-tier="blocked"] { color: var(--color-danger, #dc2626); }
.ui-prov-card__badge[data-prov-tier="excluded"] { color: var(--color-muted, #9ca3af); }
.ui-prov-card__row { display: flex; gap: 8px; }
.ui-prov-card__key { flex: 0 0 2.4rem; color: var(--color-text-secondary); }
.ui-prov-card__value { min-width: 0; white-space: pre-wrap; }
`;
export function injectProvenanceStyles(): void {
if (document.getElementById(STYLE_ID)) return;
const style = document.createElement("style");
style.id = STYLE_ID;
style.textContent = CSS;
document.head.append(style);
}