From 6c0d364fc964b6cf2869abd0e175056769982271 Mon Sep 17 00:00:00 2001 From: zio Date: Tue, 14 Jul 2026 23:36:07 +0900 Subject: [PATCH] feat(m2): floorplan tree selection + easy 3-step auto-layout wizard (real operation) Owner directives 2026-07-14: select floorplan via tree (center > hall > zone), generate booths inside it, images only on button click, zoom popup + save. - V59: align hall width/depth to official floorplan orientation (all 10 halls were transposed vs drawings, e.g. H1 63x171 -> 171x63); re-derive assumed exits (V4 formula) and trench grid (V5 formula) - V60: hall_zone tree table + drawing-measured zones (1A/1B, 6A/6B/6C, 7A/7B, 8A/8B, 9B/9A, 10B/10A) - data as tree: center > hall > zone - GET /api/halls/tree (HallTreeController) - selection tree for the wizard - AutoLayoutRequest.zoneId: pack booths only inside the selected zone bounds - S7 aerial preview is NO LONGER auto-published per option (Gemini quota); new POST .../auto-generate/preview publishes only on user button click, persisted in render_job for gallery re-view and auction reference reuse - AutoLayoutDialog reworked as 3-step wizard (place > conditions > pick): per-option generate-aerial-image button, click-to-zoom lightbox with download/save, and REAL apply via POST /apply-option (was refetch-only - selected options were never persisted) - FloorplanCanvas calibration: H8/H10 floor regions re-measured with union-of-blobs (center aisle split the color blob, bottom half only before) - Editor toolbar hall selector (grouped by exhibition center) Co-Authored-By: Claude Fable 5 --- .../kintex/module/m2/FloorplanController.java | 15 + .../kintex/module/m2/FloorplanService.java | 7 + .../module/m2/FloorplanServiceImpl.java | 69 +++- .../kintex/module/m2/HallTreeController.java | 67 ++++ .../module/m2/dto/AutoLayoutRequest.java | 5 +- .../kintex/module/m2/dto/HallTreeDto.java | 25 ++ .../kintex/module/m2/dto/S7PreviewResult.java | 10 + .../kintex/module/m2/mapper/HallMapper.java | 9 + .../V59__hall_dims_align_floorplan.sql | 48 +++ .../db/migration/V60__hall_zone_tree.sql | 37 ++ .../resources/mybatis/mapper/HallMapper.xml | 32 ++ src/frontend/src/api/endpoints.ts | 18 + src/frontend/src/api/types.ts | 31 +- .../screens/floorplan/AutoLayoutDialog.tsx | 319 +++++++++++++++--- .../floorplan/BoothLayoutEditorPage.tsx | 49 ++- .../src/screens/floorplan/auto-layout.css | 114 +++++++ src/frontend/src/screens/floorplan/editor.css | 7 + .../src/screens/floorplan/hallFloorplan.ts | 7 +- 18 files changed, 791 insertions(+), 78 deletions(-) create mode 100644 src/backend/src/main/java/com/zioinfo/kintex/module/m2/HallTreeController.java create mode 100644 src/backend/src/main/java/com/zioinfo/kintex/module/m2/dto/HallTreeDto.java create mode 100644 src/backend/src/main/java/com/zioinfo/kintex/module/m2/dto/S7PreviewResult.java create mode 100644 src/backend/src/main/resources/db/migration/V59__hall_dims_align_floorplan.sql create mode 100644 src/backend/src/main/resources/db/migration/V60__hall_zone_tree.sql diff --git a/src/backend/src/main/java/com/zioinfo/kintex/module/m2/FloorplanController.java b/src/backend/src/main/java/com/zioinfo/kintex/module/m2/FloorplanController.java index 96371c9..6adf827 100644 --- a/src/backend/src/main/java/com/zioinfo/kintex/module/m2/FloorplanController.java +++ b/src/backend/src/main/java/com/zioinfo/kintex/module/m2/FloorplanController.java @@ -11,6 +11,7 @@ import com.zioinfo.kintex.module.m2.dto.LayoutDto; import com.zioinfo.kintex.module.m2.dto.LayoutInterpretRequest; import com.zioinfo.kintex.module.m2.dto.LayoutInterpretResult; import com.zioinfo.kintex.module.m2.dto.LayoutSaveRequest; +import com.zioinfo.kintex.module.m2.dto.S7PreviewResult; import com.zioinfo.kintex.rules.ComplianceReport; import jakarta.validation.Valid; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -94,6 +95,20 @@ public class FloorplanController { } /** POST /apply-option — 자동배치 안 선택/병합 적용(주최자). 새 버전 저장 + 규정 재검증. */ + /** + * POST /auto-generate/preview — S7 홀 전경(조감) 이미지 생성 발행(주최자). + * 이미지 생성은 이 버튼 경유만 허용(자동 발행 금지, 소유자 지시 2026-07-14). 결과는 render_job 영속 → + * 갤러리(boothRef=HALL-{hallId}) 재열람·옥션 참고 이미지 재사용. + */ + @PostMapping("/auto-generate/preview") + public ApiResponse preview(@AuthenticationPrincipal KintexPrincipal principal, + @PathVariable String eventId, + @PathVariable String hallId, + @RequestParam(required = false) String option) { + guard.requireRole(principal, eventId, EventRole.ORGANIZER); + return ApiResponse.ok(service.previewS7(eventId, hallId, option)); + } + @PostMapping("/apply-option") public ApiResponse applyOption(@AuthenticationPrincipal KintexPrincipal principal, @PathVariable String eventId, diff --git a/src/backend/src/main/java/com/zioinfo/kintex/module/m2/FloorplanService.java b/src/backend/src/main/java/com/zioinfo/kintex/module/m2/FloorplanService.java index 901e331..3e72c5b 100644 --- a/src/backend/src/main/java/com/zioinfo/kintex/module/m2/FloorplanService.java +++ b/src/backend/src/main/java/com/zioinfo/kintex/module/m2/FloorplanService.java @@ -6,6 +6,7 @@ import com.zioinfo.kintex.module.m2.dto.AutoLayoutRequest; import com.zioinfo.kintex.module.m2.dto.LayoutDto; import com.zioinfo.kintex.module.m2.dto.LayoutInterpretResult; import com.zioinfo.kintex.module.m2.dto.LayoutSaveRequest; +import com.zioinfo.kintex.module.m2.dto.S7PreviewResult; import com.zioinfo.kintex.rules.ComplianceReport; import java.util.List; @@ -34,6 +35,12 @@ public interface FloorplanService { */ List autoGenerate(String eventId, String hallId, AutoLayoutRequest request, boolean ai); + /** + * S7 홀 전경(조감) 이미지 생성 발행 — 버튼 클릭 시에만 호출(자동 발행 금지, 소유자 지시 2026-07-14). + * 렌더 잡은 render_job 영속 → 갤러리 재열람·옥션 참고 이미지 재사용. 인프라 미가용 시 degraded. + */ + S7PreviewResult previewS7(String eventId, String hallId, String optionLabel); + /** 자동배치 안 적용/병합 → 새 배치안 버전으로 저장 후 규정 재검증(선택/병합→검증 흐름 마감). */ LayoutDto applyOption(String eventId, String hallId, ApplyOptionRequest request); } diff --git a/src/backend/src/main/java/com/zioinfo/kintex/module/m2/FloorplanServiceImpl.java b/src/backend/src/main/java/com/zioinfo/kintex/module/m2/FloorplanServiceImpl.java index dd727d1..03e5127 100644 --- a/src/backend/src/main/java/com/zioinfo/kintex/module/m2/FloorplanServiceImpl.java +++ b/src/backend/src/main/java/com/zioinfo/kintex/module/m2/FloorplanServiceImpl.java @@ -16,6 +16,7 @@ import com.zioinfo.kintex.module.m2.dto.LayoutDto; import com.zioinfo.kintex.module.m2.dto.LayoutInterpretResult; import com.zioinfo.kintex.module.m2.dto.LayoutSaveRequest; import com.zioinfo.kintex.module.m2.dto.LayoutSummary; +import com.zioinfo.kintex.module.m2.dto.S7PreviewResult; import com.zioinfo.kintex.module.m2.mapper.BoothMapper; import com.zioinfo.kintex.module.m2.mapper.HallMapper; import com.zioinfo.kintex.module.m5.RenderJobService; @@ -205,7 +206,7 @@ public class FloorplanServiceImpl implements FloorplanService { @Override public LayoutInterpretResult interpret(String eventId, String hallId, String text) { - AutoLayoutRequest fallback = new AutoLayoutRequest(60, 0.15, 1, 1, 2, 3); + AutoLayoutRequest fallback = new AutoLayoutRequest(60, 0.15, 1, 1, 2, 3, null); if (text == null || text.isBlank()) { return new LayoutInterpretResult(fallback, "입력이 비어 있어 기본 조건을 적용했습니다.", true, "none"); } @@ -233,7 +234,7 @@ public class FloorplanServiceImpl implements FloorplanService { int optionCount = clampInt(intField(node, "optionCount", fallback.optionCount()), OPTION_MIN, OPTION_MAX); AutoLayoutRequest parsed = new AutoLayoutRequest( - boothCount, premiumRatio, stageCount, loungeCount, mainEntranceCount, optionCount); + boothCount, premiumRatio, stageCount, loungeCount, mainEntranceCount, optionCount, null); String note = String.format( "부스 %d · 프리미엄 %d%% · 무대 %d · 라운지 %d · 주출입구 %d · %d안으로 해석했습니다.", boothCount, Math.round(premiumRatio * 100), stageCount, loungeCount, mainEntranceCount, optionCount); @@ -272,6 +273,18 @@ public class FloorplanServiceImpl implements FloorplanService { double hallW = hall.dimsM().get(0); double hallD = hall.dimsM().get(1); + // 구역(소분류, V60 hall_zone) 제한 — 평면도 트리 선택(2026-07-14). 미지정 시 홀 전체. + double[] bounds = {0, 0, hallW, hallD}; + String zoneLabel = null; + if (request.zoneId() != null && !request.zoneId().isBlank()) { + Map z = hallMapper.findZone(request.zoneId()); + if (z == null || !hallId.equals(str(z.get("hallId")))) { + throw new ApiException(ErrorCode.NOT_FOUND, "선택한 구역을 찾을 수 없습니다."); + } + bounds = new double[]{num(z.get("x0")), num(z.get("y0")), num(z.get("x1")), num(z.get("y1"))}; + zoneLabel = str(z.get("label")); + } + // 3안: 통로 폭·부스 규격을 달리해 서로 구분되는 후보를 결정적으로 생성. double[][] variants = { {3.0, 3.0, 3.0}, // A: 표준 3×3, 통로 3.0m @@ -282,7 +295,7 @@ public class FloorplanServiceImpl implements FloorplanService { for (int i = 0; i < optionCount; i++) { double[] vp = variants[i % variants.length]; char tag = (char) ('A' + i); - List booths = packBooths(vp[0], vp[1], vp[2], hallW, hallD, request, tag); + List booths = packBooths(vp[0], vp[1], vp[2], hallW, hallD, bounds, request, tag); double salesArea = booths.stream() .mapToDouble(b -> vp[0] * vp[1]).sum(); @@ -290,11 +303,12 @@ public class FloorplanServiceImpl implements FloorplanService { booths.size(), request.targetBoothCount(), round2(salesArea), vp[2], 0, 0); - String s7JobId = publishS7Preview(eventId, hallId, hall, tag); - options.add(new AutoLayoutOption("opt-" + tag, "배치안 " + tag, summary, booths, s7JobId)); + // S7 조감 이미지는 자동 발행하지 않는다 — 사용자가 버튼 클릭 시에만 생성(소유자 지시 2026-07-14). + String label = "배치안 " + tag + (zoneLabel != null ? " · " + zoneLabel : ""); + options.add(new AutoLayoutOption("opt-" + tag, label, summary, booths, null)); } - log.info("자동배치 생성: event={} hall={} options={} target={} ai={}", - eventId, hallId, options.size(), request.targetBoothCount(), ai); + log.info("자동배치 생성: event={} hall={} zone={} options={} target={} ai={}", + eventId, hallId, request.zoneId(), options.size(), request.targetBoothCount(), ai); // AI 부가 요약(ai=true) — 엔진 산출 metrics만 근거. 실패해도 배치는 그대로 반환(요약만 생략). return ai ? attachAiSummaries(options, hall) : options; @@ -360,20 +374,24 @@ public class FloorplanServiceImpl implements FloorplanService { * 배치안이 실제 전시 평면도처럼 홀 바닥면 안에서 구획된다(소유자 지시 2026-07-14). */ private List packBooths(double boothW, double boothD, double aisle, - double hallW, double hallD, AutoLayoutRequest req, char tag) { + double hallW, double hallD, double[] bounds, + AutoLayoutRequest req, char tag) { List booths = new ArrayList<>(); int target = req.targetBoothCount(); int premiumTarget = (int) Math.round(target * clamp01(req.premiumRatio())); double stepX = boothW + aisle; double stepY = boothD + aisle; - double usableW = hallW - 2 * PERIMETER_MARGIN_M; - double usableD = hallD - 2 * PERIMETER_MARGIN_M; + // 패킹 범위 = 선택 구역(bounds) ∩ 외곽 주통로 여유. 구역 내부 경계에는 1.5m 이격. + double startX = bounds[0] <= 0 ? PERIMETER_MARGIN_M : bounds[0] + 1.5; + double endX = bounds[2] >= hallW ? hallW - PERIMETER_MARGIN_M : bounds[2] - 1.5; + double startY = bounds[1] <= 0 ? PERIMETER_MARGIN_M : bounds[1] + 1.5; + double endY = bounds[3] >= hallD ? hallD - PERIMETER_MARGIN_M : bounds[3] - 1.5; List reserved = reservedZones(hallW, hallD, aisle, req); int index = 0; - for (double y = PERIMETER_MARGIN_M; y + boothD <= PERIMETER_MARGIN_M + usableD && index < target; y += stepY) { - for (double x = PERIMETER_MARGIN_M; x + boothW <= PERIMETER_MARGIN_M + usableW && index < target; x += stepX) { + for (double y = startY; y + boothD <= endY && index < target; y += stepY) { + for (double x = startX; x + boothW <= endX && index < target; x += stepX) { if (intersectsAny(reserved, x, y, x + boothW, y + boothD)) { continue; } @@ -442,8 +460,24 @@ public class FloorplanServiceImpl implements FloorplanService { return false; } - /** S7 홀 전경(조감) 프리뷰 발행 — 렌더 인프라 장애 시에도 자동배치가 성립하도록 degraded(null) 허용. */ - private String publishS7Preview(String eventId, String hallId, HallInfo hall, char tag) { + /** + * S7 홀 전경(조감) 이미지 생성 — 사용자 버튼 클릭 시에만 호출된다(자동 발행 금지, 소유자 지시 2026-07-14). + * 렌더 잡은 render_job 에 영속되어 조회화면(렌더 이력 갤러리, boothRef=HALL-{hallId})에서 재열람하거나 + * 옥션 자료 패키지(aiImageUrl) 참고 이미지로 재사용한다. 인프라 장애 시 degraded(jobId=null). + */ + @Override + public S7PreviewResult previewS7(String eventId, String hallId, String optionLabel) { + HallInfo hall = loadHallInfo(hallId); + if (hall == null || hall.dimsM() == null || hall.dimsM().size() < 2) { + throw new ApiException(ErrorCode.NOT_FOUND, "홀 규격을 찾을 수 없습니다."); + } + String jobId = publishS7Preview(eventId, hallId, hall, + optionLabel == null || optionLabel.isBlank() ? "홀 전경" : optionLabel); + return new S7PreviewResult(jobId, jobId == null); + } + + /** S7 홀 전경(조감) 프리뷰 발행 — 렌더 인프라 장애 시에도 흐름이 성립하도록 degraded(null) 허용. */ + private String publishS7Preview(String eventId, String hallId, HallInfo hall, String optionLabel) { try { Map scene = new LinkedHashMap<>(); Map hallScene = new LinkedHashMap<>(); @@ -453,7 +487,7 @@ public class FloorplanServiceImpl implements FloorplanService { hallScene.put("ceiling_m", hall.ceilingM()); } scene.put("hall", hallScene); - scene.put("option", "배치안 " + tag); + scene.put("option", optionLabel); RenderJobRequest req = new RenderJobRequest("S7", scene, null); return renderJobService.publish(eventId, "HALL-" + hallId, req).jobId(); } catch (RuntimeException e) { @@ -462,6 +496,11 @@ public class FloorplanServiceImpl implements FloorplanService { } } + /** numeric(8,2) 등 DB 수치 → double (BigDecimal/Number 공통). */ + private static double num(Object o) { + return o instanceof Number n ? n.doubleValue() : Double.parseDouble(String.valueOf(o)); + } + // ------------------------------------------------------------- 조립 유틸 ----- private LayoutDto assembleLayout(String eventId, String hallId, Map header) { diff --git a/src/backend/src/main/java/com/zioinfo/kintex/module/m2/HallTreeController.java b/src/backend/src/main/java/com/zioinfo/kintex/module/m2/HallTreeController.java new file mode 100644 index 0000000..0fd54fa --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/module/m2/HallTreeController.java @@ -0,0 +1,67 @@ +package com.zioinfo.kintex.module.m2; + +import com.zioinfo.kintex.auth.EventAccessGuard; +import com.zioinfo.kintex.auth.KintexPrincipal; +import com.zioinfo.kintex.common.ApiResponse; +import com.zioinfo.kintex.module.m2.dto.HallTreeDto; +import com.zioinfo.kintex.module.m2.mapper.HallMapper; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 평면도 선택 트리 API — 대분류(전시장) > 중분류(홀) > 소분류(구역). + * 로그인 사용자 조회 가능(홀 마스터는 비밀 아님 — 자격증명·PII 없음). 데이터 원천: hall + hall_zone(V60). + */ +@RestController +@RequestMapping("/api/halls/tree") +public class HallTreeController { + + private final HallMapper hallMapper; + private final EventAccessGuard guard; + + public HallTreeController(HallMapper hallMapper, EventAccessGuard guard) { + this.hallMapper = hallMapper; + this.guard = guard; + } + + @GetMapping + public ApiResponse tree(@AuthenticationPrincipal KintexPrincipal principal) { + guard.require(principal); + + // 구역을 홀별로 그룹핑 + Map> zonesByHall = new LinkedHashMap<>(); + for (Map z : hallMapper.findAllZones()) { + zonesByHall.computeIfAbsent(str(z.get("hallId")), k -> new ArrayList<>()) + .add(new HallTreeDto.ZoneNode(str(z.get("zoneId")), str(z.get("label")))); + } + + // 홀을 전시장(대분류)별로 그룹핑 + Map> hallsByCenter = new LinkedHashMap<>(); + for (Map h : hallMapper.findAllHalls()) { + String hallId = str(h.get("hallId")); + int center = ((Number) h.get("center")).intValue(); + List dims = h.get("widthM") == null || h.get("depthM") == null + ? List.of() + : List.of(((Number) h.get("widthM")).doubleValue(), ((Number) h.get("depthM")).doubleValue()); + hallsByCenter.computeIfAbsent(center, k -> new ArrayList<>()) + .add(new HallTreeDto.HallNode(hallId, str(h.get("label")), dims, + zonesByHall.getOrDefault(hallId, List.of()))); + } + + List centers = hallsByCenter.entrySet().stream() + .map(e -> new HallTreeDto.Center(e.getKey(), "제" + e.getKey() + "전시장", e.getValue())) + .toList(); + return ApiResponse.ok(new HallTreeDto(centers)); + } + + private static String str(Object o) { + return o == null ? null : String.valueOf(o); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/module/m2/dto/AutoLayoutRequest.java b/src/backend/src/main/java/com/zioinfo/kintex/module/m2/dto/AutoLayoutRequest.java index 8842f0f..41796f1 100644 --- a/src/backend/src/main/java/com/zioinfo/kintex/module/m2/dto/AutoLayoutRequest.java +++ b/src/backend/src/main/java/com/zioinfo/kintex/module/m2/dto/AutoLayoutRequest.java @@ -11,6 +11,8 @@ import jakarta.validation.constraints.Min; * @param loungeCount 라운지 수 * @param mainEntranceCount 주출입구 수 * @param optionCount 생성할 배치안 개수(기본 3) + * @param zoneId 선택 구역(소분류, V60 hall_zone.id — 예 "H1-A"). null이면 홀 전체. + * 지정 시 해당 구역 경계 안에서만 부스를 패킹한다(평면도 트리 선택, 2026-07-14). */ public record AutoLayoutRequest( @Min(1) int targetBoothCount, @@ -18,6 +20,7 @@ public record AutoLayoutRequest( int stageCount, int loungeCount, int mainEntranceCount, - @Min(1) int optionCount + @Min(1) int optionCount, + String zoneId ) { } diff --git a/src/backend/src/main/java/com/zioinfo/kintex/module/m2/dto/HallTreeDto.java b/src/backend/src/main/java/com/zioinfo/kintex/module/m2/dto/HallTreeDto.java new file mode 100644 index 0000000..0311e92 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/module/m2/dto/HallTreeDto.java @@ -0,0 +1,25 @@ +package com.zioinfo.kintex.module.m2.dto; + +import java.util.List; + +/** + * 평면도 선택 트리 — 대분류(전시장) > 중분류(홀) > 소분류(구역, V60 hall_zone). + * 소유자 지시 2026-07-14: "데이터는 트리구조". 배치 에디터가 이 트리로 전시장→홀→구역을 선택하고 + * 선택 구역(zoneId)은 자동배치 패킹 경계로 쓰인다. + * + * @param centers 전시장(대분류) 목록 + */ +public record HallTreeDto(List
centers) { + + /** 대분류 — 전시장(제1/제2). */ + public record Center(int center, String label, List halls) { + } + + /** 중분류 — 홀. dimsM=[폭, 깊이](m, V59 도면 방향 정렬). */ + public record HallNode(String id, String label, List dimsM, List zones) { + } + + /** 소분류 — 홀 내부 구역(예: 1A/1B). */ + public record ZoneNode(String id, String label) { + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/module/m2/dto/S7PreviewResult.java b/src/backend/src/main/java/com/zioinfo/kintex/module/m2/dto/S7PreviewResult.java new file mode 100644 index 0000000..90f4c8e --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/module/m2/dto/S7PreviewResult.java @@ -0,0 +1,10 @@ +package com.zioinfo.kintex.module.m2.dto; + +/** + * S7 홀 전경(조감) 이미지 생성 발행 결과 — 버튼 클릭 전용 생성(소유자 지시 2026-07-14). + * + * @param jobId 렌더 잡 ID(WebSocket /topic/render/{jobId} 구독·상태 조회 키). degraded 시 null. + * @param degraded 렌더 인프라 미가용으로 발행 실패(이미지 없이 흐름 지속) + */ +public record S7PreviewResult(String jobId, boolean degraded) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/module/m2/mapper/HallMapper.java b/src/backend/src/main/java/com/zioinfo/kintex/module/m2/mapper/HallMapper.java index df446ec..6957f77 100644 --- a/src/backend/src/main/java/com/zioinfo/kintex/module/m2/mapper/HallMapper.java +++ b/src/backend/src/main/java/com/zioinfo/kintex/module/m2/mapper/HallMapper.java @@ -23,4 +23,13 @@ public interface HallMapper { /** 홀 비상구 좌표·이격 목록 — 캔버스 비상구 표시용. */ List> findExits(@Param("hallId") String hallId); + + /** 홀 마스터 전체(전시장 순) — 평면도 선택 트리 대분류·중분류 근거(V60). */ + List> findAllHalls(); + + /** 홀 구역(소분류) 전체 — 평면도 선택 트리(V60 hall_zone). */ + List> findAllZones(); + + /** 구역 단건(경계 좌표) — 구역 제한 자동배치 근거. 없으면 null. */ + Map findZone(@Param("zoneId") String zoneId); } diff --git a/src/backend/src/main/resources/db/migration/V59__hall_dims_align_floorplan.sql b/src/backend/src/main/resources/db/migration/V59__hall_dims_align_floorplan.sql new file mode 100644 index 0000000..2803a77 --- /dev/null +++ b/src/backend/src/main/resources/db/migration/V59__hall_dims_align_floorplan.sql @@ -0,0 +1,48 @@ +-- V59: 홀 치수 방향을 공식 평면도(도면 가로×세로) 기준으로 정렬 — 실 운영 전환(소유자 지시 2026-07-14). +-- 근거: 홀 도면 JPG 10장 전수 실측 결과 전 홀이 DB(width_m×depth_m)와 방향이 뒤집혀 있어 +-- (예: H1 도면 171×63 vs DB 63×171, H7 도면 90×126 vs DB 126×90) 부스 자동배치가 +-- 도면과 90° 어긋난 방향으로 생성됐다. 면적·기타 컬럼 불변. +-- 부수 재유도: 비상구(V4)·가정 트렌치(V5)는 치수에서 유도된 가정값이므로 재생성. +-- (trench 는 참조 FK 없음 — is_assumed 행만 삭제 후 재유도. CAD 실측 대체 시 V5 주석과 동일 TODO.) + +-- 1) 치수 스왑(목표값 직접 지정 — 값 기준 멱등) +UPDATE hall SET width_m = 171, depth_m = 63 WHERE id IN ('H1','H2','H3','H4','H5'); +UPDATE hall SET width_m = 60, depth_m = 93 WHERE id = 'H6'; +UPDATE hall SET width_m = 90, depth_m = 126 WHERE id IN ('H7','H8'); +UPDATE hall SET width_m = 99, depth_m = 132 WHERE id IN ('H9','H10'); + +-- 2) 가정 비상구 재유도(V4 공식 동일 — 각 변 중앙 4개소, ON CONFLICT UPDATE 로 좌표 갱신) +INSERT INTO hall_exit (id, hall_id, geom, clearance_m, is_assumed) +SELECT h.id || '-EXIT-' || g.tag, + h.id, + ST_SetSRID(ST_MakePoint(g.x, g.y), 0), + 3.0, + true +FROM hall h +CROSS JOIN LATERAL (VALUES + ('S', h.width_m / 2.0, 0.0), + ('N', h.width_m / 2.0, h.depth_m), + ('W', 0.0, h.depth_m / 2.0), + ('E', h.width_m, h.depth_m / 2.0) +) AS g(tag, x, y) +WHERE h.id IN ('H1','H2','H3','H4','H5','H6','H7','H8','H9','H10') +ON CONFLICT (id) DO UPDATE SET + geom = EXCLUDED.geom, + clearance_m = EXCLUDED.clearance_m, + is_assumed = EXCLUDED.is_assumed; + +-- 3) 가정 트렌치 재생성(V5 공식 동일 — 6m 격자, 구 방향 격자 제거 후 재유도) +DELETE FROM trench +WHERE is_assumed AND hall_id IN ('H1','H2','H3','H4','H5','H6','H7','H8','H9','H10'); + +INSERT INTO trench (id, hall_id, geom, + supply_power, supply_water, supply_air, supply_network, supply_gas, is_assumed) +SELECT h.id || '-T-' || gx || '-' || gy, + h.id, + ST_SetSRID(ST_MakePoint(gx, gy), 0), + true, true, true, true, h.has_gas, true +FROM hall h +CROSS JOIN LATERAL generate_series(3, floor(h.width_m)::int - 3, 6) AS gx +CROSS JOIN LATERAL generate_series(3, floor(h.depth_m)::int - 3, 6) AS gy +WHERE h.id IN ('H1','H2','H3','H4','H5','H6','H7','H8','H9','H10') +ON CONFLICT (id) DO NOTHING; diff --git a/src/backend/src/main/resources/db/migration/V60__hall_zone_tree.sql b/src/backend/src/main/resources/db/migration/V60__hall_zone_tree.sql new file mode 100644 index 0000000..0909aff --- /dev/null +++ b/src/backend/src/main/resources/db/migration/V60__hall_zone_tree.sql @@ -0,0 +1,37 @@ +-- V60: 평면도 선택 트리(대분류 전시장 > 중분류 홀 > 소분류 구역) — 소유자 지시 2026-07-14. +-- 대분류 = hall.exhibition_center(1/2), 중분류 = hall, 소분류 = hall_zone(홀 내부 구획, 도면 실측). +-- 구역 경계는 V59 정렬 후 홀 로컬 미터 좌표(x0,y0 = 도면 좌상단 기준). 자동배치가 구역을 +-- 선택하면 해당 경계 안에서만 부스를 패킹한다. +CREATE TABLE IF NOT EXISTS hall_zone ( + id TEXT PRIMARY KEY, -- 예: H1-A + hall_id TEXT NOT NULL REFERENCES hall(id), + label TEXT NOT NULL, -- 예: 1A + x0 NUMERIC(8,2) NOT NULL, + y0 NUMERIC(8,2) NOT NULL, + x1 NUMERIC(8,2) NOT NULL, + y1 NUMERIC(8,2) NOT NULL, + sort INT NOT NULL DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_hall_zone_hall ON hall_zone (hall_id, sort); + +-- 도면 실측 구획 시드(멱등): +-- H1~H5 (171×63): A 좌 81m / B 우 90m (hall1 도면 Hall A:81m, Hall B:90m) +-- H6 (60×93): 6A/6B/6C 세로 3분할(31m) +-- H7·H8 (90×126): A 상 63m / B 하 63m +-- H9·H10 (99×132): B 상 66m / A 하 66m (도면상 A가 아래) +INSERT INTO hall_zone (id, hall_id, label, x0, y0, x1, y1, sort) VALUES + ('H1-A', 'H1', '1A', 0, 0, 81, 63, 1), ('H1-B', 'H1', '1B', 81, 0, 171, 63, 2), + ('H2-A', 'H2', '2A', 0, 0, 81, 63, 1), ('H2-B', 'H2', '2B', 81, 0, 171, 63, 2), + ('H3-A', 'H3', '3A', 0, 0, 81, 63, 1), ('H3-B', 'H3', '3B', 81, 0, 171, 63, 2), + ('H4-A', 'H4', '4A', 0, 0, 81, 63, 1), ('H4-B', 'H4', '4B', 81, 0, 171, 63, 2), + ('H5-A', 'H5', '5A', 0, 0, 81, 63, 1), ('H5-B', 'H5', '5B', 81, 0, 171, 63, 2), + ('H6-A', 'H6', '6A', 0, 0, 60, 31, 1), ('H6-B', 'H6', '6B', 0, 31, 60, 62, 2), + ('H6-C', 'H6', '6C', 0, 62, 60, 93, 3), + ('H7-A', 'H7', '7A', 0, 0, 90, 63, 1), ('H7-B', 'H7', '7B', 0, 63, 90, 126, 2), + ('H8-A', 'H8', '8A', 0, 0, 90, 63, 1), ('H8-B', 'H8', '8B', 0, 63, 90, 126, 2), + ('H9-B', 'H9', '9B', 0, 0, 99, 66, 1), ('H9-A', 'H9', '9A', 0, 66, 99, 132, 2), + ('H10-B', 'H10', '10B', 0, 0, 99, 66, 1), ('H10-A', 'H10', '10A', 0, 66, 99, 132, 2) +ON CONFLICT (id) DO UPDATE SET + label = EXCLUDED.label, + x0 = EXCLUDED.x0, y0 = EXCLUDED.y0, x1 = EXCLUDED.x1, y1 = EXCLUDED.y1, + sort = EXCLUDED.sort; diff --git a/src/backend/src/main/resources/mybatis/mapper/HallMapper.xml b/src/backend/src/main/resources/mybatis/mapper/HallMapper.xml index 40f2039..15dc91e 100644 --- a/src/backend/src/main/resources/mybatis/mapper/HallMapper.xml +++ b/src/backend/src/main/resources/mybatis/mapper/HallMapper.xml @@ -50,4 +50,36 @@ ORDER BY id + + + + + + + + + diff --git a/src/frontend/src/api/endpoints.ts b/src/frontend/src/api/endpoints.ts index 9fafcac..f497064 100644 --- a/src/frontend/src/api/endpoints.ts +++ b/src/frontend/src/api/endpoints.ts @@ -12,7 +12,10 @@ import type { AnalyticsPerspective, AutoLayoutOption, AutoLayoutRequest, + BoothDto, ComplianceReport, + HallTreeDto, + S7PreviewResult, DashboardData, OpsData, DesignPlanDto, @@ -175,6 +178,21 @@ export const layoutApi = { `${layoutBase(eventId, hallId)}/auto-generate${ai ? '?ai=true' : ''}`, body, ), + // "이 안으로 편집 시작" 실저장 — 선택 안 부스를 새 배치안 버전으로 영속(규정 재검증 포함). + applyOption: (eventId: string, hallId: string, body: { name: string; booths: BoothDto[] }) => + api.post(`${layoutBase(eventId, hallId)}/apply-option`, body), + // S7 홀 전경 이미지 생성 — 버튼 클릭 시에만 발행(자동 발행 금지, 2026-07-14). + previewS7: (eventId: string, hallId: string, option?: string) => + api.post( + `${layoutBase(eventId, hallId)}/auto-generate/preview${ + option ? `?option=${encodeURIComponent(option)}` : '' + }`, + ), +}; + +// 평면도 선택 트리 — 대분류(전시장) > 중분류(홀) > 소분류(구역) (V60) +export const hallTreeApi = { + get: () => api.get('/api/halls/tree'), }; // ── M3 부스 설계 스튜디오 (SCR-06/09) ── diff --git a/src/frontend/src/api/types.ts b/src/frontend/src/api/types.ts index fdc4c76..c868599 100644 --- a/src/frontend/src/api/types.ts +++ b/src/frontend/src/api/types.ts @@ -218,12 +218,15 @@ export interface AutoLayoutRequest { loungeCount: number; mainEntranceCount: number; optionCount: number; + /** 선택 구역(소분류, hall_zone.id 예 "H1-A"). null/미지정이면 홀 전체(2026-07-14 트리 선택). */ + zoneId?: string | null; } export interface AutoLayoutOption { optionId: string; label: string; summary: LayoutSummary; - s7RenderJobId: string; + booths: BoothDto[]; // apply-option 저장 페이로드 — "이 안으로 편집 시작" 실배선 + s7RenderJobId: string | null; // 항상 null — 이미지는 버튼 클릭 시 previewS7로만 생성(2026-07-14) aiSummary?: string; // ai=true 시에만: 엔진 metrics 근거 AI 장단점 요약(WISE AI). 없으면 미표시. } @@ -239,6 +242,32 @@ export interface LayoutInterpretResult { } // ── 4. M3 부스 설계 스튜디오 (SCR-06/09) ── +// S7 홀 전경 이미지 생성 발행 결과(버튼 클릭 전용, POST /auto-generate/preview) +export interface S7PreviewResult { + jobId: string | null; + degraded: boolean; +} + +// 평면도 선택 트리 — 대분류(전시장) > 중분류(홀) > 소분류(구역) (GET /api/halls/tree, V60) +export interface HallTreeZone { + id: string; + label: string; +} +export interface HallTreeHall { + id: string; + label: string; + dimsM: number[]; + zones: HallTreeZone[]; +} +export interface HallTreeCenter { + center: number; + label: string; + halls: HallTreeHall[]; +} +export interface HallTreeDto { + centers: HallTreeCenter[]; +} + export interface DesignZone { type: string; // demo | consult | reception | storage … ratioPercent: number; diff --git a/src/frontend/src/screens/floorplan/AutoLayoutDialog.tsx b/src/frontend/src/screens/floorplan/AutoLayoutDialog.tsx index ed31438..98181a6 100644 --- a/src/frontend/src/screens/floorplan/AutoLayoutDialog.tsx +++ b/src/frontend/src/screens/floorplan/AutoLayoutDialog.tsx @@ -1,24 +1,28 @@ -import { useEffect, useState } from 'react'; -import { layoutApi, renderApi } from '../../api/endpoints'; +import { useEffect, useMemo, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { layoutApi, renderApi, hallTreeApi } from '../../api/endpoints'; import { ApiRequestError } from '../../api/client'; import { subscribeRenderJob } from '../../api/websocket'; import { Button } from '../../components/ui/Button'; import { AiLabel } from '../../components/ui/Badge'; import { AiImage } from '../../components/ui/AiImage'; import { ErrorState } from '../../components/ui/States'; -import type { AutoLayoutOption, RenderJobDto } from '../../api/types'; +import type { AutoLayoutOption, HallTreeDto, RenderJobDto } from '../../api/types'; import './auto-layout.css'; /* - * AI 자동배치 (SCR-03 → SCR-04 브릿지). - * 조건 입력 → auto-generate로 1·2·3안 생성 → S7 조감(RenderJob) WebSocket 수신 → - * 라디오 선택 → "이 안으로 편집 시작"(병합/적용). 실제 병합은 상위에서 처리(onApply). + * AI 자동배치 3단계 위저드 (소유자 지시 2026-07-14 "사용자가 쉽게"). + * ① 어디에 — 평면도 트리 선택: 대분류(전시장) > 중분류(홀) > 소분류(구역 1A/1B…) + * ② 어떻게 — 자연어 한 줄 + AI 해석 폼(최종 권위는 폼) + * ③ 고르기 — 3안 비교 → 조감 이미지는 버튼 클릭 시에만 생성(쿼터 보호) → 클릭 확대·저장 → + * "이 안으로 편집 시작" 시 apply-option으로 실제 저장. */ interface AutoLayoutDialogProps { eventId: string; - hallId: string; + hallId: string; // 초기 홀(라우트) — 위저드에서 변경 가능 onClose: () => void; - onApply: (option: AutoLayoutOption) => void; + /** 적용 완료(서버 저장 후) — 적용된 홀로 이동/재조회는 상위 책임. */ + onApply: (option: AutoLayoutOption, appliedHallId: string) => void; } const DEFAULT_REQ = { @@ -30,13 +34,32 @@ const DEFAULT_REQ = { optionCount: 3, }; +type Phase = 'place' | 'form' | 'generating' | 'result'; + export function AutoLayoutDialog({ eventId, hallId, onClose, onApply }: AutoLayoutDialogProps) { + const [phase, setPhase] = useState('place'); const [req, setReq] = useState(DEFAULT_REQ); - const [phase, setPhase] = useState<'form' | 'generating' | 'result'>('form'); const [options, setOptions] = useState([]); const [renderJobs, setRenderJobs] = useState>({}); + const [previewJobIds, setPreviewJobIds] = useState>({}); + const [previewBusy, setPreviewBusy] = useState>({}); const [selected, setSelected] = useState(null); const [error, setError] = useState(null); + const [applying, setApplying] = useState(false); + const [lightbox, setLightbox] = useState<{ url: string; alt: string } | null>(null); + + // ① 평면도 트리 선택 상태 + const treeQuery = useQuery({ queryKey: ['halls-tree'], queryFn: hallTreeApi.get, staleTime: 600_000 }); + const [selHall, setSelHall] = useState(hallId); + const [selZone, setSelZone] = useState(''); // '' = 홀 전체 + const centers = treeQuery.data?.centers ?? []; + const currentCenter = useMemo( + () => centers.find((c) => c.halls.some((h) => h.id === selHall)) ?? centers[0] ?? null, + [centers, selHall], + ); + const [selCenter, setSelCenter] = useState(null); + const activeCenter = centers.find((c) => c.center === selCenter) ?? currentCenter; + const activeHall = activeCenter?.halls.find((h) => h.id === selHall) ?? null; // 자연어 조건 해석 상태 (AI가 문장을 폼 값으로 파싱 — 최종 권위는 폼). const [nlText, setNlText] = useState(''); @@ -49,8 +72,7 @@ export function AutoLayoutDialog({ eventId, hallId, onClose, onApply }: AutoLayo setInterpreting(true); setError(null); try { - const res = await layoutApi.interpret(eventId, hallId, { text: nlText }); - // 서버 클램핑된 조건으로 폼 자동 채움 — 사용자가 확인·수정 가능. + const res = await layoutApi.interpret(eventId, selHall, { text: nlText }); setReq({ targetBoothCount: res.request.targetBoothCount, premiumRatio: res.request.premiumRatio, @@ -62,7 +84,6 @@ export function AutoLayoutDialog({ eventId, hallId, onClose, onApply }: AutoLayo setNlNote(res.interpretedNote); setNlDegraded(res.degraded); } catch { - // 해석 실패도 수동 폼은 유지 — 안내만. setNlNote('AI 해석을 사용할 수 없습니다. 아래 값을 직접 입력해 주세요.'); setNlDegraded(true); } finally { @@ -70,35 +91,54 @@ export function AutoLayoutDialog({ eventId, hallId, onClose, onApply }: AutoLayo } } - // S7 조감 RenderJob 구독 (옵션별 s7RenderJobId). + // 조감 RenderJob 구독 — 버튼으로 발행한 잡만(자동 발행 없음, 2026-07-14). useEffect(() => { - if (phase !== 'result') return; - const unsubs = options - .filter((o) => o.s7RenderJobId) - .map((o) => - subscribeRenderJob(o.s7RenderJobId, (job) => - setRenderJobs((prev) => ({ ...prev, [o.optionId]: job })), - ), - ); - // 폴백: 구독 즉시 현재 상태 1회 조회 - options.forEach(async (o) => { - if (!o.s7RenderJobId) return; + const entries = Object.entries(previewJobIds); + if (entries.length === 0) return; + const unsubs = entries.map(([optionId, jobId]) => + subscribeRenderJob(jobId, (job) => setRenderJobs((prev) => ({ ...prev, [optionId]: job }))), + ); + entries.forEach(async ([optionId, jobId]) => { try { - const job = await renderApi.status(eventId, o.s7RenderJobId); - setRenderJobs((prev) => ({ ...prev, [o.optionId]: job })); + const job = await renderApi.status(eventId, jobId); + setRenderJobs((prev) => ({ ...prev, [optionId]: job })); } catch { - /* 미구현/미완 무시 */ + /* 미완 무시 — WS 수신 대기 */ } }); return () => unsubs.forEach((u) => u()); - }, [phase, options, eventId]); + }, [previewJobIds, eventId]); + + /** 조감 이미지 생성 — 사용자가 버튼을 클릭할 때만 발행(쿼터 보호). */ + async function requestPreview(o: AutoLayoutOption) { + if (previewBusy[o.optionId] || previewJobIds[o.optionId]) return; + setPreviewBusy((prev) => ({ ...prev, [o.optionId]: true })); + try { + const res = await layoutApi.previewS7(eventId, selHall, o.label); + if (res.jobId) { + setPreviewJobIds((prev) => ({ ...prev, [o.optionId]: res.jobId as string })); + } else { + setError('이미지 생성 큐가 준비되지 않았습니다(렌더 인프라 점검 필요). 배치안 선택은 계속할 수 있습니다.'); + } + } catch { + setError('이미지 생성 요청에 실패했습니다. 잠시 후 다시 시도해 주세요.'); + } finally { + setPreviewBusy((prev) => ({ ...prev, [o.optionId]: false })); + } + } async function generate() { setError(null); setPhase('generating'); + setRenderJobs({}); + setPreviewJobIds({}); try { - // ai=true — 각 안에 엔진 metrics 근거 AI 장단점 요약 부가(실패해도 배치는 성공). - const res = await layoutApi.autoGenerate(eventId, hallId, req, true); + const res = await layoutApi.autoGenerate( + eventId, + selHall, + { ...req, zoneId: selZone || null }, + true, + ); setOptions(res); setSelected(res[0]?.optionId ?? null); setPhase('result'); @@ -116,7 +156,35 @@ export function AutoLayoutDialog({ eventId, hallId, onClose, onApply }: AutoLayo } } - const selectedOption = options.find((o) => o.optionId === selected) ?? null; + /** "이 안으로 편집 시작" — apply-option으로 서버에 새 배치안 버전 저장(실배선). */ + async function applySelected() { + const option = options.find((o) => o.optionId === selected); + if (!option || applying) return; + setApplying(true); + setError(null); + try { + await layoutApi.applyOption(eventId, selHall, { + name: `${option.label}(적용)`, + booths: option.booths, + }); + onApply(option, selHall); + } catch (err) { + setError( + err instanceof ApiRequestError + ? `배치안 저장 실패: ${err.message}` + : '배치안 저장 중 오류가 발생했습니다.', + ); + } finally { + setApplying(false); + } + } + + const steps: { key: Phase; label: string }[] = [ + { key: 'place', label: '① 어디에' }, + { key: 'form', label: '② 조건' }, + { key: 'result', label: '③ 배치안 선택' }, + ]; + const stepIndex = phase === 'place' ? 0 : phase === 'form' || phase === 'generating' ? 1 : 2; return (
@@ -125,11 +193,10 @@ export function AutoLayoutDialog({ eventId, hallId, onClose, onApply }: AutoLayo

- AI 자동배치 배치안 생성 + AI 자동배치 평면도 안 자동 배치

- 제약조건을 입력하면 복수 배치안을 생성합니다. 마음에 드는 안을 선택해 편집을 - 시작하세요. + 전시장 평면도를 고르고 조건을 입력하면, 도면 바닥면 안에 배치안을 생성합니다.

+ {/* 단계 표시 */} +
    + {steps.map((s, i) => ( +
  1. + {s.label} +
  2. + ))} +
+ + {phase === 'place' && ( +
+
+
+ + +
+
+ + +
+
+ + +
+
+ {treeQuery.isError && ( +

⚠ 평면도 목록을 불러오지 못해 현재 홀({hallId}) 기준으로 진행합니다.

+ )} +
+ + +
+
+ )} + {phase === 'form' && (
@@ -207,8 +357,8 @@ export function AutoLayoutDialog({ eventId, hallId, onClose, onApply }: AutoLayo
{error && }
- +
+ )}
{o.label}
    @@ -279,19 +459,50 @@ export function AutoLayoutDialog({ eventId, hallId, onClose, onApply }: AutoLayo ); })}
+

+ 생성한 조감 이미지는 자동 저장되어 시각화 갤러리에서 다시 보거나 공사 옥션 참고 + 이미지로 쓸 수 있습니다. +

-
)} + + {/* 확대 이미지 팝업(라이트박스) — 클릭 확대 + 저장(다운로드) */} + {lightbox && ( +
setLightbox(null)} + onKeyDown={(e) => { + if (e.key === 'Escape') setLightbox(null); + }} + tabIndex={-1} + > +
e.stopPropagation()}> + {lightbox.alt} +

+ AI 생성 예상 이미지 — 실제 시공 결과와 다를 수 있습니다. 계약·심사 서류에 사용 불가. +

+
+ + 이미지 저장 + + +
+
+
+ )}
); diff --git a/src/frontend/src/screens/floorplan/BoothLayoutEditorPage.tsx b/src/frontend/src/screens/floorplan/BoothLayoutEditorPage.tsx index b7cd504..c8df1b5 100644 --- a/src/frontend/src/screens/floorplan/BoothLayoutEditorPage.tsx +++ b/src/frontend/src/screens/floorplan/BoothLayoutEditorPage.tsx @@ -1,7 +1,8 @@ import { useCallback, useMemo, useState } from 'react'; -import { useParams } from 'react-router-dom'; +import { useNavigate, useParams } from 'react-router-dom'; import { useQuery } from '@tanstack/react-query'; import { layoutApi } from '../../api/endpoints'; +import { hallAssignApi, type Hall } from '../hallassign/hallAssignApi'; import { ApiRequestError } from '../../api/client'; import { Button } from '../../components/ui/Button'; import { Skeleton } from '../../components/ui/States'; @@ -17,6 +18,23 @@ type RightTab = 'props' | 'validation'; export function BoothLayoutEditorPage() { const { eventId = '', hallId = '' } = useParams(); + const navigate = useNavigate(); + + // 전시장(홀) 선택 실 운영(소유자 지시 2026-07-14) — 홀 마스터 전체를 드롭다운으로 제공. + const hallsQuery = useQuery({ + queryKey: ['halls'], + queryFn: hallAssignApi.halls, + staleTime: 10 * 60 * 1000, + }); + const hallGroups = useMemo(() => { + const groups = new Map(); + for (const h of hallsQuery.data ?? []) { + const list = groups.get(h.exhibitionCenter) ?? []; + list.push(h); + groups.set(h.exhibitionCenter, list); + } + return [...groups.entries()].sort(([a], [b]) => a - b); + }, [hallsQuery.data]); const [layers, setLayers] = useState({ trench: true, @@ -73,10 +91,14 @@ export function BoothLayoutEditorPage() { } }, [eventId, hallId, layout?.version]); - function applyAutoOption(_option: AutoLayoutOption) { - // 병합: 실제 부스 좌표는 백엔드 저장/재조회로 반영. 지금은 재조회 트리거. + function applyAutoOption(_option: AutoLayoutOption, appliedHallId: string) { + // 다이얼로그가 apply-option으로 서버 저장을 마친 뒤 호출된다(2026-07-14 실배선). setAutoOpen(false); - void layoutQuery.refetch(); + if (appliedHallId && appliedHallId !== hallId) { + navigate(`/events/${eventId}/halls/${appliedHallId}/layout`); + } else { + void layoutQuery.refetch(); + } } const summary = layout?.summary; @@ -86,6 +108,25 @@ export function BoothLayoutEditorPage() { {/* 상단 툴바 */}
+

{layout?.name ?? '배치안'}

v{layout?.version ?? '—'} diff --git a/src/frontend/src/screens/floorplan/auto-layout.css b/src/frontend/src/screens/floorplan/auto-layout.css index ba049f7..8e08420 100644 --- a/src/frontend/src/screens/floorplan/auto-layout.css +++ b/src/frontend/src/screens/floorplan/auto-layout.css @@ -176,3 +176,117 @@ color: var(--color-error); font-weight: var(--fw-semibold); } + +/* ── 3단계 위저드(2026-07-14 "사용자가 쉽게" 재구성) ── */ +.kx-auto__steps { + display: flex; + gap: var(--space-2); + list-style: none; + margin: 0; + padding: var(--space-3) var(--space-6) 0; +} +.kx-auto__steps li { + font-size: var(--fs-caption); + font-weight: var(--fw-medium); + color: var(--color-neutral-500); + padding: var(--space-1) var(--space-3); + border-radius: 999px; + background: var(--color-neutral-100); +} +.kx-auto__steps li.is-active { + color: var(--color-on-accent, #fff); + background: var(--color-accent, #0066b3); +} +.kx-auto__steps li.is-done { + color: var(--color-success); + background: var(--color-neutral-100); +} + +/* ① 평면도 트리 선택 */ +.kx-auto__place { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: var(--space-4); +} + +/* ③ 조감 이미지 — 버튼 클릭 전용 생성 CTA */ +.kx-auto__imgcta { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--space-3); + min-height: 160px; + border: 1px dashed var(--color-neutral-300); + border-radius: var(--radius-md); + background: var(--color-neutral-50, #f8f9fb); + text-align: center; +} +.kx-auto__imgcta p { + font-size: var(--fs-caption); + color: var(--color-neutral-500); + margin: 0; +} +.kx-auto__imgwrap.is-zoomable { + cursor: zoom-in; +} +.kx-auto__keepnote { + font-size: var(--fs-caption); + color: var(--color-neutral-500); + margin: var(--space-3) 0 0; +} + +/* 확대 이미지 팝업(라이트박스) + 저장 */ +.kx-lightbox { + position: fixed; + inset: 0; + z-index: 60; + display: flex; + align-items: center; + justify-content: center; + background: rgba(12, 18, 30, 0.78); + padding: var(--space-6); +} +.kx-lightbox__inner { + display: flex; + flex-direction: column; + gap: var(--space-3); + max-width: min(1200px, 94vw); + max-height: 92vh; + background: var(--color-white); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-level2); + padding: var(--space-4); +} +.kx-lightbox__inner img { + max-width: 100%; + max-height: calc(92vh - 120px); + object-fit: contain; + border-radius: var(--radius-md); +} +.kx-lightbox__notice { + font-size: var(--fs-caption); + color: var(--color-neutral-500); + margin: 0; +} +.kx-lightbox__actions { + display: flex; + justify-content: flex-end; + align-items: center; + gap: var(--space-3); +} +.kx-lightbox__download { + display: inline-flex; + align-items: center; + height: 32px; + padding: 0 var(--space-4); + border-radius: var(--radius-md); + background: var(--color-accent, #0066b3); + color: var(--color-on-accent, #fff); + font-size: var(--fs-caption); + font-weight: var(--fw-medium); + text-decoration: none; +} +.kx-lightbox__download:hover { + filter: brightness(1.08); +} diff --git a/src/frontend/src/screens/floorplan/editor.css b/src/frontend/src/screens/floorplan/editor.css index c6d89f8..063454a 100644 --- a/src/frontend/src/screens/floorplan/editor.css +++ b/src/frontend/src/screens/floorplan/editor.css @@ -1,3 +1,5 @@ +@import '../shared.css'; /* .kx-select 등 공용 프리미티브 */ + .kx-editor { display: flex; flex-direction: column; @@ -27,6 +29,11 @@ font-weight: var(--fw-bold); color: var(--color-neutral-900); } +/* 전시장(홀) 선택 — 실 운영 진입점(공용 .kx-select 위 툴바 보정) */ +.kx-editor__hall-select { + max-width: 200px; + height: 32px; +} .kx-editor__version { display: flex; align-items: center; diff --git a/src/frontend/src/screens/floorplan/hallFloorplan.ts b/src/frontend/src/screens/floorplan/hallFloorplan.ts index 1dd59d7..3b4465f 100644 --- a/src/frontend/src/screens/floorplan/hallFloorplan.ts +++ b/src/frontend/src/screens/floorplan/hallFloorplan.ts @@ -6,7 +6,8 @@ * FLOOR_REGIONS: 도면 이미지 안에서 전시 바닥면(1전시장 연노랑·2전시장 라임그린 채색 영역)이 * 차지하는 사각 영역의 이미지 분율 [x0, y0, x1, y1]. 도면 JPG는 여백·범례·미니맵을 포함하므로 * 이 영역을 홀 좌표계(0,0~W,H m)에 정렬해야 부스가 "평면도 안"에 배치돼 보인다(소유자 지시 2026-07-14). - * 값은 색상 블롭 자동 측정(최대 연결 성분) 산출 — 도면 교체 시 재측정 필요. + * 값은 색상 블롭 자동 측정(유의미 블롭 합집합 — 중앙 통로로 갈라진 A/B 구역 포함) 산출. + * 도면 교체 시 재측정 필요. 홀 치수는 V59에서 도면 방향(가로×세로)으로 정렬됨. */ export type FloorRegion = [number, number, number, number]; @@ -18,9 +19,9 @@ const FLOOR_REGIONS: Record = { H5: [0.0528, 0.4416, 0.9028, 0.8686], H6: [0.3222, 0.2162, 0.6917, 0.8176], H7: [0.2583, 0.2027, 0.7306, 0.8547], - H8: [0.275, 0.5034, 0.7194, 0.8446], + H8: [0.275, 0.2095, 0.7194, 0.8446], H9: [0.2833, 0.2061, 0.7111, 0.8581], - H10: [0.2778, 0.5068, 0.7194, 0.8581], + H10: [0.2778, 0.2027, 0.7194, 0.8581], }; function hallKey(hallId: string | null | undefined): string | null {