From 580026a28f7b23b57ba0c8cbe1e2f3cd987dd94d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 24 Jul 2026 16:50:11 +0900 Subject: [PATCH] 260723_4 --- .../B05_wf2_Route_UI_IrregularStations.ts | 20 +++++++ B05_wf2_Route/B05_wf2_Route_UI_Page.ts | 11 ++++ .../B05_wf2_Route_UI_Profile_Edit.ts | 53 +++++++++++++++---- .../B05_wf2_Route_UI_Profile_Panel.ts | 49 ++++++++++++++++- .../B05_wf2_Route_UI_Profile_Table.ts | 52 ++++++++++++------ B05_wf2_Route/B05_wf2_Route_UI_Style.css | 50 ++++++++++++++++- .../B06_wf3_ProfileCross_Api_Fetch.ts | 2 + .../B06_wf3_ProfileCross_UI_Longitudinal.ts | 9 ++++ main.py | 2 +- 9 files changed, 219 insertions(+), 29 deletions(-) diff --git a/B05_wf2_Route/B05_wf2_Route_UI_IrregularStations.ts b/B05_wf2_Route/B05_wf2_Route_UI_IrregularStations.ts index 14d39743..c62e35fc 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_IrregularStations.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_IrregularStations.ts @@ -27,6 +27,8 @@ export interface IrregularStationsSection { getStations: () => IrregularStation[]; /** chainage로 목록 항목을 골라 폼에 로드한다(그래프·3D에서 선택 시). null이면 선택 해제. */ selectByChainage: (chainageM: number | null) => void; + /** 외부(백엔드 복귀)에서 목록을 통째로 채운다(측점번호·잔여거리는 chainage로 역산). */ + setStations: (seed: Array<{ chainage_m: number; structure: string }>) => void; clear: () => void; } @@ -231,6 +233,24 @@ export function createIrregularStationsSection( const target = stations.find((entry) => Math.abs(entry.chainage_m - chainageM) < 1e-6); loadForm(target ?? null); }, + setStations(seed) { + const interval = intervalMax(); + stations.length = 0; + seed.forEach((entry) => { + const stationNo = Math.floor((entry.chainage_m + 1e-6) / interval); + const remainder = Number((entry.chainage_m - stationNo * interval).toFixed(3)); + stations.push({ + id: String(nextId++), + station: stationNo, + remainder, + chainage_m: entry.chainage_m, + structure: entry.structure, + }); + }); + loadForm(null); + renderList(); + callbacks.onChange([...stations]); + }, clear() { stations.length = 0; loadForm(null); diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Page.ts b/B05_wf2_Route/B05_wf2_Route_UI_Page.ts index cae712b8..db1bafca 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Page.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Page.ts @@ -316,6 +316,17 @@ export async function renderB05Route(root: HTMLElement): Promise { } try { renderSections(detail, routeId); + // 복귀/최초 진입 시(클라이언트 목록이 비어 있을 때만) 확정된 비정규 측점을 사이드바에 복원한다. + // 재탐색 시엔 새 경로 detail에 비정규 측점이 없어(빈 목록으로 덮이지 않게) 이 가드로 건너뛴다. + if (!irregularStations.length) { + const restored = detail.longitudinal.stations + .filter((station) => station.kind === "irregular") + .map((station) => ({ + chainage_m: station.chainage_m, + structure: station.structure ?? "", + })); + if (restored.length) panel.irregularStations.setStations(restored); + } } catch (error) { // 렌더 실패를 "데이터 없음"으로 감추면 원인을 알 수 없게 된다. 조회는 성공했으므로 // 빈 안내로 되돌리지 않고 실패 사실을 그대로 드러낸다. diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Edit.ts b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Edit.ts index dc84dbd7..655fb2b3 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Edit.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Edit.ts @@ -155,6 +155,8 @@ export interface EditOverlayOptions { width: number; x: (chainageM: number) => number; step: number; + /** 규칙 격자 밖 비정규 측점(구조물). 규칙 측점과 똑같이 ▲/▼(+원복) 버튼을 단다. */ + irregularStations?: Array<{ chainage_m: number }>; onStation: (chainageM: number, delta: number) => void; onSegment: (segment: AlignmentSegment, delta: number) => void; onResetStation: (chainageM: number) => void; @@ -191,16 +193,38 @@ function overlayButton( return button; } +/** 계획선 샘플에서 chainage 위치의 계획고를 선형보간한다(버튼 라벨용). */ +function planElevationAtSample(alignment: ProfileAlignment, chainageM: number): number { + const samples = alignment.samples; + if (!samples.length) return 0; + if (chainageM <= samples[0].chainage_m) return samples[0].elevation_m; + const last = samples[samples.length - 1]; + if (chainageM >= last.chainage_m) return last.elevation_m; + for (let index = 1; index < samples.length; index += 1) { + const current = samples[index]; + if (current.chainage_m < chainageM) continue; + const previous = samples[index - 1]; + const span = current.chainage_m - previous.chainage_m; + const ratio = span > 1e-9 ? (chainageM - previous.chainage_m) / span : 0; + return previous.elevation_m + (current.elevation_m - previous.elevation_m) * ratio; + } + return last.elevation_m; +} + /** 그래프 영역 위에 겹치는 편집 버튼 층을 만든다 (선 자체는 가리지 않는다). */ export function createEditOverlay(options: EditOverlayOptions): HTMLElement { const { alignment, width, x, step, onStation, onSegment, onResetStation } = options; + const irregular = options.irregularStations ?? []; const layer = document.createElement("div"); layer.className = "b05-profile-edit"; layer.style.width = `${width}px`; const repeater = createHoldRepeater(); const edited = new Set(Object.keys(alignment.edits.station_offsets)); - const stationXs = alignment.stations.map((station) => x(station.chainage_m)); + const stationXs = [ + ...alignment.stations.map((station) => x(station.chainage_m)), + ...irregular.map((station) => x(station.chainage_m)), + ]; /** * 구간 버튼을 측점 버튼과 같은 행에 두되, 겹치는 자리면 옆으로 비킨다. @@ -219,13 +243,15 @@ export function createEditOverlay(options: EditOverlayOptions): HTMLElement { } return best < BUTTON_CLEARANCE_PX ? nearest + BUTTON_CLEARANCE_PX : center; } - alignment.stations.forEach((station) => { - const left = x(station.chainage_m); - const isEdited = edited.has(chainageKey(station.chainage_m)); - const label = `${station.chainage_m.toFixed(1)}m 계획고 ${station.plan_elevation_m.toFixed(2)}m`; + // 측점 하나에 ▲/▼(+편집됐으면 원복 ↺) 버튼을 단다. 규칙·비정규 측점 공용 — 비정규 측점도 + // `onStation`이 임의 chainage를 변화점으로 승격시키므로 규칙 측점과 완전히 같은 파이프라인이다. + function addStationButtons(chainageM: number, planElevationM: number): void { + const left = x(chainageM); + const isEdited = edited.has(chainageKey(chainageM)); + const label = `${chainageM.toFixed(1)}m 계획고 ${planElevationM.toFixed(2)}m`; const up = overlayButton(repeater, "is-station is-up", "▲", `${label} — ${step}m 올림`, () => - onStation(station.chainage_m, step), + onStation(chainageM, step), ); up.style.left = `${left - BUTTON_HALF_PX}px`; const down = overlayButton( @@ -233,23 +259,30 @@ export function createEditOverlay(options: EditOverlayOptions): HTMLElement { "is-station is-down", "▼", `${label} — ${step}m 내림`, - () => onStation(station.chainage_m, -step), + () => onStation(chainageM, -step), ); down.style.left = `${left - BUTTON_HALF_PX}px`; layer.append(up, down); if (!isEdited) return; - const offset = alignment.edits.station_offsets[chainageKey(station.chainage_m)]; + const offset = alignment.edits.station_offsets[chainageKey(chainageM)]; const reset = overlayButton( repeater, "is-reset", "↺", `${label} — 자동 선형으로 원복 (현재 ${offset >= 0 ? "+" : ""}${offset.toFixed(2)}m)`, - () => onResetStation(station.chainage_m), + () => onResetStation(chainageM), ); reset.style.left = `${left - BUTTON_HALF_PX}px`; layer.append(reset); - }); + } + + alignment.stations.forEach((station) => + addStationButtons(station.chainage_m, station.plan_elevation_m), + ); + irregular.forEach((station) => + addStationButtons(station.chainage_m, planElevationAtSample(alignment, station.chainage_m)), + ); alignment.segments.forEach((segment) => { const left = x(segment.from_m); diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts index 4887e7ec..a295bcab 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts @@ -176,6 +176,33 @@ function irregularGraphStations(list: IrregularStation[], maxChainage: number): })); } +/** + * 가로 스크롤에도 좌측에 고정되는 Y축(표고 눈금) 오버레이. + * + * SVG와 **같은 눈금**(렌더러가 콜백으로 넘김)을 쓰고, 불투명 배경으로 스크롤되는 그래프가 + * 새어 보이지 않게 가린다. 레이아웃에 영향을 주지 않도록 0크기 sticky 앵커 위에 축을 절대배치한다. + */ +function buildStickyYAxis( + axis: { padLeft: number; ticks: Array<{ y: number; label: string }> }, + chartHeight: number, +): HTMLElement { + const anchor = document.createElement("div"); + anchor.className = "b05-profile__yaxis"; + const inner = document.createElement("div"); + inner.className = "b05-profile__yaxis-inner"; + inner.style.width = `${axis.padLeft}px`; + inner.style.height = `${chartHeight}px`; + axis.ticks.forEach(({ y, label }) => { + const tick = document.createElement("span"); + tick.className = "b05-profile__yaxis-tick"; + tick.style.top = `${y}px`; + tick.textContent = label; + inner.append(tick); + }); + anchor.append(inner); + return anchor; +} + /** 종단면도 렌더러와 **같은** chainage → x(px) 매핑을 만든다 (테이블·버튼 정렬 기준). */ function chainageMapper( data: LongitudinalSection, @@ -401,6 +428,9 @@ export function createRouteProfilePanel( selectedStationId, onCurveRadiusChange: (curve, radius) => applyEdits(setCurveRadius(store.edits(), curve, radius ?? 0)), + // 값 열 계획고 직접 입력 → 규칙 측점과 동일한 station_offset 파이프라인. + onAdjustStation: (chainage, delta) => + base && applyEdits(adjustStation(base, store.edits(), chainage, delta)), }) : null; @@ -421,6 +451,7 @@ export function createRouteProfilePanel( ), } : graphData; + let yAxis: { padLeft: number; ticks: Array<{ y: number; label: string }> } | null = null; chartWrap.append( createLongitudinalProfile( graphLongitudinal, @@ -434,8 +465,13 @@ export function createRouteProfilePanel( width, designProfiles, originOffset, + (axis) => { + yAxis = axis; + }, ), ); + // 가로 스크롤에도 고정되는 sticky Y축 오버레이(SVG와 같은 눈금·불투명 배경으로 값 누출 차단). + if (yAxis) chartWrap.append(buildStickyYAxis(yAxis, chartHeight)); if (alignment) { chartWrap.append( createEditOverlay({ @@ -443,6 +479,11 @@ export function createRouteProfilePanel( width, x, step: alignment.policy.edit_step_m, + // 비정규 측점도 규칙 측점처럼 ▲/▼ 버튼으로 계획고 조정(임의 chainage 변화점 승격). + irregularStations: irregularStations.filter( + (entry) => + entry.chainage_m >= 0 && entry.chainage_m <= maxChainageOf(longitudinal) + 1e-6, + ), onStation: (chainage, delta) => base && applyEdits(adjustStation(base, store.edits(), chainage, delta)), onSegment: (segment, delta) => @@ -504,12 +545,18 @@ export function createRouteProfilePanel( detail = nextDetail; stationInterval = nextStationInterval; const stored = readAlignment(nextDetail.longitudinal); + // 재탐색으로 경로(routeId)가 바뀔 때, 사용자가 조작한 편집이 있으면 **새 경로에 이월**한다. + // 편집은 chainage 키라 새 base에 그대로 재적용된다(범위 밖·미매칭 변화점은 best-effort로 드롭). + const routeChanged = nextRouteId !== routeId; + const carried = routeChanged && store.edited() ? store.edits() : null; // 서버 저장분을 기준으로 삼되, 남아 있는 세션 초안이 있으면 그쪽을 우선한다. - if (nextRouteId !== routeId || !store.dirty()) { + if (routeChanged || !store.dirty()) { routeId = nextRouteId ?? routeId; store = createProfileEditStore(routeId, stored?.edits ?? emptyEdits(), () => rebuild()); } base = stored ? toAlignmentBase(stored) : null; + // 이월분은 base 설정 후 미저장 초안으로 커밋한다(확정 시 전송·재탐색 후 새로고침에도 유지). + if (carried) store.replace(carried); alignment = base ? buildAlignment(base, store.edits()) : null; draw(); requestAnimationFrame(draw); diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Table.ts b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Table.ts index 46f5b7de..d70207be 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Table.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Table.ts @@ -42,6 +42,8 @@ export interface ProfileTableOptions { /** 현재 선택된 측점 id. 비정규 측점이면 그 측점의 값 열을 테이블에 겹쳐 보여준다. */ selectedStationId?: string | null; onCurveRadiusChange: (curve: AlignmentCurve, radiusM: number | null) => void; + /** 임의 chainage의 계획고를 delta만큼 조정(비정규 측점 값 열 직접 입력용). */ + onAdjustStation?: (chainageM: number, deltaM: number) => void; } interface StationRowSpec { @@ -344,35 +346,52 @@ function buildIrregularColumn( centerX: number, cellWidth: number, interval: number, + onAdjustStation?: (chainageM: number, deltaM: number) => void, ): HTMLElement { const plan = interpolateSample(alignment.samples, station.chainage_m, "elevation_m"); const ground = interpolateSample(alignment.samples, station.chainage_m, "ground_elevation_m"); const cut = plan !== null && ground !== null && ground - plan > 0.005 ? ground - plan : null; const fill = plan !== null && ground !== null && plan - ground > 0.005 ? plan - ground : null; // 테이블 행 순서(구배 3 · 측점값 7 · 곡선 2)와 정확히 같게 채운다. 없는 값은 공백. - const rows: Array<[string, string]> = [ - ["", ""], // 구배 L - ["", ""], // 구배 H - ["", "grade"], // 구배 S - [cut !== null ? cut.toFixed(2) : "", "cut"], // 절토고 - [fill !== null ? fill.toFixed(2) : "", "fill"], // 성토고 - [plan !== null ? plan.toFixed(2) : "", "plan"], // 계획고 - [ground !== null ? ground.toFixed(2) : "", ""], // 지반고 - [station.chainage_m.toFixed(2), ""], // 누가거리 - ["", ""], // 거리 - [stationLabel(station.chainage_m, interval), ""], // 측점 - ["", ""], // 곡선 L - ["", ""], // 곡선 R + // `plan: true`인 계획고 행만 직접 입력 셀로 만든다(값 열은 오버레이라 실제 12행 격자는 불변). + const rows: Array<{ text: string; modifier: string; plan?: boolean }> = [ + { text: "", modifier: "" }, // 구배 L + { text: "", modifier: "" }, // 구배 H + { text: "", modifier: "grade" }, // 구배 S + { text: cut !== null ? cut.toFixed(2) : "", modifier: "cut" }, // 절토고 + { text: fill !== null ? fill.toFixed(2) : "", modifier: "fill" }, // 성토고 + { text: plan !== null ? plan.toFixed(2) : "", modifier: "plan", plan: true }, // 계획고 + { text: ground !== null ? ground.toFixed(2) : "", modifier: "" }, // 지반고 + { text: station.chainage_m.toFixed(2), modifier: "" }, // 누가거리 + { text: "", modifier: "" }, // 거리 + { text: stationLabel(station.chainage_m, interval), modifier: "" }, // 측점 + { text: "", modifier: "" }, // 곡선 L + { text: "", modifier: "" }, // 곡선 R ]; const column = element("div", "b05-profile-table__irregular-col"); column.style.left = `${centerX}px`; column.style.width = `${cellWidth}px`; - rows.forEach(([text, modifier]) => { + rows.forEach((row) => { const cell = element( "div", - `b05-profile-table__irregular-col-cell${modifier ? ` is-${modifier}` : ""}`, - text, + `b05-profile-table__irregular-col-cell${row.modifier ? ` is-${row.modifier}` : ""}`, ); + if (row.plan && onAdjustStation && plan !== null) { + // 계획고 직접 입력 → (입력값 − 현재 계획고) delta로 규칙 측점과 동일한 station_offset 반영. + const input = document.createElement("input"); + input.type = "number"; + input.step = "any"; + input.className = "b05-profile-table__irregular-input b05-profile-table__no-spin"; + input.value = plan.toFixed(2); + input.title = `계획고 직접 입력 (현재 ${plan.toFixed(2)}m)`; + input.addEventListener("change", () => { + const target = Number.parseFloat(input.value); + if (Number.isFinite(target)) onAdjustStation(station.chainage_m, target - plan); + }); + cell.append(input); + } else { + cell.textContent = row.text; + } column.append(cell); }); column.title = `비정규 측점 ${irregularLabel(station)} · ${station.chainage_m.toFixed(2)}m`; @@ -422,6 +441,7 @@ export function createProfileTable(options: ProfileTableOptions): HTMLElement { x(selectedIrregular.chainage_m), cellWidth, stationInterval, + options.onAdjustStation, ), ); } diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Style.css b/B05_wf2_Route/B05_wf2_Route_UI_Style.css index c38caabf..237e0f45 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Style.css +++ b/B05_wf2_Route/B05_wf2_Route_UI_Style.css @@ -81,6 +81,35 @@ min-height: 0; } +/* 가로 스크롤에도 고정되는 Y축(표고 눈금). 0크기 sticky 앵커에 축을 절대배치해 레이아웃 불변. */ +.b05-profile__yaxis { + position: sticky; + left: 0; + z-index: 3; + width: 0; + height: 0; +} + +.b05-profile__yaxis-inner { + position: absolute; + left: 0; + top: 0; + box-sizing: border-box; + /* SVG 배경(b06-chart__bg)과 같은 색으로 스크롤되는 그래프를 가려 값 누출을 막는다. */ + background: var(--color-surface-raised); + border-right: 1px solid var(--color-text-secondary); + pointer-events: none; +} + +.b05-profile__yaxis-tick { + position: absolute; + right: 6px; + color: var(--color-text-secondary); + font-size: 10px; + white-space: nowrap; + transform: translateY(-50%); +} + .b05-route-profile.is-collapsed .b05-route-profile__body, .b05-route-profile.is-collapsed .b05-route-profile__balance { display: none; @@ -604,7 +633,9 @@ 세로 점선·구조물 태그 없이, 선택 시에만 값 열을 테이블 위에 겹쳐 보여준다. */ .b05-profile-table__irregular-col { position: absolute; - z-index: 6; + /* 값 셀(0)·곡선(2) 위, sticky 행 이름표(5) **아래**로 둔다 — 스크롤로 값 열이 이름표까지 와도 + 행 제목이 가려지지 않는다. */ + z-index: 4; top: 0; bottom: 0; display: flex; @@ -617,6 +648,7 @@ var(--color-surface) ); transform: translateX(-50%); + /* 열 자체는 클릭을 통과시키고, 입력 셀만 pointer-events를 되살린다(작업6). */ pointer-events: none; } @@ -648,6 +680,22 @@ font-weight: var(--font-weight-medium); } +/* 값 열의 계획고 직접 입력 셀 — 열은 pointer-events:none이라 입력만 되살린다. */ +.b05-profile-table__irregular-input { + box-sizing: border-box; + width: 92%; + height: 88%; + padding: 0; + border: 1px solid var(--color-royal-amethyst, rgb(109 40 217)); + border-radius: 2px; + background: var(--color-surface); + color: var(--color-royal-amethyst, rgb(109 40 217)); + font: inherit; + font-weight: var(--font-weight-medium); + text-align: center; + pointer-events: auto; +} + /* ─── 계획고 편집 버튼 (크기 유지·상시 표시·밝은 글자) ───────────────────── */ .b05-profile-edit { position: absolute; diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts index 7cf91918..e58ce747 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts @@ -58,6 +58,8 @@ export interface SectionStation { label: string; /** irregular = 사용자가 구조물용으로 추가한 비정규 측점(프론트 주입, 백엔드 미영속). */ kind: "bp" | "ep" | "regular" | "irregular"; + /** 비정규 측점의 구조물 설명(백엔드가 확정 시 부여). 복귀 시 사이드바 목록 복원에 쓴다. */ + structure?: string; center_z: number | null; azimuth_deg: number | null; center_x: number; diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Longitudinal.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Longitudinal.ts index a2c841af..b459ce01 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Longitudinal.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Longitudinal.ts @@ -91,6 +91,11 @@ export function createLongitudinalProfile( * B05 도면 테이블과 정렬할 때 0측점을 이름표 열 바깥으로 밀어내는 데 쓴다(기본 0). */ originOffsetPx = 0, + /** + * Y축(표고 눈금) 정보를 넘겨받는 콜백(선택). B05가 가로 스크롤에도 고정되는 sticky Y축 + * 오버레이를 그릴 때 SVG와 **같은 Y-스케일**을 공유하려고 쓴다. B06은 넘기지 않는다. + */ + onYAxis?: (axis: { padLeft: number; ticks: Array<{ y: number; label: string }> }) => void, ): HTMLElement { const samples = data.samples.filter(validElevation); if (samples.length < 2) return emptyView(L("B06_Profile_View_NoLongitudinal")); @@ -131,10 +136,12 @@ export function createLongitudinalProfile( LONG_PAD.top + ((elevationMid + elevationSpan / 2 - elevation) / elevationSpan) * plotHeight; const stationInterval = configuredStationInterval ?? inferStationInterval(data.stations); + const yAxisTicks: Array<{ y: number; label: string }> = []; for (const ratio of [0, 0.25, 0.5, 0.75, 1]) { const gridY = LONG_PAD.top + ratio * plotHeight; const displayed = elevationMid + elevationSpan / 2 - ratio * elevationSpan; const rawValue = elevationMid + (displayed - elevationMid) / exaggeration; + yAxisTicks.push({ y: gridY, label: `${rawValue.toFixed(1)}m` }); svg.append( svgElement("line", { x1: LONG_PAD.left, @@ -151,6 +158,8 @@ export function createLongitudinalProfile( }), ); } + // sticky Y축 오버레이가 SVG와 동일한 눈금을 쓰도록 전달(B05 전용, B06은 콜백 없음). + onYAxis?.({ padLeft: LONG_PAD.left, ticks: yAxisTicks }); // 절·성토 음영과 균형 구역 경계는 측점선·프로파일선보다 아래에 깔린다. const toY = (elevation: number) => y(elevationMid + (elevation - elevationMid) * exaggeration); diff --git a/main.py b/main.py index 01f2948b..e32c62ea 100644 --- a/main.py +++ b/main.py @@ -112,7 +112,7 @@ def serve_frontend_dev() -> None: logger.info("[Frontend] npm run dev 시작 (백그라운드)...") try: subprocess.Popen( - "npm run dev", + "npm run dev -- --host", shell=True, cwd=str(root_dir), env=_frontend_process_env(root_dir),