Merge remote-tracking branch 'origin/dev' into sub_laptop_6
This commit is contained in:
@@ -89,7 +89,7 @@ export const STATE_REGISTRY = {
|
||||
"masshaul-visible": {
|
||||
bucket: "pref",
|
||||
scope: "global",
|
||||
version: 5,
|
||||
version: 6,
|
||||
legacy: () => "b06:masshaul-visible-v4",
|
||||
},
|
||||
"table-open": { bucket: "pref", scope: "global", legacy: () => "b05:profile:table:open" },
|
||||
|
||||
@@ -32,6 +32,7 @@ import { saveSharedDesign, type DesignPageShared } from "./design_shared";
|
||||
import { guardDesignLeave } from "./design_leave";
|
||||
import { usePipeIds } from "./design_pipe_ids";
|
||||
import { setRouteShiftOpener } from "../../B06_Section/B06_Section_UI_Cross_Center";
|
||||
import { reconcilePagePipeline } from "./design_pipeline_page";
|
||||
import "./design_page.css";
|
||||
|
||||
export type { DesignPageShared } from "./design_shared";
|
||||
@@ -244,6 +245,7 @@ export function renderDesignPage(root: HTMLElement): MountedPage {
|
||||
if (slot) {
|
||||
slot.show();
|
||||
applyView(view);
|
||||
void reconcilePagePipeline(projectId); // 들어올 때 입력(세션 · 가게) 대 묶음 맞춤(365-2)
|
||||
} else mountView(view, true);
|
||||
// 검증용 — 보기 전환에 든 시간(ms)과 횟수를 화면 밖에서 수치로 본다.
|
||||
const probe = window as unknown as { __designPage?: { view: string; switchMs: number[] } };
|
||||
|
||||
@@ -8,9 +8,11 @@
|
||||
* 넘기는 칸 = 서버 노선 저장(`CARRY_INPUT_KEYS`)과 같은 일곱(지반 유형 · 측구 종류 · 측구 선택 · 2단 경사 ·
|
||||
* 암 경계 · 암 경사 · 소단) + 얹힌 시설 맥락(포장 · 물넘이 기울임 · 세월교 노면 하강). 단면 유형 · 측구
|
||||
* 방향은 새 지형이 정함(`logic/B05_Profile_Pipeline_Detail.ts` `crossWithUphill`) · 노견 확장 · 소단
|
||||
* 깊이는 안 넘김. 값 = 상세에 얹힌 설계(세션 고침은 종단도 · 횡단 보기가 다시 셈하며 이미 얹음).
|
||||
* 깊이는 안 넘김. 값 = 상세에 얹힌 설계 위에 세션 고침(측점 열쇠 칸 · 지반 유형 · 측구 · 2단 경사 · 암 경계 ·
|
||||
* 암 경사) — 노선 초안 동안 B06 고침은 저장 상세에 안 얹혀도 입력에 들어감(365-2). 부를 때마다 모음.
|
||||
* ========================================================================== */
|
||||
|
||||
import { readState } from "../b_page_state";
|
||||
import type { CrossSection, SectionDetailResponse } from "../../B06_Section/B06_Section_Api_Fetch";
|
||||
import {
|
||||
effectiveStandardCross,
|
||||
@@ -66,16 +68,50 @@ function carried(section: CrossSection): Record<string, unknown> {
|
||||
: null,
|
||||
paved: Boolean(section.pavement?.length) || Boolean(section.ford_pavement),
|
||||
tilted: Boolean(section.ford_pavement || section.underdrain),
|
||||
tiltPct: section.ford_pavement?.slope_pct ?? section.underdrain?.slope_pct ?? null,
|
||||
tiltPct: section.ford_pavement?.slope_pct ?? null, // 맹암거 = 표준 경사(371-3)
|
||||
surfaceDropM: drop > 0 ? drop : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** 저장 상세 + 저장 노선 측점(열쇠) → cross 입력 · 시설 제원 — 표준단면 · 상세가 없으면 null. */
|
||||
/** 세션 칸 하나를 측점 열쇠마다 — 옛 열쇠(누가거리 · `at:`)는 읽는 쪽이 옮긴 뒤에 들어옴(빼고 봄). */
|
||||
function sessionCells(
|
||||
name: "crossdesign" | "rockb" | "cutslope",
|
||||
projectId: string,
|
||||
routeId: number,
|
||||
): Array<[string, unknown]> {
|
||||
const raw = readState<Record<string, unknown>>(name, projectId, routeId);
|
||||
if (!raw || typeof raw !== "object") return [];
|
||||
return Object.entries(raw).filter(([key]) => key.includes(":") && !key.startsWith("at:"));
|
||||
}
|
||||
|
||||
/** 세션 횡단 고침 → 측점 열쇠마다 넘기는 입력 조각(`carried` 와 같은 칸 이름). */
|
||||
function sessionCarried(projectId: string, routeId: number): CrossInput["byKey"] {
|
||||
const out: CrossInput["byKey"] = {};
|
||||
for (const [key, value] of sessionCells("crossdesign", projectId, routeId)) {
|
||||
const choice = (value ?? {}) as Record<string, unknown>;
|
||||
const entry = (out[key] ??= {});
|
||||
if (typeof choice.ground_type === "string") entry.groundType = choice.ground_type;
|
||||
if (typeof choice.ditch_type === "string") entry.ditchType = choice.ditch_type;
|
||||
if ("ditch_choice" in choice)
|
||||
entry.ditchChoice = typeof choice.ditch_choice === "boolean" ? choice.ditch_choice : null;
|
||||
if ("two_stage_slope" in choice) entry.twoStageSlope = Boolean(choice.two_stage_slope);
|
||||
}
|
||||
for (const [key, value] of sessionCells("rockb", projectId, routeId))
|
||||
if (finite(value) !== null) (out[key] ??= {}).rockBoundaryOffsetM = value;
|
||||
for (const [key, value] of sessionCells("cutslope", projectId, routeId)) {
|
||||
const ratio = finite(value);
|
||||
if (ratio !== null) (out[key] ??= {}).cutSlopeRatio = ratio > 0 ? ratio : null;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 저장 상세 + 저장 노선 측점(열쇠) + 세션 고침(`routeId` 칸) → cross 입력 · 시설 제원 — 표준단면 ·
|
||||
* 상세가 없으면 null. */
|
||||
export function pipelineCrossSource(
|
||||
projectId: string,
|
||||
detail: SectionDetailResponse | null,
|
||||
savedStations: ReadonlyArray<PipelineStation>,
|
||||
routeId: number | null = null,
|
||||
): PipelineCrossSource | null {
|
||||
const standard = detail && effectiveStandardCross(projectId);
|
||||
if (!detail || !standard) return null;
|
||||
@@ -94,6 +130,9 @@ export function pipelineCrossSource(
|
||||
for (const name of SPEC_KEYS) if (section[name] !== undefined) own[name] = section[name];
|
||||
specs.set(best.key, own as SectionSpecs);
|
||||
}
|
||||
if (routeId !== null)
|
||||
for (const [key, entry] of Object.entries(sessionCarried(projectId, routeId)))
|
||||
byKey[key] = { ...byKey[key], ...entry };
|
||||
return {
|
||||
// 암 경계 기본값 = 서버 config(화면이 문맥에서 기억) — 입력 없는 측점(초안 새 측점)도 서버처럼(349-0p)
|
||||
input: { standard, byKey, rockBoundaryDefaultM: readRockBoundaryDefault(projectId) },
|
||||
|
||||
@@ -12,7 +12,10 @@
|
||||
*
|
||||
* 입력 — 노선 = 편집기 다리(`RouteDraftBridge` · 캐시 칸 route-draft) · 계획고 = 종단도 편집
|
||||
* (`pipelineGrade` · 노선 초안 동안은 alignment-draft 노선 `DRAFT_ROUTE_ID`) · 횡단 = 저장 상세의 측점
|
||||
* 입력(`design_pipeline_cross.ts`) · 관 = 가게 초안 → 저장 관 목록(좌표 · id) · 구조물 = 가게 초안 → 저장분.
|
||||
* 입력 + 세션 고침(`design_pipeline_cross.ts` · 부를 때마다 모음 · 횡단 고침 뒤 `pipelineCross` · 365-2) ·
|
||||
* 관 = 가게 초안 → 저장 관 목록(좌표 · id) · 구조물 = 가게 초안 → 저장분.
|
||||
* 고리(365-2) — 열 때 · 페이지 들어올 때(`reconcilePagePipeline`) 입력(세션 · 가게)을 묶음이 쥔 입력과 견줌 →
|
||||
* 다르면 한 바퀴 → 끝난 뒤 다시 견줌 · 최대 `RECONCILE_ROUNDS` · 넘으면 `onUnstable`(「계산 불안정」 한 줄).
|
||||
* 출력 — 가게 stations · profile · footprint · generation · 노선 초안이 있으면 종단도가 새 첫 계획선 ·
|
||||
* 옮긴 계획고 · 새 노선 상세(`pipelineDetail` — 지반 · 측점 · 횡단 면적 → 유토곡선)를 그림.
|
||||
* 첫 계산 = 열 때 한 번(저장 노선 · 발자국 · 저장 안 한 노선 초안이 있으면 그 노선).
|
||||
@@ -22,15 +25,19 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import { createDesignPipeline, publishToStore, type DesignPipeline } from "./logic/design_pipeline";
|
||||
import { SOURCE_INPUT } from "./logic/design_pipeline_types";
|
||||
export { pipelineWork } from "./logic/design_pipeline";
|
||||
import { pipelineCrossSource, type PipelineCrossSource } from "./design_pipeline_cross";
|
||||
import { createStationsStage } from "./logic/design_pipeline_stations";
|
||||
import { createStationsStage, stationDiff } from "./logic/design_pipeline_stations";
|
||||
import type {
|
||||
ChangeSource,
|
||||
CrossOutput,
|
||||
FollowPatch,
|
||||
PipelineInputs,
|
||||
PipelineOutputs,
|
||||
PipelineRunRecord,
|
||||
PipelineStation,
|
||||
RangePart,
|
||||
RouteDraft,
|
||||
StageFn,
|
||||
Vertex,
|
||||
@@ -102,6 +109,8 @@ import type {
|
||||
|
||||
/** 노선 초안 동안의 계획고 초안 칸 — alignment-draft 의 노선 0(노선 번호는 [저장] 때 남). */
|
||||
export const DRAFT_ROUTE_ID = 0;
|
||||
/** 입력 ↔ 묶음 입력 맞춤 상한(바퀴) — 넘으면 「계산 불안정」(365-2). */
|
||||
export const RECONCILE_ROUNDS = 3;
|
||||
|
||||
/** 종단도(`design_profile`)가 묶음에 내주는 것. */
|
||||
export interface ProfileLink {
|
||||
@@ -137,6 +146,15 @@ export interface PagePipelineOptions {
|
||||
routeId: number,
|
||||
saved: SectionDetailResponse | null,
|
||||
) => (stations: PipelineStation[]) => Promise<string>;
|
||||
/** 고리가 `RECONCILE_ROUNDS` 바퀴 안에 안 맞음 — 「계산 불안정」 한 줄(365-2). */
|
||||
onUnstable?: () => void;
|
||||
/** 저장 노선 cross = 다시 셈 창구 한 벌(365-4) — 상세 설계를 고치고 그 값을 냄 · 측점이 상세와 다르면 null
|
||||
* (묶음 엔진). 주면 종단도가 계획고 편집 뒤 따로 다시 셈 안 함(`pipelineOwnsCross`). */
|
||||
savedCross?: (
|
||||
detail: SectionDetailResponse,
|
||||
routeId: number,
|
||||
stations: ReadonlyArray<PipelineStation>,
|
||||
) => Promise<CrossOutput | null>;
|
||||
}
|
||||
|
||||
interface PagePipeline {
|
||||
@@ -145,6 +163,9 @@ interface PagePipeline {
|
||||
grade(edits: AlignmentEdits): void;
|
||||
restart(): void;
|
||||
earthwork(context: MassHaulStageContext | null): void;
|
||||
cross(): void;
|
||||
reconcile(): Promise<number>;
|
||||
ownsCross: boolean;
|
||||
detail(): SectionDetailResponse | null;
|
||||
dispose(): void;
|
||||
}
|
||||
@@ -155,22 +176,6 @@ type Facilities = Pick<DesignStoreValues, "pipes" | "structures">;
|
||||
const pages = new Map<string, PagePipeline>();
|
||||
const same = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b);
|
||||
|
||||
/** 상세 측점 대 묶음 측점(1 mm) — 같으면 null · 다르면 첫 다른 자리 · 개수(묶음 기록 `restoreDiff`). */
|
||||
function stationDiff(
|
||||
detail: SectionDetailResponse,
|
||||
stations: ReadonlyArray<PipelineStation>,
|
||||
): NonNullable<PipelineRunRecord["restoreDiff"]> | null {
|
||||
const saved = detail.cross_sections.map((section) => section.chainage_m);
|
||||
const n = Math.max(saved.length, stations.length);
|
||||
for (let i = 0; i < n; i += 1) {
|
||||
const pipelineM = stations[i]?.chainageM ?? null;
|
||||
if (saved[i] !== undefined && pipelineM !== null && Math.abs(saved[i] - pipelineM) < 1e-3)
|
||||
continue;
|
||||
return { savedM: saved[i] ?? null, pipelineM, saved: saved.length, pipeline: stations.length };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 편집기에 넘길 다리 — 그 프로젝트 묶음을 처음 부를 때 세움. */
|
||||
export function routeDraftBridge(
|
||||
projectId: string,
|
||||
@@ -193,6 +198,17 @@ export const restartPagePipeline = (projectId: string): void => pages.get(projec
|
||||
export const pipelineEarthwork = (projectId: string, context: MassHaulStageContext | null): void =>
|
||||
pages.get(projectId)?.earthwork(context);
|
||||
|
||||
/** 횡단 고침(지반 유형 · 측구 · 암 · 소단 · 표준 — B06 · 365-2) — 세션 횡단 입력을 다시 모아 cross 부터. */
|
||||
export const pipelineCross = (projectId: string): void => pages.get(projectId)?.cross();
|
||||
|
||||
/** 저장 노선 횡단 다시 셈을 묶음 cross 가 맡는가(365-4) — 종단도는 계획고 편집 뒤 따로 안 셈. */
|
||||
export const pipelineOwnsCross = (projectId: string): boolean =>
|
||||
pages.get(projectId)?.ownsCross ?? false;
|
||||
|
||||
/** 입력(세션 · 가게) 대 묶음 입력을 견줘 맞춤(365-2) — 돈 바퀴 수(0 = 같음 · -1 = 넘음 · 묶음 없음 = 0). */
|
||||
export const reconcilePagePipeline = (projectId: string): Promise<number> =>
|
||||
pages.get(projectId)?.reconcile() ?? Promise.resolve(0);
|
||||
|
||||
/** 노선 초안 동안 종단도가 그릴 새 노선 상세 — 초안이 없으면 null(저장 상세). */
|
||||
export const pipelineDetail = (projectId: string): SectionDetailResponse | null =>
|
||||
pages.get(projectId)?.detail() ?? null;
|
||||
@@ -219,10 +235,9 @@ function createPagePipeline(
|
||||
let savedStructures: PlanStructure[] = [];
|
||||
let savedPipes: PlanPipe[] = [];
|
||||
let earthwork: MassHaulStageContext | null = null;
|
||||
/** 저장 노선 측점(열쇠) · 그것으로 모은 횡단 입력 — 저장 상세가 바뀌면 다시. */
|
||||
/** 저장 노선 측점(열쇠) · 마지막으로 모은 횡단 입력(시설 제원 — 새 노선 상세가 씀). */
|
||||
let savedStations: PipelineStation[] = [];
|
||||
let crossSource: { detail: SectionDetailResponse; source: PipelineCrossSource | null } | null =
|
||||
null;
|
||||
let crossSource: PipelineCrossSource | null = null;
|
||||
/** 저장 정본(서버가 준 상세 · 노선 번호) — 측점 갈아끼움(swap)이 만든 새 상세는 지반 표본 배열을
|
||||
* 그대로 둠 → 정본 아님(묶음 출력 → 상세 → 다시 묶음 고리를 끊음). */
|
||||
let canon: { routeId: unknown; samples: unknown; detail: SectionDetailResponse | null } | null =
|
||||
@@ -286,12 +301,17 @@ function createPagePipeline(
|
||||
emptyEdits()
|
||||
);
|
||||
};
|
||||
/** 횡단 입력 — 부를 때마다 저장 상세 + 세션 고침 · 표준을 모음(상세 객체마다 기억하면 고침이 안 감 · 365-2). */
|
||||
const crossNow = (): PipelineCrossSource | null => {
|
||||
const detail = canon?.detail ?? null;
|
||||
if (!detail) return null;
|
||||
if (crossSource?.detail !== detail || !crossSource.source)
|
||||
crossSource = { detail, source: pipelineCrossSource(projectId, detail, savedStations) };
|
||||
return crossSource.source;
|
||||
const routeId = canon?.routeId;
|
||||
const source = pipelineCrossSource(
|
||||
projectId,
|
||||
canon?.detail ?? null,
|
||||
savedStations,
|
||||
typeof routeId === "number" ? routeId : null,
|
||||
);
|
||||
if (source) crossSource = source;
|
||||
return source;
|
||||
};
|
||||
const groundDeps = groundStageDeps(projectId);
|
||||
const crossStage = createCrossStage({ grid: groundDeps.grid }); // 미교차 넓힘 = 같은 지형 격자(349-0j)
|
||||
@@ -340,18 +360,27 @@ function createPagePipeline(
|
||||
ground: createGroundStage(groundDeps),
|
||||
planline: createPlanlineStage({ savedBase: () => savedSource().base, savedLine }),
|
||||
profile: profileStage,
|
||||
// 단면 유형 기본값 = 새 지형 상단측(서버 노선 저장 규칙) — 단계 본문은 그대로 부름
|
||||
cross: (ctx) =>
|
||||
crossStage.stage({
|
||||
...ctx,
|
||||
inputs: {
|
||||
...ctx.inputs,
|
||||
cross: crossWithUphill(
|
||||
ctx.inputs.cross as CrossInput | null,
|
||||
ctx.out.ground?.stations ?? [],
|
||||
),
|
||||
},
|
||||
}),
|
||||
// 단면 유형 기본값 = 새 지형 상단측(서버 노선 저장 규칙) — 단계 본문은 그대로 부름 · 저장 노선 = 다시 셈
|
||||
// 창구 한 벌(카드 · 유토곡선이 보는 상세 설계 · 365-4)
|
||||
cross: (ctx) => {
|
||||
const engine = () =>
|
||||
crossStage.stage({
|
||||
...ctx,
|
||||
inputs: {
|
||||
...ctx.inputs,
|
||||
cross: crossWithUphill(
|
||||
ctx.inputs.cross as CrossInput | null,
|
||||
ctx.out.ground?.stations ?? [],
|
||||
),
|
||||
},
|
||||
});
|
||||
const { detail = null, routeId } = savedSource();
|
||||
const saved =
|
||||
!ctx.inputs.route && detail && typeof routeId === "number"
|
||||
? options.savedCross?.(detail, routeId, ctx.out.ground?.stations ?? [])
|
||||
: null;
|
||||
return saved ? saved.then((out) => out ?? engine()) : engine();
|
||||
},
|
||||
footprint: footprintStage,
|
||||
masshaul: createMassHaulStage(() => earthwork),
|
||||
haul: (ctx) => (ctx.inputs.route ? haulStage(ctx) : (ctx.prev ?? null)),
|
||||
@@ -460,7 +489,7 @@ function createPagePipeline(
|
||||
ground,
|
||||
profile,
|
||||
pipe.output("cross"),
|
||||
crossSource?.source?.specs ?? new Map(),
|
||||
crossSource?.specs ?? new Map(),
|
||||
options.stationIntervalM ?? profile.policy.station_interval_m,
|
||||
)
|
||||
: null;
|
||||
@@ -513,12 +542,52 @@ function createPagePipeline(
|
||||
if (disposed || !ready || !savedSource().base) return;
|
||||
canonChanged();
|
||||
started = true;
|
||||
held = null;
|
||||
const inputs = inputsNow();
|
||||
// 초안 둔 채 열기(새로고침 · B06 바로 열기 · 365-1) — 초안 전 = 지금 저장 상세(세션 그대로) · 측점은 가게에 냄
|
||||
held = inputs.route ? (held ?? holdNow()) : null;
|
||||
dropping = null;
|
||||
void pipe.start(inputsNow());
|
||||
openGeneration = quietGeneration = pipe.generation;
|
||||
Object.assign(given, inputs);
|
||||
const run = (lastRun = pipe.start(inputs));
|
||||
openGeneration = pipe.generation;
|
||||
quietGeneration = inputs.route ? -1 : pipe.generation;
|
||||
void run.then(() => reconcile()); // 열 때 셈하는 사이 바뀐 입력(세션 · 가게)도 맞춤
|
||||
};
|
||||
|
||||
/** 묶음에 넘긴 입력(follow 가 고치기 전) · 마지막으로 띄운 차례 — 고리가 견줌 · 기다림. */
|
||||
const given: Partial<PipelineInputs> = {};
|
||||
let lastRun: Promise<unknown> = Promise.resolve();
|
||||
const feed = (source: ChangeSource, value: unknown, part: RangePart | null = null) => {
|
||||
given[SOURCE_INPUT[source]] = value as never;
|
||||
const run = pipe.update(source, value, part);
|
||||
if (run) lastRun = run;
|
||||
return run;
|
||||
};
|
||||
let reconciling: Promise<number> | null = null;
|
||||
/** 입력 지문 대 묶음 입력 — 넘긴 값 · follow 뒤 값 둘 다와 다르면 한 바퀴(노선 = 다리 길) · 끝난 뒤 다시 견줌. */
|
||||
function reconcile(): Promise<number> {
|
||||
if (!started || disposed) return Promise.resolve(0);
|
||||
return (reconciling ??= (async () => {
|
||||
for (let round = 0; ; round += 1) {
|
||||
let seen: Promise<unknown> | null = null;
|
||||
while (seen !== lastRun) await (seen = lastRun); // 기다리는 사이 뜬 차례까지
|
||||
await pipe.settled();
|
||||
if (disposed || canon?.routeId !== savedSource().routeId) return round; // 정본 바뀜 = restart 몫
|
||||
const now = inputsNow();
|
||||
const stale = (Object.entries(SOURCE_INPUT) as Array<[ChangeSource, keyof PipelineInputs]>)
|
||||
.filter(([, key]) => !same(now[key], pipe.input(key)) && !same(now[key], given[key]))
|
||||
.map(([source, key]): [ChangeSource, unknown] => [source, now[key]]);
|
||||
if (!stale.length) return round;
|
||||
if (round >= RECONCILE_ROUNDS) {
|
||||
options.onUnstable?.();
|
||||
return -1;
|
||||
}
|
||||
for (const [source, value] of stale)
|
||||
if (source === "route") bridge.edited(value as RouteDraft | null);
|
||||
else feed(source, value);
|
||||
}
|
||||
})().finally(() => (reconciling = null)));
|
||||
}
|
||||
|
||||
/** 저장 노선(그린 선 · 곡선표 · 기본 반경) · 그 측점 열쇠 — [저장] 뒤 다시 받음(`restart`). */
|
||||
const loadPlan = (): Promise<void> =>
|
||||
fetchRoutePlan(projectId)
|
||||
@@ -552,13 +621,10 @@ function createPagePipeline(
|
||||
.catch(() => undefined);
|
||||
|
||||
const unsubscribe = [
|
||||
store.subscribe(
|
||||
"pipes",
|
||||
(pipes) => started && !echo && void pipe.update("pipe", pipes ?? savedPipes),
|
||||
),
|
||||
store.subscribe("pipes", (pipes) => started && !echo && void feed("pipe", pipes ?? savedPipes)),
|
||||
store.subscribe(
|
||||
"structures",
|
||||
(list) => started && !echo && void pipe.update("structure", list ?? savedStructures),
|
||||
(list) => started && !echo && void feed("structure", list ?? savedStructures),
|
||||
),
|
||||
];
|
||||
|
||||
@@ -579,7 +645,7 @@ function createPagePipeline(
|
||||
if (!started) return start();
|
||||
const before = pipe.output("route")?.line ?? null;
|
||||
const after = draft ? draftLine(draft, plan?.min_radius_m ?? 0).line : savedLine();
|
||||
const run = pipe.update("route", draft, routeRange(before, after));
|
||||
const run = feed("route", draft, routeRange(before, after));
|
||||
if (run && !draft && wasDraft) {
|
||||
quietGeneration = pipe.generation;
|
||||
dropping = run.then(finishDrop);
|
||||
@@ -595,7 +661,7 @@ function createPagePipeline(
|
||||
if (same(edits, pipe.input("alignment"))) return;
|
||||
const nodes = pipe.output("planline")?.basePvi.map((node) => node.chainage_m) ?? [];
|
||||
const before = pipe.input("alignment") as AlignmentEdits | null;
|
||||
void pipe.update("grade", edits, gradeRange(before, edits, nodes));
|
||||
void feed("grade", edits, gradeRange(before, edits, nodes));
|
||||
},
|
||||
restart() {
|
||||
if (!started) return start();
|
||||
@@ -604,8 +670,14 @@ function createPagePipeline(
|
||||
earthwork(context) {
|
||||
earthwork = context;
|
||||
const cross = crossNow()?.input ?? null;
|
||||
if (started && cross) void pipe.update("cross", cross);
|
||||
if (started && cross) void feed("cross", cross);
|
||||
},
|
||||
cross() {
|
||||
const cross = started ? (crossNow()?.input ?? null) : null;
|
||||
if (cross) void feed("cross", cross);
|
||||
},
|
||||
reconcile,
|
||||
ownsCross: Boolean(options.savedCross),
|
||||
detail: () => shown,
|
||||
dispose() {
|
||||
disposed = true;
|
||||
|
||||
@@ -324,9 +324,9 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
||||
refresh: () => renderBalance(),
|
||||
});
|
||||
|
||||
/** 편집을 적용한다. 법정 위반 정책이 block이면 새 위반이 생기는 편집을 막는다. */
|
||||
function applyEdits(next: AlignmentEdits): void {
|
||||
if (!base || !alignment) return;
|
||||
/** 편집을 적용한다. 법정 위반 정책이 block이면 새 위반이 생기는 편집을 막는다. false = 막힘. */
|
||||
function applyEdits(next: AlignmentEdits): boolean {
|
||||
if (!base || !alignment) return false;
|
||||
const candidate = buildAlignment(base, next);
|
||||
if (
|
||||
base.policy.grade_violation_policy === "block" &&
|
||||
@@ -336,12 +336,13 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
||||
`종단기울기 상한 ${base.policy.max_grade_pct.toFixed(1)}%를 넘어 편집을 적용하지 않았습니다.`,
|
||||
"error",
|
||||
);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
// 최소고 가드 — 편집 경로가 전부 여기로 모인다(2026-09-02 직선화·쉬프트·틸팅·방향키 누락 수정).
|
||||
if (blocksMinCover(base, alignment, candidate, enforceMinCover, minCoverTargets)) return;
|
||||
if (blocksMinCover(base, alignment, candidate, enforceMinCover, minCoverTargets)) return false;
|
||||
store.replace(next);
|
||||
history.record();
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 계획선 측점 줄 = 측점 한 벌 — 셈은 `design_profile_base` 몫(계산 묶음 planline 도 같은 기준). */
|
||||
@@ -353,8 +354,8 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
||||
pagePipeline.pipelineGrade(projectId, store.edits());
|
||||
// 계획고가 바뀌면 측점별 횡단 단면적도 함께 바뀐다 — 전 측점을 다시 계산해 공유
|
||||
// 캐시에 얹는다. 그래야 **횡단 기준** 유토곡선과 횡단 카드가 따라 움직인다
|
||||
// (2026-08-03 사용자 보고: B05에서 계획선을 끌어도 횡단 곡선이 그대로였음).
|
||||
scheduleCrossPreview();
|
||||
// (2026-08-03 사용자 보고: B05에서 계획선을 끌어도 횡단 곡선이 그대로였음). 묶음이 맡으면 그 cross 한 벌(365-4).
|
||||
if (!pagePipeline.pipelineOwnsCross(projectId)) scheduleCrossPreview();
|
||||
drawSoon();
|
||||
}
|
||||
/** 다음 틀에 한 번 그림 — 같은 차례 알림 · 갈아끼움 · 설정이 여러 번 불러도 한 번(349-0s). */
|
||||
|
||||
@@ -59,8 +59,6 @@ export function createProfileOverlays(deps: ProfileOverlaysDeps) {
|
||||
const column = document.createElement("div");
|
||||
column.className = "b05-route-profile__column";
|
||||
column.append(bodyWrap, massHaul.overlay);
|
||||
// 판의 가로 스크롤을 종단 스크롤러와 양방향 동기화 — 측점 세로선 정렬 유지.
|
||||
massHaul.attachScrollSync(body);
|
||||
// 테이블은 종단 칸 바닥 겹침 판(2026-08-05 사용자 지시).
|
||||
const tableOverlay = createProfileTableOverlay(onChange);
|
||||
bodyWrap.append(tableOverlay.overlay, tableOverlay.handle);
|
||||
|
||||
@@ -29,6 +29,19 @@ import { saveDesign, type DesignSaveStep } from "./design_save";
|
||||
import { markDesignClean } from "./design_leave";
|
||||
import { resetDesign } from "./design_reset";
|
||||
import type { DesignView } from "./design_dock";
|
||||
import { routeDraftBridge, type PagePipelineOptions } from "./design_pipeline_page";
|
||||
import { SECTION_STATION_INTERVAL_M } from "../../B05_Profile/logic/B05_Profile_Sections_Stations";
|
||||
import {
|
||||
applyStructureAreaRows,
|
||||
structureAreaRows,
|
||||
} from "../../B06_Section/B06_Section_Structure_Layouts";
|
||||
import {
|
||||
holdSectionDetail,
|
||||
restoreSectionDetail,
|
||||
swapSectionStations,
|
||||
} from "../../B06_Section/B06_Section_Section_Store";
|
||||
import { refreshCrossDesigns } from "../../B06_Section/B06_Section_Cross_Refresh";
|
||||
import { detailCross } from "../../B05_Profile/logic/B05_Profile_Pipeline_Detail";
|
||||
|
||||
function L(key: keyof typeof ui_locales): string {
|
||||
return ui_locales[key][currentLanguageIndex];
|
||||
@@ -62,6 +75,8 @@ export interface DesignViewBinding {
|
||||
onReset?: () => void;
|
||||
/** 다른 보기가 고친 관 옵션을 이 보기 캐시(횡단 스펙)에 얹는다. */
|
||||
onPipeOptions?: (chainageM: number, patch: Record<string, number | string>) => void;
|
||||
/** 계산 묶음 알림 — 묶음은 페이지가 세움(365-1) · 보기는 알림만 받음. */
|
||||
pipeline?: Pick<PagePipelineOptions, "onCorridorStale" | "onFacilitiesFollowed">;
|
||||
}
|
||||
|
||||
/** 한 벌 자리 — 페이지(`design_page`)가 만들어 두 보기에 같은 객체로 넘긴다. */
|
||||
@@ -182,9 +197,48 @@ export function sharedProfile(shared: DesignPageShared): DesignProfile {
|
||||
attachProfilePopout(profile, shared.profileSlot, popoutButton);
|
||||
attachSubPanelPopouts(profile);
|
||||
shared.profile = profile;
|
||||
if (shared.projectId) startPagePipeline(shared, profile);
|
||||
return profile;
|
||||
}
|
||||
|
||||
/** 계산 묶음 한 벌(365-1) — 종단도와 함께 페이지가 세움 · B05 · B06 어느 쪽으로 들어와도(새로고침도) 노선
|
||||
* 초안을 셈. 편집기는 `routeDraftBridge` 로 이 다리를 받아 씀 · 보기 몫 알림은 `pipeline` 바인딩으로. */
|
||||
function startPagePipeline(shared: DesignPageShared, profile: DesignProfile): void {
|
||||
const projectId = shared.projectId;
|
||||
const each = (fn: (hooks: NonNullable<DesignViewBinding["pipeline"]>) => void): void =>
|
||||
Object.values(shared.bindings).forEach((binding) => binding?.pipeline && fn(binding.pipeline));
|
||||
routeDraftBridge(projectId, profile, {
|
||||
stationIntervalM: SECTION_STATION_INTERVAL_M,
|
||||
onCorridorStale: () => each((hooks) => hooks.onCorridorStale?.()),
|
||||
onFacilitiesFollowed: (far) => each((hooks) => hooks.onFacilitiesFollowed?.(far)),
|
||||
onUnstable: () => showToast(L("Design_Pipeline_Unstable"), "warning"),
|
||||
// 저장 노선 횡단 = 다시 셈 창구 한 벌(365-4) — 카드 · 유토곡선 · [저장] 패치가 보는 상세 설계를 고치고 그 값을
|
||||
// 묶음 출력으로(발자국 · 평면 횡단 판) · 측점이 상세와 다르면(갈아끼움 전) 묶음 엔진
|
||||
savedCross: async (detail, routeId, stations) => {
|
||||
if (!detailCross(detail, stations)) return null;
|
||||
const edits = profile.pipelineSource().edits;
|
||||
const updated = await refreshCrossDesigns({ projectId, routeId, detail, edits });
|
||||
if (updated.length) toAll(shared, "onCrossDesignsUpdated")(updated);
|
||||
profile.redraw();
|
||||
return detailCross(detail, stations);
|
||||
},
|
||||
structureAreas: ({ cross_sections: rows }) =>
|
||||
applyStructureAreaRows(rows, structureAreaRows(rows)),
|
||||
// 초안 시작 — 초안 전 상태를 떠 둠 · 버리면 그대로(측점이 달라도 · 349-0o) · 상세가 없으면 묶음 측점으로 갈아끼움
|
||||
holdDraft: (routeId, saved) => {
|
||||
const hold = saved && holdSectionDetail(projectId, routeId, saved);
|
||||
return async (stations) => {
|
||||
if (!hold) {
|
||||
await swapSectionStations(projectId, routeId, stations);
|
||||
return "no-saved";
|
||||
}
|
||||
const back = await restoreSectionDetail(projectId, routeId, hold, stations);
|
||||
return !back ? "no-cache" : back.same ? "saved" : "saved-diff";
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 「구조물 배치」 폼 한 벌 — 알림은 지금 보기로, 상세 옵션은 횡단 보기에서만 그린다. */
|
||||
export function sharedStructures(shared: DesignPageShared): StructuresSection {
|
||||
if (shared.structures) return shared.structures;
|
||||
|
||||
@@ -225,11 +225,11 @@ export async function mountCrossView(
|
||||
const seq = ++structuresRefreshSeq; // 잇단 조작 — 최신 요청만 그림(54-4)
|
||||
if (savingDesign(projectId)) return; // 저장 도중 = 낡은 캐시 · 빈 초안 — 저장 뒤 한 번(323-9)
|
||||
try {
|
||||
await new Promise((next) => setTimeout(next, 0)); // 겹친 신호(갈아끼움 · 묶음 · 초안) = 한 번(365-4)
|
||||
const fresh = await loadSectionDetail(projectId, currentRouteId);
|
||||
if (seq !== structuresRefreshSeq) return;
|
||||
sectionDetail = fresh;
|
||||
// 면적을 **먼저** 다시 얹는다 — 유토곡선은 카드보다 앞서 계산되므로, 카드 렌더가
|
||||
// 고치는 것만으로는 곡선이 옛 면적으로 남는다(2026-09-06 실측).
|
||||
// 면적을 **먼저** 다시 얹는다 — 유토곡선은 카드보다 앞서 셈(카드만 고치면 곡선이 옛 면적 · 2026-09-06 실측).
|
||||
if (sectionDetail) {
|
||||
applyStructureAreaRows(
|
||||
sectionDetail.cross_sections,
|
||||
|
||||
@@ -48,16 +48,7 @@ import {
|
||||
rememberStandardDefaults,
|
||||
} from "../../B06_Section/B06_Section_UI_Standard_Panel";
|
||||
import { fetchSectionContext } from "../../B06_Section/B06_Section_Api_Fetch";
|
||||
import {
|
||||
applyStructureAreaRows,
|
||||
structureAreaRows,
|
||||
} from "../../B06_Section/B06_Section_Structure_Layouts";
|
||||
import {
|
||||
holdSectionDetail,
|
||||
invalidateSectionDetail,
|
||||
restoreSectionDetail,
|
||||
swapSectionStations,
|
||||
} from "../../B06_Section/B06_Section_Section_Store";
|
||||
import { invalidateSectionDetail } from "../../B06_Section/B06_Section_Section_Store";
|
||||
import {
|
||||
onStaleStructureTypes,
|
||||
structureAnchorM,
|
||||
@@ -419,6 +410,11 @@ export async function mountProfileView(
|
||||
bindDesignView(shared, "profile", {
|
||||
save: profileSaveParts(actionContext),
|
||||
onReset: () => uphillOverrides.clear(),
|
||||
// 계산 묶음(페이지 몫 · 365-1) 알림 중 종단 보기 몫 — 3D 「낡음」 · 관 · 구조물 따라감
|
||||
pipeline: {
|
||||
onCorridorStale: () => sections.setCorridorPending(true),
|
||||
onFacilitiesFollowed: followFacilities,
|
||||
},
|
||||
});
|
||||
|
||||
// 탭이 다시 보일 때 정의 판이 바뀌었으면 종류 · 패널 다시 읽음(322-0)
|
||||
@@ -539,27 +535,8 @@ export async function mountProfileView(
|
||||
smooth: latestResponse.surface_params.smooth,
|
||||
tools: [view3d.modal.button],
|
||||
recommend: panel.recommend,
|
||||
// 계산 묶음(346-1) — 노선을 고칠 때마다 종단이 따라옴(미리보기 · [저장]이 정본).
|
||||
draft: routeDraftBridge(activeProjectId, profilePanel, {
|
||||
stationIntervalM: panel.values().stationInterval ?? undefined,
|
||||
onCorridorStale: () => sections.setCorridorPending(true),
|
||||
onFacilitiesFollowed: followFacilities,
|
||||
structureAreas: ({ cross_sections: rows }) =>
|
||||
applyStructureAreaRows(rows, structureAreaRows(rows)),
|
||||
// 초안 시작 — 초안 전 상태를 떠 둠 · 버리면 그대로(측점이 달라도 · 349-0o) · 상세가 없으면(초안 둔 채
|
||||
// 새로고침) 묶음 측점으로 갈아끼움
|
||||
holdDraft: (routeId, saved) => {
|
||||
const hold = saved && holdSectionDetail(activeProjectId, routeId, saved);
|
||||
return async (stations) => {
|
||||
if (!hold) {
|
||||
await swapSectionStations(activeProjectId, routeId, stations);
|
||||
return "no-saved";
|
||||
}
|
||||
const back = await restoreSectionDetail(activeProjectId, routeId, hold, stations);
|
||||
return !back ? "no-cache" : back.same ? "saved" : "saved-diff";
|
||||
};
|
||||
},
|
||||
}),
|
||||
// 계산 묶음(346-1) — 페이지가 세운 다리(365-1) · 노선을 고칠 때마다 종단이 따라옴(미리보기 · [저장]이 정본).
|
||||
draft: routeDraftBridge(activeProjectId, profilePanel),
|
||||
});
|
||||
|
||||
// ③ 종단면·횡단 자료 — 하단 패널을 3D보다 먼저 채운다.
|
||||
|
||||
@@ -14,7 +14,13 @@ import type {
|
||||
PlanStructure,
|
||||
PlanType,
|
||||
} from "../../../common_util/common_util_structure_station";
|
||||
import type { PipelineStation, StageContext, StageFn } from "./design_pipeline_types";
|
||||
import type {
|
||||
PipelineRunRecord,
|
||||
PipelineStation,
|
||||
StageContext,
|
||||
StageFn,
|
||||
} from "./design_pipeline_types";
|
||||
import type { SectionDetailResponse } from "../../../B06_Section/B06_Section_Api_Fetch";
|
||||
|
||||
/** 범위 밖 측점은 앞 값(같은 열쇠 · 같은 자리)을 그대로 — 뒤 단계가 같은 객체로 「안 바뀜」을 앎. */
|
||||
export function reuseOutside(
|
||||
@@ -54,3 +60,19 @@ export function createStationsStage(
|
||||
return reuseOutside(next, ctx.prev, ctx.range);
|
||||
};
|
||||
}
|
||||
|
||||
/** 상세 측점 대 묶음 측점(1 mm) — 같으면 null · 다르면 첫 다른 자리 · 개수(묶음 기록 `restoreDiff`). */
|
||||
export function stationDiff(
|
||||
detail: SectionDetailResponse,
|
||||
stations: ReadonlyArray<PipelineStation>,
|
||||
): NonNullable<PipelineRunRecord["restoreDiff"]> | null {
|
||||
const saved = detail.cross_sections.map((section) => section.chainage_m);
|
||||
const n = Math.max(saved.length, stations.length);
|
||||
for (let i = 0; i < n; i += 1) {
|
||||
const pipelineM = stations[i]?.chainageM ?? null;
|
||||
if (saved[i] !== undefined && pipelineM !== null && Math.abs(saved[i] - pipelineM) < 1e-3)
|
||||
continue;
|
||||
return { savedM: saved[i] ?? null, pipelineM, saved: saved.length, pipeline: stations.length };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
*
|
||||
* 흐름: 세로 측점선 둘로 범위를 고름 → 시작(또는 끝) 세로선 근처 클릭 = 그 측점 계획고에 이은
|
||||
* 채 시작 → 클릭마다 점 하나(가로 = 범위 안 가장 가까운 측점선 · 높이 = 클릭한 높이) → 반대쪽
|
||||
* 세로선 근처 클릭 또는 Enter = 그 측점 계획고에 이어 끝. Esc = 취소 · Backspace = 마지막 점 빼기.
|
||||
* 세로선 근처 클릭 또는 Enter = 그 측점 계획고에 이어 끝. Esc = 취소 · ↶ ↷(Backspace = ↶) =
|
||||
* 자유지정 안 한 걸음씩(범위 · 시작 · 점 · 367-3) — 큰 이력과 따로 · 끝나면 큰 이력 한 걸음.
|
||||
*
|
||||
* 적용은 직선화와 같은 길 — 편집 델타(`station_offsets` · `curve_radii`)만 만들어 캐시에 쓰고
|
||||
* [저장] 때 서버가 다시 셈한다. 꺾이는 점 종단곡선 = 직선화 틸팅과 같은 기본 길이 L
|
||||
@@ -181,8 +182,8 @@ export interface FreeDrawDeps {
|
||||
base: () => AlignmentBase | null;
|
||||
alignment: () => ProfileAlignment | null;
|
||||
edits: () => AlignmentEdits;
|
||||
/** 편집 적용 — 직선화 결과와 같은 길(캐시 · 되돌리기 · 막기 규칙). */
|
||||
apply: (next: AlignmentEdits) => void;
|
||||
/** 편집 적용 — 직선화 결과와 같은 길(캐시 · 되돌리기 · 막기 규칙). false = 막힘(열린 채). */
|
||||
apply: (next: AlignmentEdits) => boolean | void;
|
||||
/** 모드를 끈다(적용 뒤 · Esc). */
|
||||
close: () => void;
|
||||
/** 단계 · 안내가 바뀜. */
|
||||
@@ -198,6 +199,11 @@ export interface FreeDraw {
|
||||
hover: (pointer: { chainageM: number; elevationM: number; stations: number[] } | null) => void;
|
||||
/** Enter · Escape · Backspace — 먹었으면 true. */
|
||||
key: (key: string) => boolean;
|
||||
/** 자유지정 안 한 걸음 되돌림 · 다시함(367-3) — 큰 이력과 따로. */
|
||||
undo: () => void;
|
||||
redo: () => void;
|
||||
canUndo: () => boolean;
|
||||
canRedo: () => boolean;
|
||||
marks: () => FreeMarks;
|
||||
hint: () => string;
|
||||
}
|
||||
@@ -209,6 +215,25 @@ export function createFreeDraw(deps: FreeDrawDeps): FreeDraw {
|
||||
let anchor: number | null = null;
|
||||
let points: FreeVertex[] = [];
|
||||
let target: FreeVertex | null = null;
|
||||
/** 자유지정 안 이력(367-3) — 상태 바뀐 클릭만 쌓음 · 새 클릭이 future 비움. */
|
||||
interface Snap {
|
||||
phase: FreePhase;
|
||||
pending: number | null;
|
||||
range: [number, number] | null;
|
||||
anchor: number | null;
|
||||
points: FreeVertex[];
|
||||
}
|
||||
let past: Snap[] = [];
|
||||
let future: Snap[] = [];
|
||||
const snap = (): Snap => ({ phase, pending, range, anchor, points: [...points] });
|
||||
function step(from: Snap[], to: Snap[]): void {
|
||||
const state = from.pop();
|
||||
if (!state) return;
|
||||
to.push(snap());
|
||||
({ phase, pending, range, anchor, points } = state);
|
||||
target = null;
|
||||
deps.changed();
|
||||
}
|
||||
|
||||
function reset(): void {
|
||||
phase = "range";
|
||||
@@ -217,6 +242,8 @@ export function createFreeDraw(deps: FreeDrawDeps): FreeDraw {
|
||||
anchor = null;
|
||||
points = [];
|
||||
target = null;
|
||||
past = [];
|
||||
future = [];
|
||||
}
|
||||
|
||||
/** 그 측점의 지금 계획고(변화점이면 라운드 중심 z — 직선화와 같은 값). */
|
||||
@@ -263,8 +290,9 @@ export function createFreeDraw(deps: FreeDrawDeps): FreeDraw {
|
||||
if (!base || end === null || z === null) return;
|
||||
const vertices = [...path(), { chainage_m: end, elevation_m: z }];
|
||||
const next = buildFreeEdits(base, deps.edits(), vertices);
|
||||
// 막히면(기울기 · 흙덮기) 자유지정 열린 채 — 그린 점 그대로(367-3)
|
||||
if (deps.apply(next) === false) return;
|
||||
reset();
|
||||
deps.apply(next);
|
||||
deps.close();
|
||||
}
|
||||
|
||||
@@ -272,6 +300,7 @@ export function createFreeDraw(deps: FreeDrawDeps): FreeDraw {
|
||||
phase: () => phase,
|
||||
reset,
|
||||
click(chainageM, elevationM, stations) {
|
||||
const before = snap();
|
||||
if (phase === "range") {
|
||||
const station = snapStation(stations, chainageM);
|
||||
if (station === null) return true;
|
||||
@@ -297,6 +326,10 @@ export function createFreeDraw(deps: FreeDrawDeps): FreeDraw {
|
||||
}
|
||||
points.push(next);
|
||||
}
|
||||
if (JSON.stringify(snap()) !== JSON.stringify(before)) {
|
||||
past.push(before);
|
||||
future = [];
|
||||
}
|
||||
target = null;
|
||||
deps.changed();
|
||||
return true;
|
||||
@@ -316,18 +349,16 @@ export function createFreeDraw(deps: FreeDrawDeps): FreeDraw {
|
||||
return true;
|
||||
}
|
||||
if (key === "Backspace") {
|
||||
if (phase !== "drawing") return false;
|
||||
if (points.length) points.pop();
|
||||
else {
|
||||
anchor = null;
|
||||
phase = "armed";
|
||||
}
|
||||
target = null;
|
||||
deps.changed();
|
||||
if (!past.length) return false;
|
||||
step(past, future);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
undo: () => step(past, future),
|
||||
redo: () => step(future, past),
|
||||
canUndo: () => past.length > 0,
|
||||
canRedo: () => future.length > 0,
|
||||
marks() {
|
||||
const maxPct = deps.base()?.policy.max_grade_pct ?? Number.POSITIVE_INFINITY;
|
||||
const dots = path();
|
||||
@@ -357,7 +388,7 @@ export function createFreeDraw(deps: FreeDrawDeps): FreeDraw {
|
||||
: `${pending.toFixed(1)}m 선택 — 범위 끝 세로 측점선을 고르세요`;
|
||||
}
|
||||
if (phase === "armed") return "시작(또는 끝) 세로선 근처를 눌러 시작하세요 (Esc 취소)";
|
||||
return "클릭마다 점 · 반대쪽 세로선 또는 Enter 로 끝 (Backspace 점 빼기 · Esc 취소)";
|
||||
return "클릭마다 점 · 반대쪽 세로선 또는 Enter 로 끝 (↶ · Backspace 되돌리기 · Esc 취소)";
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user