Merge remote-tracking branch 'origin/dev' into sub_laptop_7

This commit is contained in:
2026-10-12 21:16:39 +09:00
17 changed files with 970 additions and 93 deletions
+122
View File
@@ -0,0 +1,122 @@
/* =============================================================================
* A00_Common/b_project_switch.ts
* 다른 프로젝트에 들어갈 때 **그 밖 프로젝트의 캐시**를 지운다(계획서 290-1 · 요구 183-1).
*
* 지우는 것 — 이 탭 세션의 다른 프로젝트 칸(초안 · 결과 · 고름 · 옛 키 · 페이지별 칸)과
* 3D 지형 보관함(IndexedDB). 남기는 것 — 지금 프로젝트 것 · 계정 취향(pref, localStorage).
* 다른 프로젝트에 저장 안 한 고침(초안)이 있으면 확인창 — 「남기기」면 그 초안만 둔다.
* 같은 프로젝트로 다시 들어오면(새로고침 포함) 아무것도 안 지운다(탭마다 들어온 표식).
* ========================================================================== */
import { showConfirmDialog } from "@ui/ui_template_elements";
import { purgeOtherProjects } from "./b_asset_cache";
import { STATE_REGISTRY, type StateEntry } from "./b_page_state";
/** 이 탭이 마지막으로 들어온 프로젝트. */
const ENTERED_KEY = "aislo:entered-project";
const MARK_P = "\u0001";
const MARK_R = "\u0002";
/** 프로젝트를 가르는 키 머리 — 등록표 키(`aislo:{통}:{이름}:`)와 옛 키 머리. 초안 여부를 함께. */
function projectPrefixes(): Array<{ prefix: string; draft: boolean }> {
const out: Array<{ prefix: string; draft: boolean }> = [];
Object.entries(STATE_REGISTRY as Record<string, StateEntry>).forEach(([name, entry]) => {
if (entry.bucket === "pref" || entry.scope === "global") return;
const draft = entry.bucket === "draft";
const suffix = entry.version && entry.version > 1 ? `-v${entry.version}` : "";
out.push({ prefix: `aislo:${entry.bucket}:${name}${suffix}:`, draft });
const legacy = entry.legacy?.(MARK_P, MARK_R);
const at = legacy?.indexOf(MARK_P) ?? -1;
if (legacy && at > 0) out.push({ prefix: legacy.slice(0, at), draft });
});
return out;
}
const filled = (value: string | null): boolean =>
value !== null && value !== "" && value !== "{}" && value !== "[]";
export interface ProjectPurgePlan {
/** 저장 안 한 고침이 남은 다른 프로젝트. */
draftProjects: string[];
/** 지울 키 — `keep` 프로젝트의 초안은 뺀다. */
doomed: (keep: ReadonlySet<string>) => string[];
}
/** 세션 칸(키 → 값)에서 다른 프로젝트 몫을 가른다 — 저장소를 만지지 않는다. */
export function planProjectPurge(
session: Record<string, string>,
projectId: string,
): ProjectPurgePlan {
const prefixes = projectPrefixes();
const owners = new Map<string, { project: string; draft: boolean }>();
Object.keys(session).forEach((key) => {
const hit = prefixes.find((item) => key.startsWith(item.prefix));
if (!hit) return;
const project = key.slice(hit.prefix.length).split(":")[0];
if (project) owners.set(key, { project, draft: hit.draft });
});
const others = new Set(
[...owners.values()].map((owner) => owner.project).filter((id) => id !== projectId),
);
const draftProjects = [
...new Set(
[...owners.entries()]
.filter(([key, owner]) => owner.draft && others.has(owner.project) && filled(session[key]))
.map(([, owner]) => owner.project),
),
];
return {
draftProjects,
// 등록표 밖 칸도 키 마디에 다른 프로젝트 번호가 있으면 그 프로젝트 몫으로 본다.
doomed: (keep) =>
Object.keys(session).filter((key) => {
const owner = owners.get(key);
if (owner) return others.has(owner.project) && !(owner.draft && keep.has(owner.project));
return key.split(":").some((part) => others.has(part) && !keep.has(part));
}),
};
}
function sessionEntries(): Record<string, string> {
const out: Record<string, string> = {};
try {
for (let i = 0; i < window.sessionStorage.length; i += 1) {
const key = window.sessionStorage.key(i);
const value = key === null ? null : window.sessionStorage.getItem(key);
if (key !== null && value !== null) out[key] = value;
}
} catch {
/* 저장소가 막힌 브라우저 — 지울 것이 없다. */
}
return out;
}
/**
* 프로젝트에 들어감 — 들어온 표식이 다르면 그 밖 프로젝트 캐시를 지운다. 실패해도 이동은 막지 않는다.
* `nameOf` 는 확인창에 적을 프로젝트 이름.
*/
export async function enterProject(
projectId: string,
nameOf: (projectId: string) => Promise<string>,
): Promise<void> {
try {
if (window.sessionStorage.getItem(ENTERED_KEY) === projectId) return;
const plan = planProjectPurge(sessionEntries(), projectId);
const keep = new Set<string>();
for (const other of plan.draftProjects) {
const name = await nameOf(other).catch(() => other);
const drop = await showConfirmDialog(
`「${name}」에 저장 안 한 고침이 있음 — 지우고 열기 / 남기기`,
"지우고 열기",
"남기기",
);
if (!drop) keep.add(other);
}
plan.doomed(keep).forEach((key) => window.sessionStorage.removeItem(key));
window.sessionStorage.setItem(ENTERED_KEY, projectId);
await purgeOtherProjects(projectId);
} catch {
/* 저장소가 막힌 브라우저 — 캐시를 못 지워도 프로젝트는 연다. */
}
}
+15 -8
View File
@@ -10,6 +10,7 @@ import {
fetchSurfaceStatus,
} from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import { isProjectPreloaded, setPreloadTarget } from "./b_asset_cache";
import { enterProject } from "./b_project_switch";
import { navigateTo } from "./router";
export interface WorkflowState {
@@ -93,14 +94,20 @@ export async function dropGoneProject(): Promise<boolean> {
const PRELOAD_REQUIRED_ROUTES: readonly RoutePath[] = [ROUTES.B04_PREPROCESS, ROUTES.B05_PROFILE];
export function goToWorkflowStage(projectId: string, route: RoutePath): void {
localStorage.setItem(CURRENT_PROJECT_ID_KEY, projectId);
// 단계가 바뀌어 이동하는 자리다 — 캐시를 버려 다음 화면이 새 상태를 본다.
clearWorkflowStateCache();
if (!PRELOAD_REQUIRED_ROUTES.includes(route)) {
navigateTo(route);
return;
}
void routeAfterPreloadCheck(projectId, route);
// 다른 프로젝트에 들어가면 그 밖 프로젝트 캐시를 먼저 지운다(290-1).
void enterProject(
projectId,
async (id) => (await fetchWorkflowState(id)).project_name || id,
).then(() => {
localStorage.setItem(CURRENT_PROJECT_ID_KEY, projectId);
// 단계가 바뀌어 이동하는 자리다 — 캐시를 버려 다음 화면이 새 상태를 본다.
clearWorkflowStateCache();
if (!PRELOAD_REQUIRED_ROUTES.includes(route)) {
navigateTo(route);
return;
}
void routeAfterPreloadCheck(projectId, route);
});
}
/**
+2 -2
View File
@@ -28,8 +28,8 @@ export function createProfileOverlays(deps: ProfileOverlaysDeps) {
/** 유토곡선 총괄값 — **그래프 좌측 상단 배지**가 보인다(2026-09-06 사용자 지시).
* 유토곡선을 접어도 이 값은 남아야 한다. */
const massBadge = createMassHaulBadge();
// 그래프 위에 도구줄(상단줄)이 한 줄 있다 — 그 아래로 내려 겹치지 않게 한다.
massBadge.root.style.setProperty("--mass-haul-badge-top", "34px");
// 그래프 위 도구줄 · 측점 ▲ 줄(바닥 47px) 아래로 내려 겹치지 않게 한다(289).
massBadge.root.style.setProperty("--mass-haul-badge-top", "52px");
bodyWrap.append(massBadge.root);
let massHaulSummary: MassHaulSummaryValues | null = null;
// 유토 배분은 **서버가 만든다**(2026-09-06) — 편집이 멈추면 뒤에서 받아 두고, 도착하면
+9 -10
View File
@@ -181,9 +181,9 @@ export function createStructuresSection(
let editingId: string | null = null;
/** 목록에서 고른 계곡 통과 시설(누가거리 키). 삭제 버튼이 이쪽으로 동작한다. */
let selectedPipeChainage: number | null = null;
/** 임시 배치 중인 A군 시설의 누가거리(2026-08-18) — 측점을 넣는 순간 관 지점에
* 미리 넣어 세부유역·설계유량 미리보기를 얻고, [추가]로만 확정한다. 취소(삭제·
* 리셋·다른 항목 선택·종류 변경)하면 관을 물려 추가 직전 상태로 되돌린다. */
/** 임시 배치 중인 A군 시설의 누가거리 — 우클릭 「추가」만 관 지점에 미리 넣음(측점 입력은 안 넣음 · 291-1)
* · [추가]로 확정 · 취소(삭제 · 리셋 · 다른 항목 선택 · 종류 변경)하면 관을 물려 추가 직전 상태로.
* 새 세월교 · 물넘이 빈 월류 높이는 [추가] 뒤 재계산 유량으로 채움(126-1 `fordWaits`). */
let tempPipeChainage: number | null = null;
/** 위치 입력이 "확정"됐는지 — 측점번호+잔여거리를 다 받고 입력군을 빠져나간
* 순간(또는 폼에 위치를 실어 연 순간)에만 true. 옵션 활성화 시점도 임시 배치와
@@ -294,11 +294,9 @@ export function createStructuresSection(
facilityOptions.root.classList.toggle("is-locked", locked);
}
/** A군이면 기준 측점(측점번호+잔여거리)으로 임시 배치를 만들거나 옮긴다
* (세부유역 미리보기, 2026-08-18). 측점 두 칸을 **다 쓰고 빠져나온 뒤**에만
* 부른다 — 측점번호만 넣고 거리 칸으로 넘어가는 순간 나가면 재계산 동기화가
* 입력을 끊는다(2026-08-18 사용자 보고). */
function commitTempPipe(): void {
/** A군 임시 배치 — `create`(우클릭 「추가」)면 만들고, 있으면 기준 측점으로 옮김 · 측점 입력만으론
* 안 만듦(신규는 [추가] 뒤에만 생김 · 291-1). 측점 두 칸을 다 쓰고 빠져나온 뒤에만 부름. */
function commitTempPipe(create = false): void {
const type = currentType();
if (!type?.managed_by) return;
const anchor = anchorFields.read(interval(), false);
@@ -306,6 +304,7 @@ export function createStructuresSection(
if (anchor === null || (selectedPipeChainage !== null && tempPipeChainage === null)) return;
const attributes: FacilityAttributes = { facility: type.type_id as PipeFacility };
if (tempPipeChainage === null) {
if (!create) return;
tempPipeChainage = anchor;
callbacks.onPipeAdd(anchor, attributes);
} else if (Math.abs(tempPipeChainage - anchor) > 0.005) {
@@ -325,7 +324,7 @@ export function createStructuresSection(
}
/** 위치 입력 확정 — 측점번호+잔여거리를 다 받고 입력군을 빠져나온 순간.
* 옵션 활성화와 A군 임시 배치가 이 한 지점에서 같이 나간다(2026-08-18). */
* 옵션 활성화(와 우클릭으로 만든 A군 임시 배치 옮기기)가 이 한 지점에서 같이 나간다. */
function commitPosition(): void {
positionConfirmed = hasPosition();
commitTempPipe();
@@ -673,7 +672,7 @@ export function createStructuresSection(
if (addsAtOnce(type, optionInputs))
return void finishAdd({ type, anchorM: chainageM, structures, commit, pick: this });
// 임시 배치도 즉시 진행(A군).
if (type.managed_by) commitTempPipe();
if (type.managed_by) commitTempPipe(true);
syncButtons();
renderList();
root.scrollIntoView({ block: "nearest" });
@@ -169,8 +169,8 @@ export function bindStructuresEvents(ctx: StructuresEventContext): {
input.addEventListener("change", ctx.handleStationInput),
),
);
// 측점번호+잔여거리가 **둘 다 확정된 순간** 임시 배치·옵션 활성화가 바로 나간다
// (2026-08-18 사용자 지시 — 입력군을 떠나기 전이라도). 잔여거리 값이 확정되면
// 측점번호+잔여거리가 **둘 다 확정된 순간** 옵션 활성화가 바로 나간다(입력군을 떠나기
// 전이라도 · 새 구조물은 [추가] 뒤에만 생김 · 291-1). 잔여거리 값이 확정되면
// 즉시, 측점번호 수정은 잔여거리가 이미 있을 때만 즉시.
ctx.anchorFields.remainder.addEventListener("change", () => ctx.commitPosition());
ctx.anchorFields.station.addEventListener("change", () => {
@@ -207,7 +207,7 @@ export function bindStructuresEvents(ctx: StructuresEventContext): {
ctx.positionRow.addEventListener("keydown", (event) => {
// Enter = 입력 확정 — 포커스를 빼서 focusout 경로 하나로 처리한다(측점번호+
// 잔여거리 합산값이 그대로 임시 배치 좌표가 된다).
// 잔여거리 합산값이 그대로 위치가 된다).
if (event.key === "Enter" && document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
@@ -1,4 +1,3 @@
import { Point } from '@flatten-js/core';
import { type DesignMeta, HtmlEvent } from '../App.types.ts';
import { ImageEntity } from '../entities/ImageEntity.ts';
import { TextEntity } from '../entities/TextEntity.ts';
@@ -11,11 +10,8 @@ import {
getDesignMeta,
getEntities,
getFrameFields,
getLayers,
getScreenCanvasDrawController,
isDrawingDirty,
isDrawingReadOnly,
isViewOnly,
resetUndoBaseline,
setActiveLayerId,
setDesignMeta,
@@ -30,10 +26,10 @@ import {
setBare,
} from '../state.ts';
import { setInspectorCollapsed, setRibbonCollapsed } from '../components/ui-state.ts';
import { toast } from 'react-toastify';
import { runCommandInput } from '../commands/run-command.ts';
import { setRecoveryScope } from '../helpers/autosave.ts';
import { registerBoxResizeDrag } from './box-resize-drag.ts';
import { closeTextEditor, handleTextDoubleClick } from './text-inplace-edit.ts';
import { handleTableMessage } from './aislo-table-bridge.ts';
import { registerGuideDrag, setGuide, setGuideArea, setGuideVisible } from './guide-area.ts';
@@ -114,63 +110,11 @@ export function requestDrawingExport(fileFormat: 'dxf' | 'dwg') {
notifyParent(AISLO_DRAWING_EXPORT_MESSAGE, { fileFormat });
}
/**
* 수량 산출표 도면층 — 여기 글자는 앞 단계(B05·B06) 산출값이라 B07에서 고치지 않는다
* (2026-09-01 사용자 확정). 고치면 그림 글자만 바뀌고 저장되는 수량표는 그대로여서
* 화면과 저장값이 어긋났다.
*/
const QUANTITY_TABLE_LAYER_ID = 'b08-cross-table';
/**
* 더블클릭한 위치의 텍스트(잠금 해제 레이어)를 찾아 내용을 즉시 편집한다.
* CAD 측점 테이블 값 셀 수정 수단 — 잠금 레이어와 수량 산출표는 제외.
*/
/** 글자 더블클릭 = 그 자리 입력 칸(`text-inplace-edit.ts` · 293-1). */
function registerTextDoubleClickEdit() {
const canvas = getCanvas();
if (!canvas) return;
canvas.addEventListener('dblclick', (event: MouseEvent) => {
if (isViewOnly()) {
getScreenCanvasDrawController().zoomToFitScreen();
return;
}
if (isDrawingReadOnly()) {
toast.info('확정한 도면입니다. 고치려면 [수정]을 먼저 누르세요.');
return;
}
const drawController = getScreenCanvasDrawController();
const bounds = canvas.getBoundingClientRect();
const screenPoint = new Point(event.clientX - bounds.left, bounds.bottom - event.clientY);
const worldPoint = drawController.targetToWorld(screenPoint);
const lockedLayerIds = new Set(
getLayers()
.filter((layer) => layer.isLocked)
.map((layer) => layer.id)
);
let closest: TextEntity | null = null;
let closestDistance = Number.MAX_SAFE_INTEGER;
let closestOnQuantityTable = false;
for (const entity of getEntities()) {
if (!(entity instanceof TextEntity) || lockedLayerIds.has(entity.layerId)) continue;
const distanceInfo = entity.distanceTo(worldPoint);
if (distanceInfo && distanceInfo[0] < closestDistance) {
closestDistance = distanceInfo[0];
closest = entity;
closestOnQuantityTable = entity.layerId === QUANTITY_TABLE_LAYER_ID;
}
}
if (!closest) return;
const fontSize = closest.getTextOptions().fontSize;
const tolerance = Math.max((closest.getLabel().length + 2) * fontSize * 0.5, fontSize * 2);
if (closestDistance > tolerance) return;
if (closestOnQuantityTable) {
toast.info('수량표는 횡단설계(B06)에서 고칩니다. 고치고 돌아오면 반영됩니다.');
return;
}
const nextLabel = window.prompt('값 수정', closest.getLabel());
if (nextLabel === null || nextLabel === closest.getLabel()) return;
closest.setLabel(nextLabel);
setEntities([...getEntities()], true); // undo 스택 + DRAWING_CHANGED 통지
});
canvas.addEventListener('dblclick', (event: MouseEvent) => handleTextDoubleClick(event, canvas));
}
/**
@@ -224,6 +168,7 @@ export function registerAisloDrawingBridge() {
}
if (!isDrawingLoadMessage(event.data)) return;
closeTextEditor(false); // 옛 도면 글자 칸은 새 도면에 적용하지 않음
try {
const drawing = await getEntitiesAndLayersFromJsonObject(event.data.drawing);
setEntities(drawing.entities, false);
@@ -0,0 +1,247 @@
/**
* 글자 더블클릭 = 그 자리 입력 칸(293-1) — 브라우저 창(prompt) 대신 글자 위에 겹친 칸에서 바로 고친다.
* 칸은 글자 크기 · 기울기 · 확대 배율을 매 화면 따라간다(휠 줌 · 가운데 단추 팬 중에도).
* Enter = 적용(여러 줄은 Ctrl+Enter · Enter 는 줄바꿈) · Esc = 취소 · 칸 밖 누름 = 적용 · 되돌리기 한 번.
*/
import { Point } from '@flatten-js/core';
import { toast } from 'react-toastify';
import type { Entity } from '../entities/Entity';
import { TextEntity } from '../entities/TextEntity';
import { getAngleWithXAxis } from '../helpers/get-angle-with-x-axis.ts';
import { bumpSceneVersion } from '../helpers/scene-version.ts';
import { isImeKey } from '../inputController/ime-sink.ts';
import {
getEntities,
getLayers,
getScreenCanvasDrawController,
isDrawingReadOnly,
isViewOnly,
setEntities,
} from '../state.ts';
import { paintColor } from '../theme.ts';
import { cloneKeepingLook } from '../tools/move-tool.helpers.ts';
/**
* 수량 산출표 도면층 — 여기 글자는 앞 단계(B05·B06) 산출값이라 B07에서 고치지 않는다
* (2026-09-01 사용자 확정). 고치면 그림 글자만 바뀌고 저장되는 수량표는 그대로여서
* 화면과 저장값이 어긋났다.
*/
const QUANTITY_TABLE_LAYER_ID = 'b08-cross-table';
/** 새로 늘어난 줄 간격 — 여러 줄 문자 명령(text-tools)과 같은 배수 */
const LINE_GAP_RATIO = 1.35;
/** 너무 작게 줌아웃했을 때도 칠 수 있는 최소 글 크기(px) */
const MIN_EDITOR_FONT_PX = 10;
let closeOpenEditor: ((apply: boolean) => void) | null = null;
const basePointOf = (entity: TextEntity): Point => entity.getFirstPoint() as Point;
/** 여러 줄 문자(MTEXT) = 같은 묶음의 글자들이 같은 x 에 위아래로 놓인 것 · 위 줄부터. 아니면 그 글자 하나 */
export function textLinesOf(target: TextEntity, entities: Entity[]): TextEntity[] {
if (!target.groupId) return [target];
const group = entities.filter((entity) => entity.groupId === target.groupId);
const x = basePointOf(target).x;
const isMultiline =
group.length > 1 &&
group.every(
(entity) => entity instanceof TextEntity && Math.abs(basePointOf(entity).x - x) < 1e-6
);
if (!isMultiline) return [target];
return (group as TextEntity[]).sort((a, b) => basePointOf(b).y - basePointOf(a).y);
}
/** 칸 글을 도면에 적용한 새 목록 — 바뀐 게 없거나 빈 글이면 null. 고친 글자는 복제본으로 갈아 끼워 되돌리기가 옛 글을 살린다 */
export function applyTextEdit(
lines: TextEntity[],
value: string,
entities: Entity[]
): Entity[] | null {
const next =
lines.length > 1
? value
.split('\n')
.map((line) => line.trimEnd())
.filter((line) => line.trim().length > 0)
: [value];
if (!next.join('').trim()) return null;
if (next.join('\n') === lines.map((line) => line.getLabel()).join('\n')) return null;
const last = lines[lines.length - 1];
const gap =
lines.length > 1
? basePointOf(lines[0]).y - basePointOf(lines[1]).y
: last.getTextOptions().fontSize * LINE_GAP_RATIO;
const replaced = next.map((label, index) => {
const kept = index < lines.length;
const copy = cloneKeepingLook(kept ? lines[index] : last, kept) as TextEntity;
copy.setLabel(label);
if (!kept) {
copy.groupId = last.groupId;
copy.move(0, -gap * (index - lines.length + 1));
}
return copy;
});
const lineIndex = new Map(lines.map((line, index) => [line.id, index]));
const result: Entity[] = [];
for (const entity of entities) {
const index = lineIndex.get(entity.id);
if (index === undefined) result.push(entity);
else if (index < replaced.length) {
result.push(replaced[index]);
if (entity === last) result.push(...replaced.slice(lines.length));
}
}
return result;
}
/** 칸 안 키 — 적용 · 취소 · 그 밖(칸이 받음). 한글 조합 중 Enter 는 조합을 끝낼 뿐 */
export function editorKeyAction(
evt: Pick<KeyboardEvent, 'key' | 'ctrlKey' | 'metaKey' | 'isComposing' | 'keyCode'>,
multiline: boolean
): 'apply' | 'cancel' | null {
if (isImeKey(evt as KeyboardEvent)) return null;
if (evt.key === 'Escape') return 'cancel';
if (evt.key === 'Enter' && (!multiline || evt.ctrlKey || evt.metaKey)) return 'apply';
return null;
}
/** 열린 칸이 있으면 닫는다(적용) — 도면을 새로 실을 때 등 */
export function closeTextEditor(apply = true): void {
closeOpenEditor?.(apply);
}
/** 글자 자리에 입력 칸을 띄운다 — 글 전체 골라진 채 바로 입력 */
export function openTextEditor(lines: TextEntity[], canvas: HTMLCanvasElement): void {
closeTextEditor();
const multiline = lines.length > 1;
const editor = document.createElement(multiline ? 'textarea' : 'input');
editor.className = 'cad-text-inplace';
editor.value = lines.map((line) => line.getLabel()).join('\n');
editor.setAttribute('spellcheck', 'false');
editor.setAttribute('autocomplete', 'off');
const options = lines[0].getTextOptions();
const angle = getAngleWithXAxis(
new Point(0, 0),
new Point(options.textDirection.x, -options.textDirection.y)
);
const shiftX = { left: '0', center: '-50%', right: '-100%' }[options.textAlign] ?? '0';
Object.assign(editor.style, {
position: 'fixed',
zIndex: '1000',
margin: '0',
padding: '0',
border: 'none',
outline: '1px dashed #4a90d9',
background: 'transparent',
color: paintColor(options.textColor),
textAlign: options.textAlign,
whiteSpace: 'pre',
overflow: 'hidden',
resize: 'none',
transformOrigin: '0 0',
});
// 칸 아래 캔버스 글자를 감춘다 — 겹쳐 두 벌로 보이지 않게(닫을 때 되살림)
const previewLabels = lines.map((line) => line.previewLabel);
for (const line of lines) line.previewLabel = '';
bumpSceneVersion();
let frame = 0;
const place = () => {
const controller = getScreenCanvasDrawController();
const scale = controller.getScreenScale();
const fontPx = Math.max(options.fontSize * scale, MIN_EDITOR_FONT_PX);
const linePx = multiline
? Math.max((basePointOf(lines[0]).y - basePointOf(lines[1]).y) * scale, fontPx)
: fontPx * 1.2;
const anchor = controller.worldToTarget(basePointOf(lines[0]));
const bounds = canvas.getBoundingClientRect();
const rows = editor.value.split('\n');
const widest = Math.max(...rows.map((row) => controller.measureTextScreen(row, fontPx)));
Object.assign(editor.style, {
left: `${bounds.left + anchor.x}px`,
top: `${bounds.bottom - anchor.y}px`,
font: `${options.italic ? 'italic ' : ''}${options.bold ? 'bold ' : ''}${fontPx}px ${options.fontFamily}`,
lineHeight: `${linePx}px`,
width: `${widest + fontPx * 1.5}px`,
height: `${linePx * rows.length}px`,
transform: `rotate(${angle}rad) translate(${shiftX}, ${-linePx / 2}px)`,
});
frame = requestAnimationFrame(place);
};
let closed = false;
const close = (apply: boolean) => {
if (closed) return;
closed = true;
closeOpenEditor = null;
cancelAnimationFrame(frame);
document.removeEventListener('pointerdown', onOutsidePointer, true);
lines.forEach((line, index) => (line.previewLabel = previewLabels[index]));
const value = editor.value;
editor.remove();
const next = apply ? applyTextEdit(lines, value, getEntities()) : null;
if (next)
setEntities(next, true); // 되돌리기 한 번 + DRAWING_CHANGED 알림
else bumpSceneVersion();
};
// 칸 밖 누름 = 적용 · 가운데 단추(팬)는 칸이 따라가므로 닫지 않음
const onOutsidePointer = (evt: PointerEvent) => {
if (evt.target !== editor && evt.button !== 1) close(true);
};
editor.addEventListener('keydown', (evt) => {
evt.stopPropagation(); // CAD 단축키 · 명령줄로 새지 않게
const action = editorKeyAction(evt as KeyboardEvent, multiline);
if (!action) return;
evt.preventDefault();
close(action === 'apply');
});
editor.addEventListener('blur', () => close(true));
document.addEventListener('pointerdown', onOutsidePointer, true);
document.body.appendChild(editor);
closeOpenEditor = close;
place();
editor.focus({ preventScroll: true });
editor.select();
}
/**
* 더블클릭한 위치의 글자(잠금 해제 도면층)를 찾아 그 자리 입력 칸을 띄운다.
* 잠금 도면층 · 수량 산출표는 제외.
*/
export function handleTextDoubleClick(event: MouseEvent, canvas: HTMLCanvasElement): void {
if (isViewOnly()) {
getScreenCanvasDrawController().zoomToFitScreen();
return;
}
if (isDrawingReadOnly()) {
toast.info('확정한 도면입니다. 고치려면 [수정]을 먼저 누르세요.');
return;
}
const drawController = getScreenCanvasDrawController();
const bounds = canvas.getBoundingClientRect();
const screenPoint = new Point(event.clientX - bounds.left, bounds.bottom - event.clientY);
const worldPoint = drawController.targetToWorld(screenPoint);
const lockedLayerIds = new Set(
getLayers()
.filter((layer) => layer.isLocked)
.map((layer) => layer.id)
);
let closest: TextEntity | null = null;
let closestDistance = Number.MAX_SAFE_INTEGER;
for (const entity of getEntities()) {
if (!(entity instanceof TextEntity) || lockedLayerIds.has(entity.layerId)) continue;
const distanceInfo = entity.distanceTo(worldPoint);
if (distanceInfo && distanceInfo[0] < closestDistance) {
closestDistance = distanceInfo[0];
closest = entity;
}
}
if (!closest) return;
const fontSize = closest.getTextOptions().fontSize;
const tolerance = Math.max((closest.getLabel().length + 2) * fontSize * 0.5, fontSize * 2);
if (closestDistance > tolerance) return;
if (closest.layerId === QUANTITY_TABLE_LAYER_ID) {
toast.info('수량표는 횡단설계(B06)에서 고칩니다. 고치고 돌아오면 반영됩니다.');
return;
}
openTextEditor(textLinesOf(closest, getEntities()), canvas);
}
+20 -8
View File
@@ -386,19 +386,25 @@
display: inline-flex;
align-items: center;
gap: var(--spacing-4, 4px);
padding: 2px var(--spacing-8, 8px);
border: 1px solid var(--color-border);
/* 큰 알약(289) — 글자 · 여백 한 단 위 */
padding: var(--spacing-4, 4px) var(--spacing-12, 12px);
/* 붉은 계통(289-2) — 테두리 · 글자 · 옅은 바탕 = 위험색 섞음(밝음 · 어두움 같이) */
border: 1px solid color-mix(in srgb, var(--color-danger) 55%, transparent);
border-radius: var(--radius-pills, 999px);
background: color-mix(in srgb, var(--color-surface-raised) 88%, transparent);
color: var(--color-text-body);
font-size: var(--text-caption);
line-height: 1.3;
background: color-mix(
in srgb,
var(--color-danger) 6%,
color-mix(in srgb, var(--color-surface-raised) 88%, transparent)
);
color: color-mix(in srgb, var(--color-danger) 70%, var(--color-text-body));
font-size: var(--pill-font-size-lg);
line-height: var(--leading-body-sm);
white-space: nowrap;
pointer-events: auto;
}
.mass-haul-badge em {
color: var(--color-text-secondary);
color: color-mix(in srgb, var(--color-danger) 55%, var(--color-text-secondary));
font-style: normal;
}
@@ -406,6 +412,12 @@
font-weight: var(--font-weight-bold, 700);
}
/* 음수 = 더 진하게 — 테두리 = 위험색 그대로 · 바탕 짙게 · 값 = 위험색 더 많이(대비 4.5 : 1 지킴) */
.mass-haul-badge.is-over {
border-color: var(--color-danger);
background: color-mix(in srgb, var(--color-danger) 12%, var(--color-surface-raised));
}
.mass-haul-badge.is-over strong {
color: var(--color-danger, #f43f5e);
color: color-mix(in srgb, var(--color-danger) 85%, var(--color-text-body));
}
@@ -213,3 +213,6 @@
182. (원문 · 10-12) 그리고 횡단배수군 구조물 종류 중에 구조물측점을 제외한 구조물은 뭐야?
183. (원문 · 10-12 · 그림_183_물넘이포장.png) 1. 다른프로젝트에 들어갈경우에는 전체 프로젝트 관련 캐시는 지우는게 맞는것같아. 다른 프로젝트들을 돌아다닌다면 캐시가 무거워진다는 말이잖아. 다시 돌아온다면 그건 사용자의 의견이니 당연히 다시 로딩하여 사용자에게 보여주는게 맞는것같아. 2. 도수로/돌붙임의 경우에 면적값은 조심해야하는게 길이값을 지운다면 면적값은 기본값으로 돌려줘야함. 다시 넣으면 폭과 길이로 면적값을 산출하면 됨. 그때는 원지반까지 연결하면 되는거고 이해한거지? 3. 맹암거의 횡단도 형상에 대해 설명하겠음. 높이값과 기울기값을 받게 되어있으며 측구는 존재하지 않음. 도로의 경사를 각도를 반영하여 지대가 낮은 방향으로 기울이고 맹암거의 높이만큼 도로를 옵셋하고 성토까지 연결. 절토측의 선을 연장하여 옵셋한 기울여진 선과 접함. 그후 그 안에는 돌모양을 넣는다. 4. 물넘이 포장의 경우 포장임으로 노면공처럼 도로의 포장형상을 추가함. 이때 노면공과는 다르게 노견까지 포함되어야함. 5. 종단도 그래프의 좌측 상단의 누가토량의 값을 나타내는 알약을 조금더 크게 적용해줘. 공용양식을 찾아봐. 대신 크기가 커지고 나면 아래로 알약위치가 조금 아래로 내려와야 상승 버튼이 안가려질거야. 6. 물넘이 포장의 옵션값 입력에서 월류 폭 입력하면 높이는 자동으로 계산되어질텐데 이건 세월교에 로직이 있을거야. 공용로직이야. 7. 물넘이 포장의 경우 [Image #5] 형상중 회색 점선의 경우 너가 그려준 포장과 같은 각도여야할것같고 성토가 있는 경우에는 도로와 같은각도로 포장형상이 늘어나야해. 단 짧아지는 경우에는 당연히 성토가 생겨야함. 8. 물넘이 포장의 경우 콘크리트면이 기준이 되어야하고 점선이 물넘이 월류높이라면 중심점 위로 표현이 맞음. 9. 물넘이 포장과 맹암거의 구조물 선택에 대해 고려하여 반영할것. 이것까지 반영하고 너가 직접 사용자처럼 브라우져 검증해봐.
184. (원문 · 10-12) 10번 구조물 배치에서 신규 구조물은 추가버튼을 누른 뒤에 추가되게 반영. 지금은 기준측점을 넣으면 생성하는것같음.
185. (원문 · 10-12) 다음으로 이번에 형상을 추가하는 맹암거와 물넘이포장의 경우에 조심해야할 부분은 기존에 보라색선들로 종단측점의 상하나 성토, 절토로 이동을 하고 있다는 점이야. 구조물 선택과 포장 선택에 대해 조심히 반영해야할거야.
186. (원문 · 10-12) 누가토량의 알약의 크기는 적당한데 테두리와 색상을 붉은계동으로 변경해줘.
187. (원문 · 10-12) 다음으로 캐드 프로그램의 기본기능인데 지금 텍스트를 더블클릭하면 브라우져의 창이 나오고 변경할 수 있게 되어있는데 그냥 그자리에서 프롬프트아 바를 보여줘서 바로 텍스트를 바로 입력하게 해줘.
@@ -0,0 +1,216 @@
/**
* 글자 더블클릭 = 그 자리 입력 칸(PLAN 293-1) — prompt 대신 글자 위 입력 칸 · Enter 적용 · Esc 취소 ·
* 칸 밖 누름 적용 · 되돌리기 한 번 · 여러 줄(MTEXT)은 Ctrl+Enter 적용 · 키가 CAD 로 새지 않음.
* 실행: cd B07_DesignDetail/openwebcad && npx vitest run --dir ../../resources/tester/cad text-inplace-edit
*/
import { Point } from "@flatten-js/core";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../../../B07_DesignDetail/openwebcad/src/theme.ts", () => ({
paintColor: (color: string) => color,
}));
vi.mock("../../../B07_DesignDetail/openwebcad/src/state.ts", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
getScreenCanvasDrawController: () => ({
getScreenScale: () => 2,
worldToTarget: (point: Point) => point,
// 시험 쪽 Point 로 — 앱과 시험이 flatten 을 따로 실어 instanceof 가 갈림
targetToWorld: (point: Point) => new Point(point.x, point.y),
measureTextScreen: () => 40,
zoomToFitScreen: () => {},
}),
}));
import { TextEntity } from "../../../B07_DesignDetail/openwebcad/src/entities/TextEntity.ts";
import {
closeTextEditor,
editorKeyAction,
handleTextDoubleClick,
} from "../../../B07_DesignDetail/openwebcad/src/integration/text-inplace-edit";
import {
getEntities,
resetUndoBaseline,
setEntities,
undo,
} from "../../../B07_DesignDetail/openwebcad/src/state";
type Listener = (evt: Record<string, unknown>) => void;
class FakeElement {
public value = "";
public className = "";
public style: Record<string, string> = {};
public selected = false;
public removed = false;
public listeners: Record<string, Listener[]> = {};
constructor(public tagName: string) {}
setAttribute() {}
addEventListener(type: string, listener: Listener) {
(this.listeners[type] ??= []).push(listener);
}
fire(type: string, evt: Record<string, unknown> = {}) {
for (const listener of this.listeners[type] ?? []) listener(evt);
}
focus() {}
select() {
this.selected = true;
}
remove() {
this.removed = true;
this.fire("blur");
}
}
let created: FakeElement[] = [];
let documentListeners: Record<string, Listener[]> = {};
const prompt = vi.fn();
const canvas = {
getBoundingClientRect: () => ({ left: 10, top: 20, bottom: 520, right: 810 }),
} as unknown as HTMLCanvasElement;
beforeEach(() => {
created = [];
documentListeners = {};
prompt.mockReset();
vi.stubGlobal("window", { prompt, dispatchEvent: () => true });
vi.stubGlobal("requestAnimationFrame", () => 1);
vi.stubGlobal("cancelAnimationFrame", () => {});
vi.stubGlobal("document", {
createElement: (tag: string) => {
const element = new FakeElement(tag);
created.push(element);
return element;
},
body: { appendChild: () => {} },
addEventListener: (type: string, listener: Listener) =>
(documentListeners[type] ??= []).push(listener),
removeEventListener: (type: string, listener: Listener) =>
(documentListeners[type] = (documentListeners[type] ?? []).filter((l) => l !== listener)),
});
});
afterEach(() => {
closeTextEditor(false);
vi.unstubAllGlobals();
});
/** 화면 좌표 = 월드 좌표(가짜 제어기) · 캔버스 아래 520 기준으로 y 를 뒤집음 */
const dblclickAt = (x: number, y: number) =>
handleTextDoubleClick({ clientX: 10 + x, clientY: 520 - y } as MouseEvent, canvas);
const key = (editor: FakeElement, k: string, extra: Record<string, unknown> = {}) => {
const evt = {
key: k,
ctrlKey: false,
metaKey: false,
isComposing: false,
keyCode: 0,
stopped: false,
prevented: false,
stopPropagation() {
evt.stopped = true;
},
preventDefault() {
evt.prevented = true;
},
...extra,
};
editor.fire("keydown", evt);
return evt;
};
const labels = () => getEntities().map((entity) => (entity as TextEntity).getLabel());
const load = (...entities: TextEntity[]) => {
setEntities(entities, false);
resetUndoBaseline();
};
describe("글자 더블클릭 = 그 자리 입력 칸", () => {
it("더블클릭 → 글자 자리에 입력 칸 · 글 전체 골라짐 · prompt 안 부름 · 캔버스 글자 감춤", () => {
const text = new TextEntity("0", "배수관", new Point(100, 200), { fontSize: 5 });
load(text);
dblclickAt(101, 200);
expect(prompt).not.toHaveBeenCalled();
expect(created).toHaveLength(1);
const [editor] = created;
expect(editor.tagName).toBe("input");
expect(editor.value).toBe("배수관");
expect(editor.selected).toBe(true);
expect(editor.style.left).toBe("110px"); // 캔버스 왼쪽 10 + 글자 x 100
expect(editor.style.top).toBe("320px"); // 캔버스 아래 520 - 글자 y 200
expect(editor.style.font).toContain("10px"); // 글 크기 5 × 배율 2
expect(text.previewLabel).toBe("");
});
it("Enter = 적용 · 되돌리기 한 번에 옛 글 · 키가 CAD 로 새지 않음", () => {
load(new TextEntity("0", "배수관", new Point(100, 200), { fontSize: 5 }));
dblclickAt(100, 200);
const [editor] = created;
const typed = key(editor, "a");
expect(typed.stopped).toBe(true);
expect(typed.prevented).toBe(false);
editor.value = "횡단배수관";
const enter = key(editor, "Enter");
expect(enter.stopped).toBe(true);
expect(editor.removed).toBe(true);
expect(labels()).toEqual(["횡단배수관"]);
expect((getEntities()[0] as TextEntity).previewLabel).toBeNull();
undo();
expect(labels()).toEqual(["배수관"]);
});
it("Esc = 취소 · 글 그대로 · 감춘 글자 되살림", () => {
const text = new TextEntity("0", "배수관", new Point(100, 200), { fontSize: 5 });
load(text);
dblclickAt(100, 200);
const [editor] = created;
editor.value = "바꿈";
key(editor, "Escape");
expect(editor.removed).toBe(true);
expect(labels()).toEqual(["배수관"]);
expect(getEntities()[0]).toBe(text);
expect(text.previewLabel).toBeNull();
});
it("칸 밖 누름 = 적용 · 가운데 단추(팬)는 닫지 않음", () => {
load(new TextEntity("0", "배수관", new Point(100, 200), { fontSize: 5 }));
dblclickAt(100, 200);
const [editor] = created;
editor.value = "옹벽";
for (const listener of documentListeners.pointerdown) listener({ target: {}, button: 1 });
expect(editor.removed).toBe(false);
for (const listener of documentListeners.pointerdown) listener({ target: {}, button: 0 });
expect(editor.removed).toBe(true);
expect(labels()).toEqual(["옹벽"]);
});
it("한글 조합 중 Enter 는 적용하지 않음", () => {
expect(editorKeyAction({ key: "Enter", isComposing: true } as KeyboardEvent, false)).toBeNull();
expect(editorKeyAction({ key: "Enter", keyCode: 229 } as KeyboardEvent, false)).toBeNull();
});
it("여러 줄(MTEXT) = 여러 줄 칸 · Enter 줄바꿈 · Ctrl+Enter 적용 · 줄 늘면 아래로 · 되돌리기 한 번", () => {
const first = new TextEntity("0", "첫 줄", new Point(100, 200), { fontSize: 5 });
const second = new TextEntity("0", "둘째 줄", new Point(100, 193), { fontSize: 5 });
first.groupId = second.groupId = "m1";
load(second, first);
dblclickAt(100, 193);
const [editor] = created;
expect(editor.tagName).toBe("textarea");
expect(editor.value).toBe("첫 줄\n둘째 줄");
expect(key(editor, "Enter").prevented).toBe(false);
expect(editor.removed).toBe(false);
editor.value = "첫 줄\n둘째\n셋째";
key(editor, "Enter", { ctrlKey: true });
expect(editor.removed).toBe(true);
const lines = getEntities() as TextEntity[];
expect(lines.map((line) => line.getLabel())).toEqual(["둘째", "셋째", "첫 줄"]);
expect(lines.every((line) => line.groupId === "m1")).toBe(true);
expect(lines[1].getFirstPoint()?.y).toBeCloseTo(186);
undo();
expect(labels()).toEqual(["둘째 줄", "첫 줄"]);
});
it("수량표 글자는 칸을 띄우지 않음", () => {
load(new TextEntity("b08-cross-table", "12.5", new Point(100, 200), { fontSize: 5 }));
dblclickAt(100, 200);
expect(created).toHaveLength(0);
expect(prompt).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,90 @@
/* 290-1 다른 프로젝트 열면 그 밖 프로젝트 캐시 지움 — 진짜 TS(b_page_state · b_project_switch)를
* 가짜 저장소 위에서 돌려 JSON 으로 냄(공용 storage · 브라우저 안 씀). */
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
const load = (rel, requireStub) => {
const js = ts.transpileModule(fs.readFileSync(path.join(ROOT, rel), "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const exports = {};
new Function("exports", "require", "window", js)(exports, requireStub, global.window);
return exports;
};
function fakeStorage() {
const map = new Map();
return {
get length() {
return map.size;
},
key: (i) => [...map.keys()][i] ?? null,
getItem: (k) => (map.has(k) ? map.get(k) : null),
setItem: (k, v) => map.set(k, String(v)),
removeItem: (k) => map.delete(k),
dump: () => Object.fromEntries(map),
};
}
global.window = { sessionStorage: fakeStorage(), localStorage: fakeStorage() };
const pageState = load("A00_Common/b_page_state.ts", () => ({}));
const asked = [];
const purged = [];
let answer = true;
const sw = load("A00_Common/b_project_switch.ts", (id) => {
if (id.includes("b_page_state")) return pageState;
if (id.includes("b_asset_cache")) return { purgeOtherProjects: async (p) => purged.push(p) };
return {
showConfirmDialog: async (msg, ok, cancel) => (asked.push([msg, ok, cancel]), answer),
};
});
const A = "aaaa1111";
const B = "bbbb2222";
const C = "cccc3333";
const SEED = {
[`aislo:draft:structures:${A}`]: '[{"id":1}]',
[`aislo:draft:crossw:${A}:12`]: '{"10":1}',
[`b06:culvertopt:${A}:12`]: '{"x":1}', // 옛 키(초안)
[`aislo:result:latest:${A}`]: '{"r":1}',
[`aislo:draft:structures:${B}`]: '[{"id":2}]',
[`aislo:result:section-detail:${B}:7`]: '{"d":1}',
[`aislo:result:latest:${C}`]: '{"r":3}',
[`aislo:draft:pipes:${C}`]: "{}", // 빈 초안 = 고침 없음
[`b09:page-cache:${C}`]: "1", // 등록표 밖 페이지 칸
"aislo.otp-pending": "x", // 프로젝트와 무관
};
const seed = (extra = {}) => {
global.window.sessionStorage = fakeStorage();
Object.entries({ ...SEED, ...extra }).forEach(([k, v]) =>
global.window.sessionStorage.setItem(k, v),
);
asked.length = 0;
purged.length = 0;
};
const keys = () => Object.keys(global.window.sessionStorage.dump()).sort();
const nameOf = async (id) => `이름-${id}`;
global.window.localStorage.setItem(`aislo:pref:routeedit-view:${A}`, "{}");
(async () => {
const out = {};
seed();
answer = true;
await sw.enterProject(B, nameOf);
out.drop = { keys: keys(), asked: [...asked], purged: [...purged] };
seed();
answer = false;
await sw.enterProject(B, nameOf);
out.keep = { keys: keys(), asked: asked.length };
seed({ "aislo:entered-project": B });
await sw.enterProject(B, nameOf);
out.same = { keys: keys(), asked: asked.length, purged: purged.length };
out.pref = global.window.localStorage.getItem(`aislo:pref:routeedit-view:${A}`);
out.plan = sw.planProjectPurge(SEED, B).draftProjects;
process.stdout.write(JSON.stringify(out));
})();
+1 -1
View File
@@ -89,7 +89,7 @@ def test_addAt_은_새로_놓기_패널_폄_후_판정():
body.index('root.classList.remove("is-collapsed")'),
body.index("addsAtOnce(type, optionInputs)"),
body.index("finishAdd("),
body.index("commitTempPipe()"), # 폼만 갈래 — A군 임시 배치 그대로
body.index("commitTempPipe(true)"), # 폼만 갈래 — A군 임시 배치 그대로(우클릭만 · 291-1)
]
assert order == sorted(order)
assert "DEFAULT_INTERVAL_LENGTH_M" in body # 구간형 기본 15 m 그대로
@@ -0,0 +1,94 @@
"""289 종단도 누가토량 알약 크게 · 측점 ▲ 줄 아래로.
- 테마 토큰 `--pill-font-size-lg` = 글자 한 단 위(14px · 공용 알약 12px)
- 배지(B05 · B06 한 벌 `common_util_mass_haul.css`) = 토큰 글자 · 줄 간격 · 여백 4/12
- 자리 = 위에서 52px(▲ 줄 바닥 47px 아래 · 잰 값) · 종단 오버레이 한 곳(`design_profile_overlays.ts`)
"""
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
def _read(rel: str) -> str:
return (ROOT / rel).read_text(encoding="utf-8")
def _block(css: str, selector: str) -> str:
m = re.search(re.escape(selector) + r"\s*\{([^}]*)\}", css)
assert m, selector
return m.group(1)
def test_token_one_step_up():
theme = _read("ui_template/ui_template_theme.css")
assert "--pill-font-size-lg: var(--text-body-sm);" in theme
def test_badge_uses_large_pill():
badge = _block(_read("common_util/common_util_mass_haul.css"), ".mass-haul-badge")
assert "font-size: var(--pill-font-size-lg)" in badge
assert "line-height: var(--leading-body-sm)" in badge
assert "padding: var(--spacing-4, 4px) var(--spacing-12, 12px)" in badge
def test_badge_below_up_buttons():
overlays = _read("A00_Common/design/design_profile_overlays.ts")
m = re.search(r'"--mass-haul-badge-top", "(\d+)px"', overlays)
assert m and int(m.group(1)) > 47 # 측점 ▲ 줄 바닥(top 30 + 17)
# 289-2 붉은 계통 — 테마 위험색 섞음 · 밝음 · 어두움 대비(글자 4.5 : 1 이상)
THEMES = {
"light": {"danger": "#c0392b", "surface": "#ffffff", "body": "#333333", "secondary": "#615e6e"},
"dark": {"danger": "#e8695a", "surface": "#251f38", "body": "#e4e0f0", "secondary": "#b3aec6"},
}
def _rgb(hex_: str) -> tuple[float, ...]:
return tuple(int(hex_[i : i + 2], 16) / 255 for i in (1, 3, 5))
def _mix(a: str, pct: int, b: str) -> tuple[float, ...]:
"""CSS color-mix(in srgb, a pct%, b) — 불투명 근사."""
return tuple(x * pct / 100 + y * (100 - pct) / 100 for x, y in zip(_rgb(a), _rgb(b)))
def _lum(c: tuple[float, ...]) -> float:
lin = [v / 12.92 if v <= 0.04045 else ((v + 0.055) / 1.055) ** 2.4 for v in c]
return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2]
def _contrast(a: tuple[float, ...], b: tuple[float, ...]) -> float:
hi, lo = sorted((_lum(a), _lum(b)), reverse=True)
return (hi + 0.05) / (lo + 0.05)
def test_badge_red_tone_uses_theme_danger():
css = _read("common_util/common_util_mass_haul.css")
badge = _block(css, ".mass-haul-badge")
assert "border: 1px solid color-mix(in srgb, var(--color-danger) 55%, transparent)" in badge
assert "color: color-mix(in srgb, var(--color-danger) 70%, var(--color-text-body))" in badge
assert "var(--color-danger) 6%" in badge
em = _block(css, ".mass-haul-badge em")
assert "color-mix(in srgb, var(--color-danger) 55%, var(--color-text-secondary))" in em
over = _block(css, ".mass-haul-badge.is-over")
assert "border-color: var(--color-danger)" in over
assert "var(--color-danger) 12%" in over
over_value = _block(css, ".mass-haul-badge.is-over strong")
assert "color-mix(in srgb, var(--color-danger) 85%, var(--color-text-body))" in over_value
assert not re.search(r"#[0-9a-fA-F]{3,6}\b", badge + em + over + over_value) # 하드코딩 색 없음
def test_badge_red_tone_contrast_both_themes():
for name, t in THEMES.items():
bg = _mix(t["danger"], 6, t["surface"])
text = _mix(t["danger"], 70, t["body"])
caption = _mix(t["danger"], 55, t["secondary"])
over_bg = _mix(t["danger"], 12, t["surface"])
over_value = _mix(t["danger"], 85, t["body"]) # 음수 값 = 위험색 더 많이
assert _contrast(text, bg) >= 4.5, name
assert _contrast(caption, bg) >= 4.5, name
assert _contrast(over_value, over_bg) >= 4.5, name
assert _contrast(caption, over_bg) >= 4.5, name
@@ -0,0 +1,64 @@
"""290-1 다른 프로젝트 열면 그 밖 프로젝트 캐시 지움(요구 183-1).
- 들어갈 때(공용 `goToWorkflowStage`) 다른 프로젝트 세션 칸(초안 · 결과 · 옛 키 · 페이지 칸) · 3D 보관함 지움
- 다른 프로젝트에 저장 안 한 고침이 있으면 확인창(지우고 열기 / 남기기) · 남기기 = 그 초안만 둠
- 같은 프로젝트로 다시 들어오면 아무것도 안 지움 · 지금 프로젝트 · 계정 취향(pref) 그대로
`helper_290_1_project_purge.cjs` 가 진짜 TS 를 가짜 저장소 위에서 돌림.
"""
import json
import os
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
HERE = os.path.dirname(os.path.abspath(__file__))
A, B, C = "aaaa1111", "bbbb2222", "cccc3333"
MINE = [f"aislo:draft:structures:{B}", f"aislo:result:section-detail:{B}:7"]
UNRELATED = ["aislo.otp-pending", "aislo:entered-project"]
@pytest.fixture(scope="module")
def out():
proc = subprocess.run(
["node", os.path.join(HERE, "helper_290_1_project_purge.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_지우고_열기_다른_프로젝트_모두_지움(out):
assert out["drop"]["keys"] == sorted(MINE + UNRELATED)
assert out["drop"]["purged"] == [B] # 3D 보관함도
assert out["drop"]["asked"] == [
[f"「이름-{A}」에 저장 안 한 고침이 있음 — 지우고 열기 / 남기기", "지우고 열기", "남기기"]
]
def test_남기기_그_프로젝트_초안만_둠(out):
kept = [f"aislo:draft:crossw:{A}:12", f"aislo:draft:structures:{A}", f"b06:culvertopt:{A}:12"]
assert out["keep"]["keys"] == sorted(MINE + UNRELATED + kept)
assert out["keep"]["asked"] == 1
def test_같은_프로젝트_다시_들어오면_안_지움(out):
assert out["same"]["asked"] == 0 and out["same"]["purged"] == 0
assert len(out["same"]["keys"]) == 11
def test_빈_초안은_고침_아님_취향은_둠(out):
assert out["plan"] == [A] # C 의 빈 초안({})은 묻지 않음
assert out["pref"] == "{}"
def test_공용_이동_한_곳에서_부름():
nav = (ROOT / "A00_Common/b_workflow_nav.ts").read_text(encoding="utf-8")
assert "void enterProject(" in nav and "./b_project_switch" in nav
ui = (ROOT / "ui_template/ui_template_elements.ts").read_text(encoding="utf-8")
assert 'cancelLabel = "취소"' in ui and "label: cancelLabel" in ui
@@ -0,0 +1,68 @@
# -*- coding: utf-8 -*-
"""PLAN 291-1 — 구조물 배치: 새 구조물은 [추가] 누른 뒤에만 생김.
A군(관 지점 정본) — 기준 측점 확정(`commitPosition`)은 관을 안 넣음 · 우클릭 「추가」(116-1)만
임시 배치를 만듦 · [추가](`commit`)가 관 정본에 넣음. B · C 군은 [추가] 전 기록이 없음(`liveCommit` 이
고르던 항목만). 세월교 · 물넘이 빈 월류 높이는 [추가] 뒤 재계산 유량으로 채움(126-1 `fordWaits`).
"""
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
B05 = ROOT / "B05_Profile"
def _read(name: str) -> str:
return (B05 / name).read_text(encoding="utf-8")
def _body(src: str, start: str, end: str) -> str:
at = src.index(start)
return src[at : src.index(end, at)]
PANEL = _read("B05_Profile_UI_Structures_Panel.ts")
def test_임시_배치는_create_일_때만_관을_넣음():
body = _body(
PANEL, "function commitTempPipe(create = false): void {", "function handleStationInput"
)
guard = body.index("if (!create) return;")
add = body.index("callbacks.onPipeAdd(anchor, attributes);")
assert body.index("if (tempPipeChainage === null) {") < guard < add
# 이미 있는 임시 관(우클릭)은 기준 측점으로 옮김 그대로
assert "callbacks.onPipeUpdate(from, anchor, attributes);" in body
def test_측점_확정은_관을_안_만듦():
body = _body(PANEL, "function commitPosition(): void {", "function renderOptionFields")
assert "commitTempPipe();" in body
assert "commitTempPipe(true)" not in body
def test_우클릭_추가만_임시_배치_만듦():
body = _body(PANEL, " addAt(chainageM, typeId) {", " moveById(")
assert "if (type.managed_by) commitTempPipe(true);" in body
assert PANEL.count("commitTempPipe(true)") == 1
def test_추가_단추가_관_정본에_넣음():
commit = _read("B05_Profile_UI_Structures_Panel_Commit.ts")
body = _body(commit, "export async function commit(", "const placement = type.placement;")
assert "ctx.callbacks.onPipeAdd(anchor, attributes);" in body
def test_고르던_항목만_실시간_반영():
body = _body(PANEL, "function liveCommit(): void {", "function commitSummary")
assert "if (!editingId && selectedPipeChainage === null) return;" in body
def test_새_세월교_물넘이_월류_높이는_추가_뒤_채움():
drainage = _read("B05_Profile_UI_Drainage_Panel.ts")
body = _body(drainage, " addPipe(chainageM, attributes) {", " updatePipeFacility(")
assert "fordWaits.add(chainageM)" in body
def test_패널_700줄_안():
assert len(PANEL.splitlines()) <= 700
+10 -2
View File
@@ -314,7 +314,11 @@ export function showToast(
* -------------------------------------------------------------------------- */
/** 확인/취소 모달 — 확인이면 true. 네이티브 confirm 대신 반드시 이것을 쓴다. */
export function showConfirmDialog(message: string, confirmLabel = "확인"): Promise<boolean> {
export function showConfirmDialog(
message: string,
confirmLabel = "확인",
cancelLabel = "취소",
): Promise<boolean> {
return new Promise((resolve) => {
const overlay = el("div", { className: "ui-confirm" });
const panel = el("div", { className: "ui-confirm__panel" });
@@ -326,7 +330,11 @@ export function showConfirmDialog(message: string, confirmLabel = "확인"): Pro
if (back?.isConnected && back !== document.body) back.focus({ preventScroll: true });
resolve(result);
};
const cancel = createButton({ label: "취소", variant: "ghost", onClick: () => done(false) });
const cancel = createButton({
label: cancelLabel,
variant: "ghost",
onClick: () => done(false),
});
actions.append(
cancel,
createButton({ label: confirmLabel, variant: "danger", onClick: () => done(true) }),
+2
View File
@@ -170,6 +170,8 @@
--btn-bar-font-size: var(--text-caption);
/* 보조 탭 단추 높이 — B08 · B09 보조 탭 줄(.ui-subtabs) 한 꼴 */
--subtab-height: 24px;
/* 큰 알약 글자 — 공용 알약(.ui-tag · 12px) 한 단 위 · 종단 누가토량 배지가 씀 */
--pill-font-size-lg: var(--text-body-sm);
/* ---------------------------------------------------------------------------
* 6. 그림자 (Shadows) — 블루틴트, 서브틀 (최대 32px blur / 6% opacity)