Merge remote-tracking branch 'origin/main_desktop_1' into sub_desktop_1
This commit is contained in:
@@ -0,0 +1,163 @@
|
|||||||
|
"""B08 수량 화면의 **근거 사전** — 어느 숫자가 어디서 와서 어떻게 나왔나 (PLAN 8-36 ④).
|
||||||
|
|
||||||
|
⚠⚠ **개발 전용.** 사전은 `provenance_payload()` 를 거쳐 나가고, 개발환경이 아니면 `None`
|
||||||
|
이라 응답에 칸 자체가 안 생긴다. 화면에서 숨기는 것이 아니라 **안 보내는 것**이다.
|
||||||
|
|
||||||
|
왜 이 파일인가
|
||||||
|
「식」과 「원천」의 정답은 값을 낳는 엔진이 안다. 화면 TS 에 손으로 적어 두면 엔진을
|
||||||
|
고칠 때 설명만 옛것으로 남는다. 엔진 옆(같은 폴더)에 두어 같이 눈에 들어오게 한다.
|
||||||
|
|
||||||
|
⚠ **열 단위로 적는다.** 토적표 한 장이 30열 × 200줄 = 6천 칸이라 칸마다 지으면 응답이
|
||||||
|
붐는다. 줄마다 갈리는 것(측구 안분 폴백 사유 등)은 줄이 이미 `notes` 로 들고 있고,
|
||||||
|
화면이 그것을 카드에 덧붙인다.
|
||||||
|
|
||||||
|
⚠ **토적표에는 `final`(최종) 열이 없다 — 억지로 붙이지 않았다.**
|
||||||
|
이 표는 중간 장부다. 내역서로 나가는 값은 **토공집계표**에서 선다. 여섯 등급을
|
||||||
|
한 장에 다 채우려고 아무 열에나 `final` 을 붙이면 「분류가 있다」는 거짓만 남는다.
|
||||||
|
이 어긋남은 등급을 고칠 근거이므로 PLAN 8-36 ① 에 그대로 남긴다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from common_util.common_util_provenance import (
|
||||||
|
TIER_CALC,
|
||||||
|
TIER_SURVEY,
|
||||||
|
ColumnProvenance,
|
||||||
|
provenance_payload,
|
||||||
|
sheet_provenance,
|
||||||
|
)
|
||||||
|
|
||||||
|
#: 토량환산계수가 어디서 오는지 — 여러 열이 같은 문장을 쓰므로 한 벌로 둔다.
|
||||||
|
_FACTOR_SOURCE = (
|
||||||
|
"토량환산계수(다짐) — 기본값 `config_system_design.EARTHWORK_CONVERSION_FACTORS`, "
|
||||||
|
"프로젝트가 고른 값이 있으면 산출 조건 패널의 값"
|
||||||
|
)
|
||||||
|
|
||||||
|
#: 단면적 넷의 공통 원천. B06 이 낸 설계 단면을 **그대로** 읽는다(여기서 다시 안 짓는다).
|
||||||
|
_SECTION_SOURCE = "B06 횡단 설계가 낸 측점별 단면적"
|
||||||
|
|
||||||
|
#: 평균단면적법 한 줄. 신규 문서 5장 「다. 공사수량의 산출」.
|
||||||
|
_MEAN_AREA = "(앞 측점 단면적 + 이 측점 단면적) ÷ 2 × 두 측점 사이 거리"
|
||||||
|
|
||||||
|
|
||||||
|
def _area(key: str, label: str, extra: str = "") -> ColumnProvenance:
|
||||||
|
"""단면적 열 — B06 설계값을 그대로 옮긴 자리라 식이 없다."""
|
||||||
|
return ColumnProvenance(
|
||||||
|
key=key,
|
||||||
|
label=label,
|
||||||
|
tier=TIER_SURVEY,
|
||||||
|
formula="설계가 낸 값을 그대로 읽음 (여기서 다시 계산하지 않음)",
|
||||||
|
source=_SECTION_SOURCE + (f" · {extra}" if extra else ""),
|
||||||
|
code="B08_Quantity_Engine_EarthworkTable.py:StationArea.from_design",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _volume(key: str, label: str, area_label: str) -> ColumnProvenance:
|
||||||
|
return ColumnProvenance(
|
||||||
|
key=key,
|
||||||
|
label=label,
|
||||||
|
tier=TIER_CALC,
|
||||||
|
formula=_MEAN_AREA.replace("단면적", area_label),
|
||||||
|
source="첫 측점은 앞이 없어 비어 있음 (실무 토적표도 첫 줄 체적이 빈칸)",
|
||||||
|
code="B08_Quantity_Engine_EarthworkTable.py:200 mean_volume",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _adjusted(key: str, label: str, volume_label: str) -> ColumnProvenance:
|
||||||
|
return ColumnProvenance(
|
||||||
|
key=key,
|
||||||
|
label=label,
|
||||||
|
tier=TIER_CALC,
|
||||||
|
formula=f"{volume_label} × 토량환산계수(다짐)",
|
||||||
|
source=_FACTOR_SOURCE,
|
||||||
|
code="B08_Quantity_Engine_EarthworkTable.py:210",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def earthwork_sheet() -> dict[str, Any]:
|
||||||
|
"""토적표 한 장의 사전. 열 키는 화면 `EarthworkRow` 와 같은 낱말이라야 한다."""
|
||||||
|
return sheet_provenance(
|
||||||
|
[
|
||||||
|
ColumnProvenance(
|
||||||
|
key="chainage_m",
|
||||||
|
label="측점",
|
||||||
|
tier=TIER_SURVEY,
|
||||||
|
formula="노선 시점에서 잰 이정(m). 화면은 NO.n+m 으로 적음",
|
||||||
|
source="B05 종단이 놓은 측점 배치",
|
||||||
|
code="B08_Quantity_Engine_EarthworkTable.py:StationArea.chainage_m",
|
||||||
|
),
|
||||||
|
ColumnProvenance(
|
||||||
|
key="distance_m",
|
||||||
|
label="거리",
|
||||||
|
tier=TIER_CALC,
|
||||||
|
formula="이 측점 이정 − 앞 측점 이정",
|
||||||
|
source="B05 종단 측점 배치. 첫 줄은 앞이 없어 0",
|
||||||
|
code="B08_Quantity_Engine_EarthworkTable.py:195",
|
||||||
|
),
|
||||||
|
_area("cut_soil_area_m2", "절토 토사 단면적"),
|
||||||
|
_volume("cut_soil_volume_m3", "절토 토사 입적", "절토 토사 단면적"),
|
||||||
|
_adjusted("cut_soil_adjusted_m3", "절토 토사 보정량", "절토 토사 입적"),
|
||||||
|
_area("cut_rock_area_m2", "절토 암석 단면적", "암 갈래는 측점의 `cut_rock_kind`"),
|
||||||
|
_volume("cut_rock_volume_m3", "절토 암석 입적", "절토 암석 단면적"),
|
||||||
|
_adjusted("cut_rock_adjusted_m3", "절토 암석 보정량", "절토 암석 입적"),
|
||||||
|
_area(
|
||||||
|
"ditch_soil_area_m2",
|
||||||
|
"측구터파기 토사 단면적",
|
||||||
|
"지반 유형·암반 경계선으로 B06 이 가른 값. 가름이 없는 옛 저장분만 "
|
||||||
|
"절토 토사:암 면적비로 안분하고 그 줄에 사유가 남음",
|
||||||
|
),
|
||||||
|
_volume("ditch_soil_volume_m3", "측구터파기 토사 입적", "측구 토사 단면적"),
|
||||||
|
_adjusted("ditch_soil_adjusted_m3", "측구터파기 토사 보정량", "측구 토사 입적"),
|
||||||
|
_area(
|
||||||
|
"ditch_rock_area_m2",
|
||||||
|
"측구터파기 암석 단면적",
|
||||||
|
"위와 같은 가름값. 0.0 은 설계가 낸 「없음」이고 값 없음과 다름",
|
||||||
|
),
|
||||||
|
_volume("ditch_rock_volume_m3", "측구터파기 암석 입적", "측구 암석 단면적"),
|
||||||
|
_adjusted("ditch_rock_adjusted_m3", "측구터파기 암석 보정량", "측구 암석 입적"),
|
||||||
|
ColumnProvenance(
|
||||||
|
key="adjusted_total_m3",
|
||||||
|
label="보정량계",
|
||||||
|
tier=TIER_CALC,
|
||||||
|
formula="절토 토사 보정량 + 절토 암석 보정량 + 측구 토사 보정량 + 측구 암석 보정량",
|
||||||
|
source="네 보정량의 합. 성토에 쓸 수 있는 양으로 환산한 뒤의 값",
|
||||||
|
code="B08_Quantity_Engine_EarthworkTable.py:215",
|
||||||
|
),
|
||||||
|
_area("fill_area_m2", "성토 단면적"),
|
||||||
|
_volume("fill_volume_m3", "성토 입적", "성토 단면적"),
|
||||||
|
ColumnProvenance(
|
||||||
|
key="diverted_m3",
|
||||||
|
label="유용토",
|
||||||
|
tier=TIER_CALC,
|
||||||
|
formula="min(보정량계, 성토 입적)",
|
||||||
|
source="그 측점에서 절취분과 성토분이 서로 만나는 몫",
|
||||||
|
code="B08_Quantity_Engine_EarthworkTable.py:222",
|
||||||
|
),
|
||||||
|
ColumnProvenance(
|
||||||
|
key="balance_m3",
|
||||||
|
label="차인토량",
|
||||||
|
tier=TIER_CALC,
|
||||||
|
formula="보정량계 − 성토 입적",
|
||||||
|
source="양수면 남는 흙(사토), 음수면 모자란 흙(객토)",
|
||||||
|
code="B08_Quantity_Engine_EarthworkTable.py:223",
|
||||||
|
),
|
||||||
|
ColumnProvenance(
|
||||||
|
key="cumulative_m3",
|
||||||
|
label="누가토량",
|
||||||
|
tier=TIER_CALC,
|
||||||
|
formula="첫 줄부터 이 줄까지 차인토량을 더해 온 값",
|
||||||
|
source="유토곡선(mass haul)의 세로축이 되는 값",
|
||||||
|
code="B08_Quantity_Engine_EarthworkTable.py:225",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def quantity_provenance() -> dict[str, Any] | None:
|
||||||
|
"""B08 응답에 실을 사전 — **개발환경이 아니면 `None`.**
|
||||||
|
|
||||||
|
시트를 늘릴 때는 여기 한 줄만 더한다. 화면은 시트 이름으로 찾아 쓴다.
|
||||||
|
"""
|
||||||
|
return provenance_payload({"earthwork": earthwork_sheet()})
|
||||||
@@ -40,6 +40,7 @@ from B08_Quantity.B08_Quantity_Engine_Preparation import build_table as build_pr
|
|||||||
from B08_Quantity.B08_Quantity_Engine_HaulSummary import summary_input_rows
|
from B08_Quantity.B08_Quantity_Engine_HaulSummary import summary_input_rows
|
||||||
from B08_Quantity.B08_Quantity_Engine_SlopeArea import build_table as build_slope_table
|
from B08_Quantity.B08_Quantity_Engine_SlopeArea import build_table as build_slope_table
|
||||||
from B08_Quantity.B08_Quantity_Engine_SlopeLength import station_slopes
|
from B08_Quantity.B08_Quantity_Engine_SlopeLength import station_slopes
|
||||||
|
from B08_Quantity.B08_Quantity_Provenance import quantity_provenance
|
||||||
from common_util.common_util_project_settings import (
|
from common_util.common_util_project_settings import (
|
||||||
CONCRETE_PLACING_METHODS,
|
CONCRETE_PLACING_METHODS,
|
||||||
ROCK_METHODS,
|
ROCK_METHODS,
|
||||||
@@ -176,6 +177,11 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse:
|
|||||||
table["settings"] = settings
|
table["settings"] = settings
|
||||||
table["project_root_known"] = project_root is not None
|
table["project_root_known"] = project_root is not None
|
||||||
table["route_id"] = route_id
|
table["route_id"] = route_id
|
||||||
|
# 근거 사전(PLAN 8-36 ④) — ⚠ **개발환경에서만** 실린다. 운영에서는 `None` 이라
|
||||||
|
# 칸 자체가 안 생긴다 — 화면에서 숨기는 것이 아니라 안 보내는 것이 요점이다.
|
||||||
|
provenance = quantity_provenance()
|
||||||
|
if provenance is not None:
|
||||||
|
table["provenance"] = provenance
|
||||||
return JSONResponse(content=table)
|
return JSONResponse(content=table)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,13 @@
|
|||||||
* 원가 쪽(줄마다 원 단위 절사)과 규칙이 반대이므로 그 코드를 여기로 옮기지 말 것.
|
* 원가 쪽(줄마다 원 단위 절사)과 규칙이 반대이므로 그 코드를 여기로 옮기지 말 것.
|
||||||
* ========================================================================== */
|
* ========================================================================== */
|
||||||
|
|
||||||
|
import {
|
||||||
|
attachProvenance,
|
||||||
|
markProvenanceCell,
|
||||||
|
type ProvenancePayload,
|
||||||
|
type ProvenanceSheet,
|
||||||
|
} from "@ui/ui_template_provenance";
|
||||||
|
|
||||||
/** 서버가 주는 토적표 한 줄. 이름은 엔진(`B08_Quantity_Engine_EarthworkTable.py`)과 같다. */
|
/** 서버가 주는 토적표 한 줄. 이름은 엔진(`B08_Quantity_Engine_EarthworkTable.py`)과 같다. */
|
||||||
export interface EarthworkRow {
|
export interface EarthworkRow {
|
||||||
chainage_m: number;
|
chainage_m: number;
|
||||||
@@ -134,6 +141,8 @@ export interface EarthworkTable {
|
|||||||
conversion_factor_choices?: Record<string, ConversionFactorChoice>;
|
conversion_factor_choices?: Record<string, ConversionFactorChoice>;
|
||||||
/** 품셈 암종별 범위(안내용). 정의처가 서버라 내려받아 보인다. */
|
/** 품셈 암종별 범위(안내용). 정의처가 서버라 내려받아 보인다. */
|
||||||
conversion_factor_pumsem_ranges?: PumsemRange[];
|
conversion_factor_pumsem_ranges?: PumsemRange[];
|
||||||
|
/** 근거 사전 — ⚠ **개발환경에서만** 실려 온다. 운영에서는 칸 자체가 없다. */
|
||||||
|
provenance?: ProvenancePayload;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 표 칸에 들어갈 수 있는 열 — 숫자 칸만 고른다(사유·주기는 표 밖이다). */
|
/** 표 칸에 들어갈 수 있는 열 — 숫자 칸만 고른다(사유·주기는 표 밖이다). */
|
||||||
@@ -334,7 +343,11 @@ function buildHead(): HTMLTableSectionElement {
|
|||||||
return head;
|
return head;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildBody(rows: EarthworkRow[], slope?: SlopeTable): HTMLTableSectionElement {
|
function buildBody(
|
||||||
|
rows: EarthworkRow[],
|
||||||
|
slope?: SlopeTable,
|
||||||
|
sheet?: ProvenanceSheet,
|
||||||
|
): HTMLTableSectionElement {
|
||||||
const body = document.createElement("tbody");
|
const body = document.createElement("tbody");
|
||||||
const columns = flatColumns();
|
const columns = flatColumns();
|
||||||
const slopeByChainage = new Map((slope?.rows ?? []).map((row) => [row.chainage_m, row]));
|
const slopeByChainage = new Map((slope?.rows ?? []).map((row) => [row.chainage_m, row]));
|
||||||
@@ -346,9 +359,15 @@ function buildBody(rows: EarthworkRow[], slope?: SlopeTable): HTMLTableSectionEl
|
|||||||
td.textContent =
|
td.textContent =
|
||||||
index === 0 ? stationLabel(row.chainage_m) : cell(row[column.key], column.digits);
|
index === 0 ? stationLabel(row.chainage_m) : cell(row[column.key], column.digits);
|
||||||
if (index === 0) td.className = "b08-grid__station";
|
if (index === 0) td.className = "b08-grid__station";
|
||||||
|
// 근거 호버·등급색은 **사전이 왔을 때만** 붙는다(개발환경).
|
||||||
|
const columnProvenance = sheet?.columns[column.key];
|
||||||
|
if (columnProvenance) markProvenanceCell(td, column.key, columnProvenance.tier);
|
||||||
tr.append(td);
|
tr.append(td);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 줄마다 갈리는 사유(측구 안분 폴백 등)는 열 사전이 못 든다 — 줄에 실어 카드가 덧붙게 한다.
|
||||||
|
if (row.notes?.length) tr.dataset.provNotes = row.notes.join("\n");
|
||||||
|
|
||||||
const slopeRow = slopeByChainage.get(row.chainage_m);
|
const slopeRow = slopeByChainage.get(row.chainage_m);
|
||||||
// 사면이 원지반을 못 만난 측점은 값이 잘려 있다 — 줄에 표시를 남긴다(PLAN 8-4b).
|
// 사면이 원지반을 못 만난 측점은 값이 잘려 있다 — 줄에 표시를 남긴다(PLAN 8-4b).
|
||||||
if (slopeRow?.unclosed) tr.classList.add("is-unclosed");
|
if (slopeRow?.unclosed) tr.classList.add("is-unclosed");
|
||||||
@@ -458,11 +477,21 @@ export function renderEarthworkGrid(table: EarthworkTable): HTMLElement {
|
|||||||
scroller.className = "b08-grid__scroll";
|
scroller.className = "b08-grid__scroll";
|
||||||
const element = document.createElement("table");
|
const element = document.createElement("table");
|
||||||
element.className = "b08-grid__table";
|
element.className = "b08-grid__table";
|
||||||
|
const sheet = table.provenance?.sheets?.earthwork;
|
||||||
element.append(
|
element.append(
|
||||||
buildHead(),
|
buildHead(),
|
||||||
buildBody(table.rows, table.slope),
|
buildBody(table.rows, table.slope, sheet),
|
||||||
buildFoot(table.totals, table.slope),
|
buildFoot(table.totals, table.slope),
|
||||||
);
|
);
|
||||||
|
// 사전이 없으면 아무 일도 안 한다 — 빈 카드를 띄우면 「설명이 있다」는 거짓만 남는다.
|
||||||
|
// 줄 사유는 **그 사유가 닿는 열에만** 붙인다. 줄에 달렸다고 십몇 칸에 다 띄우면
|
||||||
|
// 「절토 보정량」 카드에 「측구 가름값…」 이 떠서 읽는 사람을 속인다(2026-09-12 실측).
|
||||||
|
// ⚠ 지금 줄 사유는 **측구 안분 폴백 하나뿐**이라 여기서 열 이름으로 가른다.
|
||||||
|
// 사유가 늘면 엔진이 「어느 열에 닿는 사유인가」를 같이 내는 쪽이 맞다.
|
||||||
|
attachProvenance(element, sheet, (cell, columnKey) => {
|
||||||
|
if (!columnKey.startsWith("ditch_")) return [];
|
||||||
|
return (cell.closest("tr")?.dataset.provNotes ?? "").split("\n").filter(Boolean);
|
||||||
|
});
|
||||||
|
|
||||||
if (table.slope) {
|
if (table.slope) {
|
||||||
const notice = buildUnclosedNotice(table.slope, element);
|
const notice = buildUnclosedNotice(table.slope, element);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { API_BASE_URL, CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
|
|||||||
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
|
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
|
||||||
import { attachCollapsible } from "@ui/ui_template_collapsible";
|
import { attachCollapsible } from "@ui/ui_template_collapsible";
|
||||||
import { groupPanelSections } from "./B08_Quantity_UI_SidePanel_Sections";
|
import { groupPanelSections } from "./B08_Quantity_UI_SidePanel_Sections";
|
||||||
|
import { createProvenanceToggle } from "@ui/ui_template_provenance";
|
||||||
import { workflowSteps } from "../A00_Common/b_page_scaffold";
|
import { workflowSteps } from "../A00_Common/b_page_scaffold";
|
||||||
import {
|
import {
|
||||||
fetchWorkflowState,
|
fetchWorkflowState,
|
||||||
@@ -779,6 +780,11 @@ function buildQuantityBody(
|
|||||||
return element;
|
return element;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 등급색 토글 — ⚠ **사전이 왔을 때만** 만든다(개발환경). 배포 빌드에서는 단추 자체가 없다.
|
||||||
|
if ((table as unknown as { provenance?: unknown } | null)?.provenance) {
|
||||||
|
tabs.append(createProvenanceToggle(body));
|
||||||
|
}
|
||||||
|
|
||||||
if (failed) {
|
if (failed) {
|
||||||
body.append(tabs, message(L("B08_Quantity_Grid_Failed")));
|
body.append(tabs, message(L("B08_Quantity_Grid_Failed")));
|
||||||
return body;
|
return body;
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
"""화면에 뜬 숫자가 **어디서 와서 어떻게 계산됐는지**를 적어 두는 한 벌.
|
||||||
|
|
||||||
|
왜 서버가 드나 (CLAUDE.md 5장 · PLAN 8-36 ④)
|
||||||
|
「어디서 와서 어떻게 계산됐나」의 정답은 **엔진이 안다.** 이 설명을 화면 TS 에 손으로
|
||||||
|
적어 두면 엔진을 고칠 때 설명만 옛것으로 남아, 맞는 값 옆에 틀린 근거가 붙는다.
|
||||||
|
그래서 사전은 값을 낳는 쪽(서버)이 들고, 화면은 **그리기만** 한다.
|
||||||
|
|
||||||
|
⚠ **칸마다 만들지 않는다 — 열 단위다.**
|
||||||
|
토적표 한 장이 30열 × 200줄 = 6천 칸이다. 칸마다 설명을 지으면 응답이 수십 배로 붐는데,
|
||||||
|
정작 설명이 갈리는 것은 **열**이지 칸이 아니다. 줄마다 갈리는 것(폴백 안분 사유 등)은
|
||||||
|
이미 줄이 `notes` 로 들고 있으니 화면이 그것만 덧붙인다. 보는 사람 눈에는 그대로
|
||||||
|
**칸 단위**로 뜬다.
|
||||||
|
|
||||||
|
⚠⚠ **로직 보안 — 배포에서는 아예 안 실어 보낸다.**
|
||||||
|
화면에서 숨기는 것만으로는 막히지 않는다. API 를 직접 부르면 그대로 나온다.
|
||||||
|
그래서 `provenance_payload()` 가 **개발환경이 아니면 `None`** 을 돌려주고, 라우터는
|
||||||
|
그 `None` 을 응답에서 통째로 뺀다. 화면 쪽 `import.meta.env.DEV` 는 보조일 뿐이다.
|
||||||
|
문의 정본은 `common_util_dev_unlock.is_dev_environment()` 하나로 통일한다 —
|
||||||
|
개발용 문이 두 벌이 되면 한쪽만 닫히는 날이 온다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Iterable, Mapping
|
||||||
|
|
||||||
|
from common_util.common_util_dev_unlock import is_dev_environment
|
||||||
|
|
||||||
|
#: 출처 등급 (PLAN 8-36 ①) — 사람이 고르는 여섯 + 사전이 쓰는 둘(`excluded`·`unclassified`). **키는 영문 고정** — 화면·서버가 같은 낱말을 써야 하고,
|
||||||
|
#: 사람이 읽는 이름은 화면 locale 이 맡는다(번역이 서버 값을 흔들면 안 된다).
|
||||||
|
#:
|
||||||
|
#: ⚠ 등급에 **안 맞는 열이 나오면 억지로 끼우지 말 것.** 그 어긋남이 등급을 고칠 근거다.
|
||||||
|
#: 맞는 등급이 없으면 `UNCLASSIFIED` 로 두고 계획서에 남긴다 — 조용히 아무 등급이나
|
||||||
|
#: 붙이면 「분류가 있다」는 거짓만 남는다.
|
||||||
|
TIER_INPUT = "input" # 사용자가 화면에 직접 넣은 값
|
||||||
|
TIER_SURVEY = "survey" # 앞 단계(B05 종단·B06 횡단)가 낳은 값
|
||||||
|
TIER_STANDARD = "standard" # 법·품셈·단가판이 정한 고정값
|
||||||
|
TIER_CALC = "calc" # 위 셋으로 만든 중간값
|
||||||
|
TIER_FINAL = "final" # 내역서·원가계산서로 나가는 값
|
||||||
|
TIER_BLOCKED = "blocked" # 근거가 없어 값을 **못** 세운 자리 — 근거가 오면 채워질 자리
|
||||||
|
#: ⚠ `EXCLUDED` 는 `BLOCKED` 와 **뜻이 정반대**다(2026-09-12 데스크탑 보조 B09 조사 ㉱).
|
||||||
|
#: 내역서의 「우리 줄이 아닌 것」·검산용 제외 줄·이중계상이 되는 자리는 **못 세운 것이 아니라
|
||||||
|
#: 세면 안 되는 것**이다. 둘을 같은 등급으로 두면 사용자가 「빈 칸을 채워야 겠다」고 움직이고,
|
||||||
|
#: 그것이 곧 이중계상이다(PLAN 8-7).
|
||||||
|
TIER_EXCLUDED = "excluded" # 일부러 안 세는 자리 — 채우면 이중계상
|
||||||
|
TIER_UNCLASSIFIED = "unclassified" # 어느 등급에도 안 맞아 **판단을 미룬** 자리
|
||||||
|
|
||||||
|
TIERS: tuple[str, ...] = (
|
||||||
|
TIER_INPUT,
|
||||||
|
TIER_SURVEY,
|
||||||
|
TIER_STANDARD,
|
||||||
|
TIER_CALC,
|
||||||
|
TIER_FINAL,
|
||||||
|
TIER_BLOCKED,
|
||||||
|
TIER_EXCLUDED,
|
||||||
|
TIER_UNCLASSIFIED,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ColumnProvenance:
|
||||||
|
"""열 하나의 「무엇이고 · 어디서 왔고 · 어떻게 나왔나」.
|
||||||
|
|
||||||
|
`formula` 는 **사람이 읽는 한 줄**이지 실행되는 식이 아니다 — 코드를 그대로 베끼면
|
||||||
|
읽는 사람이 못 읽고, 코드가 바뀌면 또 어긋난다. 「입적 × 토량환산계수」처럼 적는다.
|
||||||
|
|
||||||
|
`code` 는 `파일:줄` 이고 **개발환경에서만 화면에 뜬다.** 줄 번호는 쉽게 밀리므로
|
||||||
|
함수 이름을 같이 적어 두면 밀려도 찾을 수 있다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
key: str
|
||||||
|
label: str
|
||||||
|
tier: str
|
||||||
|
formula: str = ""
|
||||||
|
source: str = ""
|
||||||
|
#: 고르는 자리의 **채택 규칙**. 안전관리비 A·B 중 작은 쪽·자재단가 다섯 중 적용처럼
|
||||||
|
#: **값 안에 선택이 숨은** 열이 있다(2026-09-12 B09 조사 ㉰). 그 열은 `calc` 로만 적으면
|
||||||
|
#: 「왜 그것을 골랐나」가 사라진다. 후보값은 줄마다 달라지므로 여기엔 **규칙만** 적고
|
||||||
|
#: 실제 후보값은 줄 쪽으로 내려보낸다.
|
||||||
|
rule: str = ""
|
||||||
|
code: str = ""
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, str]:
|
||||||
|
body: dict[str, str] = {"label": self.label, "tier": self.tier}
|
||||||
|
if self.formula:
|
||||||
|
body["formula"] = self.formula
|
||||||
|
if self.source:
|
||||||
|
body["source"] = self.source
|
||||||
|
if self.rule:
|
||||||
|
body["rule"] = self.rule
|
||||||
|
if self.code:
|
||||||
|
body["code"] = self.code
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
def sheet_provenance(columns: Iterable[ColumnProvenance]) -> dict[str, Any]:
|
||||||
|
"""한 장(시트)의 사전. 열 키로 찾아 쓰게 dict 로 편다.
|
||||||
|
|
||||||
|
⚠ 같은 키를 두 번 적으면 **뒤엣것이 앞엣것을 조용히 덮는다.** 열이 늘 때 실수하기
|
||||||
|
쉬운 자리라 여기서 막고 이름을 알려 준다.
|
||||||
|
"""
|
||||||
|
body: dict[str, dict[str, str]] = {}
|
||||||
|
for column in columns:
|
||||||
|
if column.key in body:
|
||||||
|
raise ValueError(f"사전에 같은 열 키가 둘 있습니다: {column.key}")
|
||||||
|
if column.tier not in TIERS:
|
||||||
|
raise ValueError(f"모르는 등급입니다: {column.key} → {column.tier}")
|
||||||
|
body[column.key] = column.as_dict()
|
||||||
|
return {"columns": body}
|
||||||
|
|
||||||
|
|
||||||
|
def provenance_payload(sheets: Mapping[str, dict[str, Any]]) -> dict[str, Any] | None:
|
||||||
|
"""응답에 실을 사전 — **개발환경이 아니면 `None`.**
|
||||||
|
|
||||||
|
라우터는 `None` 이면 그 칸을 응답에서 아예 뺀다(빈 dict 를 실으면 「사전이 있는데
|
||||||
|
비었다」로 읽혀 화면이 빈 카드를 띄운다).
|
||||||
|
"""
|
||||||
|
if not is_dev_environment():
|
||||||
|
return None
|
||||||
|
return {"sheets": dict(sheets)}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"""B08 근거 사전 — 사전이 **엔진과 어긋나지 않는지** 지키는 시험 (PLAN 8-36 ④).
|
||||||
|
|
||||||
|
이 시험의 값어치는 마지막 것 하나에 있다: **사전에 적은 열 이름이 실제 토적표 줄에 있는가.**
|
||||||
|
엔진이 열을 바꾸거나 이름을 갈면 사전만 옛것으로 남아, 맞는 값 옆에 틀린 근거가 붙는다.
|
||||||
|
그 어긋남은 화면에서 눈에 안 띄므로(카드가 그냥 안 뜬다) 여기서 잡는다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import dataclasses
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from B08_Quantity.B08_Quantity_Engine_EarthworkTable import EarthworkRow
|
||||||
|
from B08_Quantity.B08_Quantity_Provenance import earthwork_sheet, quantity_provenance
|
||||||
|
from common_util import common_util_provenance as provenance_module
|
||||||
|
from common_util.common_util_provenance import (
|
||||||
|
TIERS,
|
||||||
|
ColumnProvenance,
|
||||||
|
provenance_payload,
|
||||||
|
sheet_provenance,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_토적표_사전이_엔진_열과_같은_이름을_쓴다():
|
||||||
|
"""사전 열 키가 전부 `EarthworkRow` 에 있어야 한다 — **이 시험이 사전의 존재 이유다.**"""
|
||||||
|
row_fields = {field.name for field in dataclasses.fields(EarthworkRow)}
|
||||||
|
dictionary = earthwork_sheet()["columns"]
|
||||||
|
낯선_키 = sorted(set(dictionary) - row_fields)
|
||||||
|
assert not 낯선_키, f"사전에 있는데 토적표 줄에 없는 열: {낯선_키}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_사전_등급이_전부_아는_값이다():
|
||||||
|
for key, body in earthwork_sheet()["columns"].items():
|
||||||
|
assert body["tier"] in TIERS, f"{key} 의 등급이 모르는 값: {body['tier']}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_사전_열마다_이름과_식이_비어_있지_않다():
|
||||||
|
"""빈 카드는 「설명이 있다」는 거짓만 남긴다 — 적을 것이 없으면 열을 아예 안 넣는다."""
|
||||||
|
for key, body in earthwork_sheet()["columns"].items():
|
||||||
|
assert body.get("label"), f"{key} 에 이름이 없음"
|
||||||
|
assert body.get("formula"), f"{key} 에 식이 없음"
|
||||||
|
|
||||||
|
|
||||||
|
def test_같은_열을_두_번_적으면_막는다():
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
sheet_provenance(
|
||||||
|
[
|
||||||
|
ColumnProvenance(key="a", label="가", tier="calc", formula="x"),
|
||||||
|
ColumnProvenance(key="a", label="나", tier="calc", formula="y"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_모르는_등급을_적으면_막는다():
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
sheet_provenance([ColumnProvenance(key="a", label="가", tier="없는등급")])
|
||||||
|
|
||||||
|
|
||||||
|
def test_배포환경에서는_사전을_아예_안_보낸다(monkeypatch):
|
||||||
|
"""⚠ 로직 보안 — 화면에서 숨기는 것이 아니라 **응답에 안 싣는 것**이 문이다."""
|
||||||
|
monkeypatch.setattr(provenance_module, "is_dev_environment", lambda: False)
|
||||||
|
assert provenance_payload({"earthwork": earthwork_sheet()}) is None
|
||||||
|
assert quantity_provenance() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_개발환경에서는_시트가_실린다(monkeypatch):
|
||||||
|
monkeypatch.setattr(provenance_module, "is_dev_environment", lambda: True)
|
||||||
|
payload = quantity_provenance()
|
||||||
|
assert payload is not None
|
||||||
|
assert "earthwork" in payload["sheets"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_고르는_자리의_채택_규칙은_적었을_때만_실린다():
|
||||||
|
"""`rule` 은 안전관리비처럼 **값 안에 선택이 숨은** 열에만 붙는다(B09 조사 ㉯)."""
|
||||||
|
없는_것 = ColumnProvenance(key="a", label="가", tier="calc", formula="x").as_dict()
|
||||||
|
assert "rule" not in 없는_것
|
||||||
|
있는_것 = ColumnProvenance(
|
||||||
|
key="b", label="나", tier="calc", formula="x", rule="A·B 중 작은 쪽"
|
||||||
|
).as_dict()
|
||||||
|
assert 있는_것["rule"] == "A·B 중 작은 쪽"
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
/* =============================================================================
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user