Files
Aislo/ui_template/ui_template_provenance.ts
T
eomsangdonandClaude Opus 5 6e8de27966 feat(B08): 토적표 근거 호버와 출처 등급 분류 — 개발환경 전용
화면에 뜬 숫자가 어디서 와서 어떻게 나왔는지 가릴 길이 없어 개발·검산이 막히던 것을
엶(PLAN 8-36 ①②③④). 칸에 마우스를 올리면 등급·값·식·원천·자리가 카드로 뜨고,
토글을 켜면 등급색이 칸 왼쪽 띠로 붙음.

- `common_util/common_util_provenance.py` — 등급 상수 여덟과 `ColumnProvenance`.
  사람이 고르는 여섯(입력·측량·기준·계산·최종·막힘)에 `excluded`(일부러 안 셈,
  채우면 이중계상)와 `unclassified`(판단 미룸)를 더함. `excluded` 는 데스크탑 보조의
  B09 조사에서 나온 것으로 `blocked` 와 뜻이 정반대라 갈라 둠. 값 안에 선택이 숨은
  열을 위해 `rule`(채택 규칙) 칸도 둠.
- ⚠ 로직 보안 — `provenance_payload()` 가 개발환경이 아니면 `None` 을 돌려주고
  라우터가 응답에서 칸을 통째로 뺌. 화면에서 숨기는 것이 아니라 안 보내는 것임.
  문의 정본은 `is_dev_environment()` 하나로 통일.
- `ui_template/ui_template_provenance.ts` — 호버 카드·등급색·토글 한 벌. B09 도 같이 씀.
- `B08_Quantity/B08_Quantity_Provenance.py` — 토적표 20열 사전. 사전은 **열 단위**이고
  줄마다 갈리는 사유만 줄 쪽에서 얹음(칸마다 지으면 한 장이 6천 칸이라 응답이 붐).
- 토적표에는 `final` 열이 하나도 없어 억지로 붙이지 않음 — 중간 장부이고 내역서로
  나가는 값은 토공집계표에서 섬.

자체검증 — 시험 8개 추가(`test_b08_provenance.py`). 값어치는 첫 번째에 있음:
사전 열 이름이 전부 실제 `EarthworkRow` 에 있는가(엔진이 이름을 갈면 사전만 옛것으로
남는데 화면에서는 카드가 그냥 안 떠 눈에 안 띔). 배포환경 `None` 도 시험으로 박음.
브라우저(ORCA 5173, 실제 사전을 물려 토적표를 그림) — 표시된 칸 40개(2줄×20열) ·
등급 갈래 survey 12 / calc 28 · 카드에 「절토 토사 보정량 · 계산 · 값 45.00 ·
식 절토 토사 입적 × 토량환산계수(다짐) · 자리 EarthworkTable.py:210」 · 토글에 배경이
투명 → 초록 7 % 로 바뀌고 왼쪽 띠 inset 3px. 줄 사유가 그 줄 모든 칸에 뜨던 것을
사유가 닿는 열(`ditch_*`)에만 뜨게 고침 — 「절토 보정량」 카드에 「측구 가름값이…」 가
떠서 읽는 사람을 속이던 자리임.

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

263 lines
11 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";
/** 칸에 심는 표시 — 열 키와 등급. 표를 그리는 쪽이 칸마다 한 번 부른다. */
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, 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";
badge.dataset.provTier = column.tier;
badge.textContent = TIER_LABELS[column.tier] ?? column.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();
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,
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);
}