fix(B06): 유출 관 끝점·전면 근입 기준 + 줌 유지·조정창 레이아웃 정리
- 유출 관 하단 끝점 = 벽 바닥 +0.5m 기준선과 전면 경사선(1:0.3)의 교차점 (성토부선 시작점과 동일 자리) — 정수 맞춤 재배치에도 동일 적용 - 근입 0.5m는 경사선(전면) 측 깊이 기준 — 전면 발끝 지반 아래 0.5까지 수렴 반복으로 내림(배관 벽·추가 벽 공통, 뜬 벽은 기준선 아래 0.5) - 카드 내부 조작(조정창 등)으로 다시 그려져도 줌·팬 배율 유지 (측점별 상태 보관, ZoomPanState) - 조정창: 재질 행을 높이 행 위 별도 행으로(폭 축소 — 한계는 툴팁), 추가 기슭막이(단) 행은 유출 벽에서 항상 표시(레이아웃 널뜀 방지) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -97,6 +97,7 @@ function buildExtraWall(
|
||||
height: number,
|
||||
material: RevetMaterial,
|
||||
floatGapM: number,
|
||||
groundAt: (offset: number) => number,
|
||||
): WallLayout {
|
||||
const thickness = REVET_THICKNESS_M;
|
||||
const baseWidth = thickness * 1.5 + REVET_LEAN_RATIO * height;
|
||||
@@ -107,8 +108,16 @@ function buildExtraWall(
|
||||
const topFront = topJoint + outward * thickness;
|
||||
const frontXAt = (elevation: number): number =>
|
||||
topFront + outward * REVET_LEAN_RATIO * (topElevation - elevation);
|
||||
// 하단 = 기준선(anchor) 아래 근입 0.5m 고정 — 높이 기준 "근입 0.5 위~상단"과 일치.
|
||||
const bottomElevation = anchor.elevation - REVET_EMBED_DEPTH_M;
|
||||
// 하단 = 기준선(anchor) 아래 근입 0.5m — 근입은 **경사선(전면) 측 깊이** 기준이라
|
||||
// 전면 발끝 지반이 낮으면 그 아래 0.5까지 더 내린다(2026-08-22 사용자 ②).
|
||||
let bottomElevation = anchor.elevation - REVET_EMBED_DEPTH_M;
|
||||
if (floatGapM <= 1e-6) {
|
||||
for (let pass = 0; pass < 6; pass += 1) {
|
||||
const toeGround = Math.min(groundAt(frontXAt(bottomElevation)), anchor.elevation);
|
||||
if (toeGround - REVET_EMBED_DEPTH_M >= bottomElevation - 1e-6) break;
|
||||
bottomElevation = toeGround - REVET_EMBED_DEPTH_M;
|
||||
}
|
||||
}
|
||||
const bottomBack: OffsetPoint = { offset: backOffset, elevation: bottomElevation };
|
||||
const bottomFront: OffsetPoint = {
|
||||
offset: frontXAt(bottomElevation),
|
||||
@@ -228,6 +237,7 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult
|
||||
height,
|
||||
material,
|
||||
Math.max(0, base - groundAt(anchorX)),
|
||||
groundAt,
|
||||
);
|
||||
walls.push(wall);
|
||||
// 성토선: src → (수평 선반 x>0이면 선반 끝) → 이음선 상단점. 사면길이 = 경사부.
|
||||
|
||||
@@ -339,7 +339,16 @@ export function computeCulvertLayout(
|
||||
// 더 높으면 그만큼 더 묻힌다. 전면 경사선(1:0.3)은 하단까지 연장.
|
||||
const frontXAt = (elevation: number): number =>
|
||||
topFront + outward * REVET_LEAN_RATIO * (topElevation - elevation);
|
||||
const bottomElevation = anchor.elevation - REVET_EMBED_DEPTH_M;
|
||||
let bottomElevation = anchor.elevation - REVET_EMBED_DEPTH_M;
|
||||
if (floatGapM <= 1e-6) {
|
||||
// 근입 0.5m는 **경사선(전면) 측 깊이** 기준(2026-08-22 사용자 ②) — 전면 발끝
|
||||
// 지반이 더 낮으면 그 아래 0.5까지 내린다. 내려가면 발끝이 더 나가므로 수렴 반복.
|
||||
for (let pass = 0; pass < 6; pass += 1) {
|
||||
const toeGround = Math.min(groundAt(frontXAt(bottomElevation)), anchor.elevation);
|
||||
if (toeGround - REVET_EMBED_DEPTH_M >= bottomElevation - 1e-6) break;
|
||||
bottomElevation = toeGround - REVET_EMBED_DEPTH_M;
|
||||
}
|
||||
}
|
||||
const bottomBack: OffsetPoint = { offset: backOffset, elevation: bottomElevation };
|
||||
const bottomFront: OffsetPoint = {
|
||||
offset: frontXAt(bottomElevation),
|
||||
@@ -450,11 +459,20 @@ export function computeCulvertLayout(
|
||||
// 맞춤이 벽을 옮긴 뒤에도 옛 축을 그대로 써서, 가파른 지반에서 관이 벽 바닥
|
||||
// 아래로 삐져나갔다(13+15.7 사용자 지적).
|
||||
const pipeStart = basinPipeEnd ?? inlet;
|
||||
/** 유출 관 하단 끝점 목표 = 벽 바닥 기준 0.5m 상단(기준선)과 **전면 경사선의
|
||||
* 교차점**(2026-08-22 사용자 ① — 성토부선 시작 규칙과 같은 자리). */
|
||||
const outletPipeEnd = (wall: WallLayout): OffsetPoint => ({
|
||||
offset:
|
||||
wall.points[2].offset +
|
||||
wall.outward * REVET_LEAN_RATIO * (wall.topJoint.elevation - wall.base),
|
||||
elevation: wall.base,
|
||||
});
|
||||
// 유출 벽이 사면 끝과 다른 자리면 관 끝도 그 자리 기준으로 맞춘다(올림 연장분은
|
||||
// 유출 쪽 — 2026-08-20 확정). 관 끝은 벽 하단선 중점을 지나 정수 길이까지 나간다.
|
||||
// 유출 쪽 — 2026-08-20 확정). 관 하단선은 전면 기준선 교차점을 지나 정수 길이로.
|
||||
if (outletWall) {
|
||||
const runW = outletWallAnchor.offset - pipeStart.offset;
|
||||
const riseW = outletWallAnchor.elevation - pipeStart.elevation;
|
||||
const face = outletPipeEnd(outletWall);
|
||||
const runW = face.offset - pipeStart.offset;
|
||||
const riseW = face.elevation - pipeStart.elevation;
|
||||
const lenW = Math.hypot(runW, riseW);
|
||||
if (lenW > 0.5) {
|
||||
const scaleW = Math.ceil(lenW - 1e-6) / lenW;
|
||||
@@ -537,10 +555,10 @@ export function computeCulvertLayout(
|
||||
},
|
||||
outletWallSpec.material,
|
||||
);
|
||||
// 관 하단선은 옮겨진 벽의 **하단선 중점을 지나야** 한다 — 옛 축을 그대로 늘리면
|
||||
// 가파른 지반에서 관이 벽 바닥 아래로 삐져나간다(2026-08-22 13+15.7 사용자 지적).
|
||||
const runW = outletWallAnchor.offset - pipeStart.offset;
|
||||
const riseW = outletWallAnchor.elevation - pipeStart.elevation;
|
||||
// 관 하단선은 옮겨진 벽의 **전면 기준선 교차점**을 지나야 한다(사용자 ①).
|
||||
const face = outletWall ? outletPipeEnd(outletWall) : outletWallAnchor;
|
||||
const runW = face.offset - pipeStart.offset;
|
||||
const riseW = face.elevation - pipeStart.elevation;
|
||||
const lenW = Math.hypot(runW, riseW) || 1;
|
||||
outlet.offset = pipeStart.offset + (runW / lenW) * target;
|
||||
outlet.elevation = pipeStart.elevation + (riseW / lenW) * target;
|
||||
|
||||
@@ -126,7 +126,31 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan
|
||||
),
|
||||
);
|
||||
|
||||
// 높이·재질 행(2026-08-22 사용자) — 높이 ±0.1m, 재질이 한계를 정한다.
|
||||
// 재질 행(2026-08-22 사용자 ④ — 높이 행 **위**, 폭 축소: 한계는 툴팁으로).
|
||||
const materialRow = document.createElement("label");
|
||||
materialRow.className = "b06-structure-panel__struct";
|
||||
const materialLabelEl = document.createElement("span");
|
||||
materialLabelEl.textContent = L("B06_Cross_Mat_Label");
|
||||
const materialSelect = document.createElement("select");
|
||||
materialSelect.className = "b06-structure-panel__select";
|
||||
const MATERIAL_LABEL: Record<RevetMaterial, string> = {
|
||||
dry: L("B06_Cross_Mat_Dry"),
|
||||
wet: L("B06_Cross_Mat_Wet"),
|
||||
concrete: L("B06_Cross_Mat_Concrete"),
|
||||
};
|
||||
for (const material of REVET_MATERIALS) {
|
||||
const option = document.createElement("option");
|
||||
option.value = material;
|
||||
option.textContent = MATERIAL_LABEL[material];
|
||||
option.title = `${L("B06_Cross_Height_Label")} ~${materialLimit(material).toFixed(1)}m`;
|
||||
materialSelect.append(option);
|
||||
}
|
||||
materialSelect.addEventListener("change", () => {
|
||||
if (current) deps.setMaterial(current, materialSelect.value as RevetMaterial);
|
||||
});
|
||||
materialRow.append(materialLabelEl, materialSelect);
|
||||
|
||||
// 높이 행 — ±0.1m, 한계는 재질이 정한다.
|
||||
const heightRow = document.createElement("div");
|
||||
heightRow.className = "b06-structure-panel__struct";
|
||||
const heightLabel = document.createElement("span");
|
||||
@@ -143,23 +167,7 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan
|
||||
L("B06_Cross_Height_Plus"),
|
||||
act((key) => deps.nudgeHeight(key, HEIGHT_STEP_M)),
|
||||
);
|
||||
const materialSelect = document.createElement("select");
|
||||
materialSelect.className = "b06-structure-panel__select";
|
||||
const MATERIAL_LABEL: Record<RevetMaterial, string> = {
|
||||
dry: L("B06_Cross_Mat_Dry"),
|
||||
wet: L("B06_Cross_Mat_Wet"),
|
||||
concrete: L("B06_Cross_Mat_Concrete"),
|
||||
};
|
||||
for (const material of REVET_MATERIALS) {
|
||||
const option = document.createElement("option");
|
||||
option.value = material;
|
||||
option.textContent = `${MATERIAL_LABEL[material]}(~${materialLimit(material).toFixed(1)}m)`;
|
||||
materialSelect.append(option);
|
||||
}
|
||||
materialSelect.addEventListener("change", () => {
|
||||
if (current) deps.setMaterial(current, materialSelect.value as RevetMaterial);
|
||||
});
|
||||
heightRow.append(heightLabel, heightMinus, heightValue, heightPlus, materialSelect);
|
||||
heightRow.append(heightLabel, heightMinus, heightValue, heightPlus);
|
||||
|
||||
// 유입측 구조물 형식 드롭다운(2026-08-22 사용자) — 자동/기슭막이/집수정 I·ㄴ·ㄷ.
|
||||
const structureRow = document.createElement("label");
|
||||
@@ -218,7 +226,7 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan
|
||||
const head = document.createElement("div");
|
||||
head.className = "b06-structure-panel__head";
|
||||
head.append(title, closeButton);
|
||||
root.append(head, value, structureRow, heightRow, extraRow, buttons);
|
||||
root.append(head, value, structureRow, materialRow, heightRow, extraRow, buttons);
|
||||
|
||||
return {
|
||||
root,
|
||||
@@ -252,17 +260,16 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan
|
||||
select.value = structure;
|
||||
}
|
||||
buttons.classList.toggle("is-hidden", !movable);
|
||||
materialRow.classList.toggle("is-hidden", !movable);
|
||||
heightRow.classList.toggle("is-hidden", !movable);
|
||||
if (movable) {
|
||||
heightValue.textContent = `${deps.heightFor(key).toFixed(1)}m`;
|
||||
materialSelect.value = deps.materialFor(key);
|
||||
}
|
||||
// 단 수 행은 **유출 벽에서만** — 성토부 5m 이상(의무)이거나 이미 단이 있을 때.
|
||||
// 단 수 행은 유출 벽에서 **항상** 보인다(2026-08-22 사용자 ⑤ — 상태 따라
|
||||
// 나타났다 사라지면 레이아웃이 널뛴다).
|
||||
const extra = deps.extraState();
|
||||
extraRow.classList.toggle(
|
||||
"is-hidden",
|
||||
!(key === "outlet" && (extra.canAdd || extra.count > 0)),
|
||||
);
|
||||
extraRow.classList.toggle("is-hidden", key !== "outlet");
|
||||
countInput.value = String(extra.count);
|
||||
const adjust = deps.adjustFor(key);
|
||||
// 조작 요약 — 0이면 "자동". 좌우는 안/바깥, 상하는 위/아래로 적는다.
|
||||
|
||||
@@ -32,6 +32,7 @@ import type {
|
||||
} from "./B06_Section_UI_Cross_Culvert_Wire";
|
||||
import { buildStructurePanel } from "./B06_Section_UI_Cross_Structure_Panel";
|
||||
import { attachZoomPan, buildZoomControls } from "./B06_Section_UI_Cross_View_Zoom";
|
||||
import type { ZoomPanState } from "./B06_Section_UI_Cross_View_Zoom";
|
||||
import type { RevetHighlightSetter, RevetKey } from "./B06_Section_UI_Cross_Culvert";
|
||||
import type { RevetMaterial } from "./B06_Section_UI_Cross_Culvert_Const";
|
||||
import type { WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types";
|
||||
@@ -166,14 +167,16 @@ export interface StationWidthControl {
|
||||
reset: (chainageM: number) => void;
|
||||
}
|
||||
|
||||
// 기슭막이·유입 구조물·다단 제어 인터페이스는 Wire로 옮겼다(700줄 제한) —
|
||||
// 기존 import 경로 유지를 위해 재수출한다.
|
||||
// 기슭막이·유입 구조물·다단 제어 인터페이스는 Wire에 있다(700줄 제한) — 재수출.
|
||||
export type {
|
||||
ExtraWallControl,
|
||||
InletStructureControl,
|
||||
RevetOffsetControl,
|
||||
} from "./B06_Section_UI_Cross_Culvert_Wire";
|
||||
|
||||
/** 측점별 줌·팬 상태 — 카드 재생성에도 배율 유지(2026-08-22 사용자 ③). */
|
||||
const cardZoomStates = new Map<string, ZoomPanState>();
|
||||
|
||||
export function createCrossSectionCard(
|
||||
section: CrossSection,
|
||||
selected: boolean,
|
||||
@@ -219,15 +222,14 @@ export function createCrossSectionCard(
|
||||
let activeRevet: RevetKey | null = revetOffset?.selectedFor(section) ?? null;
|
||||
let setRevetActive: RevetHighlightSetter = () => undefined;
|
||||
let showRevetControl: (visible: boolean) => void = () => undefined;
|
||||
/** 지금 그린 관 길이(m) — 조정창이 "관 길이 8m"으로 적는다. 배수관 측점이 아니면 null. */
|
||||
/** 지금 그린 관 길이(m) — 조정창 표기용. 배수관 측점이 아니면 null. */
|
||||
let culvertPipeLengthM: number | null = null;
|
||||
/** 유입측이 집수정인가 — 조정창이 ◀/▶ 표시 여부·제목을 가른다. */
|
||||
let culvertInletIsBasin = false;
|
||||
let culvertInletIsBasin = false; // 유입이 집수정인가 — 조정창 표시·제목 분기
|
||||
/** 드롭다운 선택지 가용성(2026-08-22) — 기하 판정을 조정창에 전달. */
|
||||
let culvertInletOptions = { revetAllowed: true, basinLUAllowed: true };
|
||||
/** 추가 기슭막이 상태(2026-08-22) — 끝 성토부 5m 이상(canAdd)·세워진 개수. */
|
||||
let culvertExtraState = { canAdd: false, count: 0 };
|
||||
/** 마지막 계산의 벽 제원(높이·재질) — 조정창 높이 행 표시·높이 조작 기준값. */
|
||||
/** 마지막 계산의 벽 제원(높이·재질) — 조정창 표시·높이 조작 기준. */
|
||||
let culvertWallSpecs = new Map<RevetKey, { height: number; material: RevetMaterial }>();
|
||||
/**
|
||||
* 기슭막이를 고른다. 구조물 선택과 절·성토 면적 강조는 **같은 레벨**이라 하나를
|
||||
@@ -495,8 +497,7 @@ export function createCrossSectionCard(
|
||||
toDisplayY,
|
||||
);
|
||||
}
|
||||
// 배수관 측점 세트 — 기존 도형 위에 추가만 한다(2026-08-19). 조정창이 쓸
|
||||
// 카드 상태(관 길이·집수정 여부·선택지·추가 벽 상태)를 여기서 받아 둔다.
|
||||
// 배수관 세트 — 조정창이 쓸 카드 상태를 여기서 받아 둔다(2026-08-19).
|
||||
culvertPipeLengthM = culvertLayout?.pipe.lengthM ?? null;
|
||||
culvertInletIsBasin = !!culvertLayout?.basin;
|
||||
if (culvertLayout) {
|
||||
@@ -588,13 +589,19 @@ export function createCrossSectionCard(
|
||||
// 확대·축소 버튼, 드래그 팬, 더블클릭 원복(E-5). 도형 레이어 transform + non-scaling-stroke.
|
||||
const chartWrap = document.createElement("div");
|
||||
chartWrap.className = "b06-cross-card__chart-wrap";
|
||||
const zoomPan = attachZoomPan(svg, plotLayer, widthPx, heightPx);
|
||||
const zoomPan = attachZoomPan(
|
||||
svg,
|
||||
plotLayer,
|
||||
widthPx,
|
||||
heightPx,
|
||||
cardZoomStates.get(section.station_id),
|
||||
(state) => cardZoomStates.set(section.station_id, state),
|
||||
);
|
||||
// 절·성토 면적값은 그래프 중상단 오버레이로 표시(E-4). 값 칸은 항상 강조 토글이다.
|
||||
const readout = buildAreaReadout(section.design, toggleArea);
|
||||
setChipActive = readout.setActive;
|
||||
// 구조물 위치 조정은 **도면 안 오버레이 창**으로 한다(2026-08-21 사용자 확정).
|
||||
// 벽이 서는 쪽(outward): 상단측(유입)이 좌측이면 유입 벽은 좌(+), 유출 벽은 우(−).
|
||||
// 화면 좌(◀)로 민다 = offset이 커진다 — 벽 기준 이동량으로 환산해 넘긴다.
|
||||
// 구조물 조정은 도면 안 오버레이 창(2026-08-21). 화면 좌(◀) = offset 증가 —
|
||||
// 벽 기준 이동량(outward 부호)으로 환산해 넘긴다.
|
||||
const outwardOf = (role: RevetKey): number =>
|
||||
((section.uphill_side ?? "left") === "left") === (role === "inlet") ? 1 : -1;
|
||||
const heightOfWall = (key: RevetKey): number => culvertWallSpecs.get(key)?.height ?? 0;
|
||||
@@ -608,7 +615,6 @@ export function createCrossSectionCard(
|
||||
revetOffset?.update(section.chainage_m, key, {
|
||||
x: adjustOf(key).x + screenDeltaM * outwardOf(key),
|
||||
}),
|
||||
// 상하(대각) — ▲(위) = 성토선을 타고 노견 쪽, ▼(아래) = 계류 쪽. 수평 성분 1m.
|
||||
nudgeSlope: (key, deltaM) =>
|
||||
revetOffset?.update(section.chainage_m, key, { d: adjustOf(key).d + deltaM }),
|
||||
nudgeHeight: (key, deltaM) => {
|
||||
@@ -633,8 +639,7 @@ export function createCrossSectionCard(
|
||||
optionsFor: () => culvertInletOptions,
|
||||
extraState: () => culvertExtraState,
|
||||
setExtraCount: (count) => {
|
||||
// 줄어들며 사라지는 벽이 선택 중이면 선택을 유출 벽으로 옮긴다 — 다시
|
||||
// 그려질 때 없는 키를 강조하려다 조정창이 빈 대상으로 남는 것을 막는다.
|
||||
// 줄어들며 사라지는 벽이 선택 중이면 선택을 유출 벽으로 옮긴다(빈 대상 방지).
|
||||
if (activeRevet?.startsWith("extra") && Number(activeRevet.slice(5)) >= count) {
|
||||
activeRevet = "outlet";
|
||||
revetOffset?.select(section.chainage_m, "outlet");
|
||||
|
||||
@@ -18,11 +18,22 @@ export interface ZoomPanHandle {
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
/** 줌·팬 상태 — 카드가 다시 그려질 때 배율을 되살리는 데 쓴다(2026-08-22 사용자 ③). */
|
||||
export interface ZoomPanState {
|
||||
scale: number;
|
||||
tx: number;
|
||||
ty: number;
|
||||
}
|
||||
|
||||
export function attachZoomPan(
|
||||
svg: SVGSVGElement,
|
||||
plotLayer: SVGGElement,
|
||||
widthPx: number,
|
||||
heightPx: number,
|
||||
/** 이전 카드의 줌·팬 상태 — 조정창 조작으로 카드가 다시 그려져도 배율 유지. */
|
||||
initial?: ZoomPanState,
|
||||
/** 상태가 바뀔 때마다 부른다 — 카드 밖(뷰)이 담아 뒀다가 다음 렌더에 되돌린다. */
|
||||
onChange?: (state: ZoomPanState) => void,
|
||||
): ZoomPanHandle {
|
||||
// 플롯 영역(축 안쪽) — 확대·축소의 중심이자 이동 한계의 기준이다.
|
||||
const plot = {
|
||||
@@ -32,11 +43,13 @@ export function attachZoomPan(
|
||||
height: Math.max(heightPx - CROSS_PAD.top - CROSS_PAD.bottom, 1),
|
||||
};
|
||||
const maxScale = 8;
|
||||
let scale = 1;
|
||||
let tx = 0;
|
||||
let ty = 0;
|
||||
const applyTransform = (): void =>
|
||||
let scale = initial?.scale ?? 1;
|
||||
let tx = initial?.tx ?? 0;
|
||||
let ty = initial?.ty ?? 0;
|
||||
const applyTransform = (): void => {
|
||||
plotLayer.setAttribute("transform", `translate(${tx} ${ty}) scale(${scale})`);
|
||||
onChange?.({ scale, tx, ty });
|
||||
};
|
||||
// 확대한 도형이 플롯 영역을 항상 덮게 이동량을 가둔다 — 원배율에서는 이동량이 0으로 묶인다.
|
||||
const clampPan = (): void => {
|
||||
tx = Math.min(Math.max(tx, (plot.x + plot.width) * (1 - scale)), plot.x * (1 - scale));
|
||||
@@ -100,6 +113,10 @@ export function attachZoomPan(
|
||||
/* 이미 해제됨 */
|
||||
}
|
||||
};
|
||||
if (initial && (scale !== 1 || tx !== 0 || ty !== 0)) {
|
||||
clampPan();
|
||||
applyTransform();
|
||||
}
|
||||
svg.addEventListener("pointerup", endPan);
|
||||
svg.addEventListener("pointercancel", endPan);
|
||||
// 드래그(팬)로 끝난 클릭은 카드 선택으로 전파하지 않는다.
|
||||
|
||||
@@ -272,6 +272,7 @@ export const ui_locales_b2 = {
|
||||
"{mat} height limit {limit}m — change material to go higher",
|
||||
],
|
||||
B06_Cross_Height_Floor: ["최소 높이라 더 낮출 수 없습니다", "Already at the minimum height"],
|
||||
B06_Cross_Mat_Label: ["재질", "Material"],
|
||||
B06_Cross_Mat_Dry: ["메쌓기", "Dry masonry"],
|
||||
B06_Cross_Mat_Wet: ["찰쌓기", "Wet masonry"],
|
||||
B06_Cross_Mat_Concrete: ["콘크리트", "Concrete"],
|
||||
|
||||
Reference in New Issue
Block a user