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 <noreply@anthropic.com>
This commit is contained in:
parent
0cc7f26c56
commit
6c0d364fc9
@ -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.LayoutInterpretRequest;
|
||||||
import com.zioinfo.kintex.module.m2.dto.LayoutInterpretResult;
|
import com.zioinfo.kintex.module.m2.dto.LayoutInterpretResult;
|
||||||
import com.zioinfo.kintex.module.m2.dto.LayoutSaveRequest;
|
import com.zioinfo.kintex.module.m2.dto.LayoutSaveRequest;
|
||||||
|
import com.zioinfo.kintex.module.m2.dto.S7PreviewResult;
|
||||||
import com.zioinfo.kintex.rules.ComplianceReport;
|
import com.zioinfo.kintex.rules.ComplianceReport;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
@ -94,6 +95,20 @@ public class FloorplanController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** POST /apply-option — 자동배치 안 선택/병합 적용(주최자). 새 버전 저장 + 규정 재검증. */
|
/** POST /apply-option — 자동배치 안 선택/병합 적용(주최자). 새 버전 저장 + 규정 재검증. */
|
||||||
|
/**
|
||||||
|
* POST /auto-generate/preview — S7 홀 전경(조감) 이미지 생성 발행(주최자).
|
||||||
|
* 이미지 생성은 이 버튼 경유만 허용(자동 발행 금지, 소유자 지시 2026-07-14). 결과는 render_job 영속 →
|
||||||
|
* 갤러리(boothRef=HALL-{hallId}) 재열람·옥션 참고 이미지 재사용.
|
||||||
|
*/
|
||||||
|
@PostMapping("/auto-generate/preview")
|
||||||
|
public ApiResponse<S7PreviewResult> 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")
|
@PostMapping("/apply-option")
|
||||||
public ApiResponse<LayoutDto> applyOption(@AuthenticationPrincipal KintexPrincipal principal,
|
public ApiResponse<LayoutDto> applyOption(@AuthenticationPrincipal KintexPrincipal principal,
|
||||||
@PathVariable String eventId,
|
@PathVariable String eventId,
|
||||||
|
|||||||
@ -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.LayoutDto;
|
||||||
import com.zioinfo.kintex.module.m2.dto.LayoutInterpretResult;
|
import com.zioinfo.kintex.module.m2.dto.LayoutInterpretResult;
|
||||||
import com.zioinfo.kintex.module.m2.dto.LayoutSaveRequest;
|
import com.zioinfo.kintex.module.m2.dto.LayoutSaveRequest;
|
||||||
|
import com.zioinfo.kintex.module.m2.dto.S7PreviewResult;
|
||||||
import com.zioinfo.kintex.rules.ComplianceReport;
|
import com.zioinfo.kintex.rules.ComplianceReport;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@ -34,6 +35,12 @@ public interface FloorplanService {
|
|||||||
*/
|
*/
|
||||||
List<AutoLayoutOption> autoGenerate(String eventId, String hallId, AutoLayoutRequest request, boolean ai);
|
List<AutoLayoutOption> 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);
|
LayoutDto applyOption(String eventId, String hallId, ApplyOptionRequest request);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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.LayoutInterpretResult;
|
||||||
import com.zioinfo.kintex.module.m2.dto.LayoutSaveRequest;
|
import com.zioinfo.kintex.module.m2.dto.LayoutSaveRequest;
|
||||||
import com.zioinfo.kintex.module.m2.dto.LayoutSummary;
|
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.BoothMapper;
|
||||||
import com.zioinfo.kintex.module.m2.mapper.HallMapper;
|
import com.zioinfo.kintex.module.m2.mapper.HallMapper;
|
||||||
import com.zioinfo.kintex.module.m5.RenderJobService;
|
import com.zioinfo.kintex.module.m5.RenderJobService;
|
||||||
@ -205,7 +206,7 @@ public class FloorplanServiceImpl implements FloorplanService {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public LayoutInterpretResult interpret(String eventId, String hallId, String text) {
|
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()) {
|
if (text == null || text.isBlank()) {
|
||||||
return new LayoutInterpretResult(fallback, "입력이 비어 있어 기본 조건을 적용했습니다.", true, "none");
|
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);
|
int optionCount = clampInt(intField(node, "optionCount", fallback.optionCount()), OPTION_MIN, OPTION_MAX);
|
||||||
|
|
||||||
AutoLayoutRequest parsed = new AutoLayoutRequest(
|
AutoLayoutRequest parsed = new AutoLayoutRequest(
|
||||||
boothCount, premiumRatio, stageCount, loungeCount, mainEntranceCount, optionCount);
|
boothCount, premiumRatio, stageCount, loungeCount, mainEntranceCount, optionCount, null);
|
||||||
String note = String.format(
|
String note = String.format(
|
||||||
"부스 %d · 프리미엄 %d%% · 무대 %d · 라운지 %d · 주출입구 %d · %d안으로 해석했습니다.",
|
"부스 %d · 프리미엄 %d%% · 무대 %d · 라운지 %d · 주출입구 %d · %d안으로 해석했습니다.",
|
||||||
boothCount, Math.round(premiumRatio * 100), stageCount, loungeCount, mainEntranceCount, optionCount);
|
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 hallW = hall.dimsM().get(0);
|
||||||
double hallD = hall.dimsM().get(1);
|
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<String, Object> 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안: 통로 폭·부스 규격을 달리해 서로 구분되는 후보를 결정적으로 생성.
|
// 3안: 통로 폭·부스 규격을 달리해 서로 구분되는 후보를 결정적으로 생성.
|
||||||
double[][] variants = {
|
double[][] variants = {
|
||||||
{3.0, 3.0, 3.0}, // A: 표준 3×3, 통로 3.0m
|
{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++) {
|
for (int i = 0; i < optionCount; i++) {
|
||||||
double[] vp = variants[i % variants.length];
|
double[] vp = variants[i % variants.length];
|
||||||
char tag = (char) ('A' + i);
|
char tag = (char) ('A' + i);
|
||||||
List<BoothDto> booths = packBooths(vp[0], vp[1], vp[2], hallW, hallD, request, tag);
|
List<BoothDto> booths = packBooths(vp[0], vp[1], vp[2], hallW, hallD, bounds, request, tag);
|
||||||
|
|
||||||
double salesArea = booths.stream()
|
double salesArea = booths.stream()
|
||||||
.mapToDouble(b -> vp[0] * vp[1]).sum();
|
.mapToDouble(b -> vp[0] * vp[1]).sum();
|
||||||
@ -290,11 +303,12 @@ public class FloorplanServiceImpl implements FloorplanService {
|
|||||||
booths.size(), request.targetBoothCount(), round2(salesArea),
|
booths.size(), request.targetBoothCount(), round2(salesArea),
|
||||||
vp[2], 0, 0);
|
vp[2], 0, 0);
|
||||||
|
|
||||||
String s7JobId = publishS7Preview(eventId, hallId, hall, tag);
|
// S7 조감 이미지는 자동 발행하지 않는다 — 사용자가 버튼 클릭 시에만 생성(소유자 지시 2026-07-14).
|
||||||
options.add(new AutoLayoutOption("opt-" + tag, "배치안 " + tag, summary, booths, s7JobId));
|
String label = "배치안 " + tag + (zoneLabel != null ? " · " + zoneLabel : "");
|
||||||
|
options.add(new AutoLayoutOption("opt-" + tag, label, summary, booths, null));
|
||||||
}
|
}
|
||||||
log.info("자동배치 생성: event={} hall={} options={} target={} ai={}",
|
log.info("자동배치 생성: event={} hall={} zone={} options={} target={} ai={}",
|
||||||
eventId, hallId, options.size(), request.targetBoothCount(), ai);
|
eventId, hallId, request.zoneId(), options.size(), request.targetBoothCount(), ai);
|
||||||
|
|
||||||
// AI 부가 요약(ai=true) — 엔진 산출 metrics만 근거. 실패해도 배치는 그대로 반환(요약만 생략).
|
// AI 부가 요약(ai=true) — 엔진 산출 metrics만 근거. 실패해도 배치는 그대로 반환(요약만 생략).
|
||||||
return ai ? attachAiSummaries(options, hall) : options;
|
return ai ? attachAiSummaries(options, hall) : options;
|
||||||
@ -360,20 +374,24 @@ public class FloorplanServiceImpl implements FloorplanService {
|
|||||||
* 배치안이 실제 전시 평면도처럼 홀 바닥면 안에서 구획된다(소유자 지시 2026-07-14).
|
* 배치안이 실제 전시 평면도처럼 홀 바닥면 안에서 구획된다(소유자 지시 2026-07-14).
|
||||||
*/
|
*/
|
||||||
private List<BoothDto> packBooths(double boothW, double boothD, double aisle,
|
private List<BoothDto> 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<BoothDto> booths = new ArrayList<>();
|
List<BoothDto> booths = new ArrayList<>();
|
||||||
int target = req.targetBoothCount();
|
int target = req.targetBoothCount();
|
||||||
int premiumTarget = (int) Math.round(target * clamp01(req.premiumRatio()));
|
int premiumTarget = (int) Math.round(target * clamp01(req.premiumRatio()));
|
||||||
double stepX = boothW + aisle;
|
double stepX = boothW + aisle;
|
||||||
double stepY = boothD + aisle;
|
double stepY = boothD + aisle;
|
||||||
double usableW = hallW - 2 * PERIMETER_MARGIN_M;
|
// 패킹 범위 = 선택 구역(bounds) ∩ 외곽 주통로 여유. 구역 내부 경계에는 1.5m 이격.
|
||||||
double usableD = hallD - 2 * PERIMETER_MARGIN_M;
|
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<double[]> reserved = reservedZones(hallW, hallD, aisle, req);
|
List<double[]> reserved = reservedZones(hallW, hallD, aisle, req);
|
||||||
|
|
||||||
int index = 0;
|
int index = 0;
|
||||||
for (double y = PERIMETER_MARGIN_M; y + boothD <= PERIMETER_MARGIN_M + usableD && index < target; y += stepY) {
|
for (double y = startY; y + boothD <= endY && index < target; y += stepY) {
|
||||||
for (double x = PERIMETER_MARGIN_M; x + boothW <= PERIMETER_MARGIN_M + usableW && index < target; x += stepX) {
|
for (double x = startX; x + boothW <= endX && index < target; x += stepX) {
|
||||||
if (intersectsAny(reserved, x, y, x + boothW, y + boothD)) {
|
if (intersectsAny(reserved, x, y, x + boothW, y + boothD)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -442,8 +460,24 @@ public class FloorplanServiceImpl implements FloorplanService {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** S7 홀 전경(조감) 프리뷰 발행 — 렌더 인프라 장애 시에도 자동배치가 성립하도록 degraded(null) 허용. */
|
/**
|
||||||
private String publishS7Preview(String eventId, String hallId, HallInfo hall, char tag) {
|
* S7 홀 전경(조감) 이미지 생성 — <b>사용자 버튼 클릭 시에만</b> 호출된다(자동 발행 금지, 소유자 지시 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 {
|
try {
|
||||||
Map<String, Object> scene = new LinkedHashMap<>();
|
Map<String, Object> scene = new LinkedHashMap<>();
|
||||||
Map<String, Object> hallScene = new LinkedHashMap<>();
|
Map<String, Object> hallScene = new LinkedHashMap<>();
|
||||||
@ -453,7 +487,7 @@ public class FloorplanServiceImpl implements FloorplanService {
|
|||||||
hallScene.put("ceiling_m", hall.ceilingM());
|
hallScene.put("ceiling_m", hall.ceilingM());
|
||||||
}
|
}
|
||||||
scene.put("hall", hallScene);
|
scene.put("hall", hallScene);
|
||||||
scene.put("option", "배치안 " + tag);
|
scene.put("option", optionLabel);
|
||||||
RenderJobRequest req = new RenderJobRequest("S7", scene, null);
|
RenderJobRequest req = new RenderJobRequest("S7", scene, null);
|
||||||
return renderJobService.publish(eventId, "HALL-" + hallId, req).jobId();
|
return renderJobService.publish(eventId, "HALL-" + hallId, req).jobId();
|
||||||
} catch (RuntimeException e) {
|
} 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<String, Object> header) {
|
private LayoutDto assembleLayout(String eventId, String hallId, Map<String, Object> header) {
|
||||||
|
|||||||
@ -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<HallTreeDto> tree(@AuthenticationPrincipal KintexPrincipal principal) {
|
||||||
|
guard.require(principal);
|
||||||
|
|
||||||
|
// 구역을 홀별로 그룹핑
|
||||||
|
Map<String, List<HallTreeDto.ZoneNode>> zonesByHall = new LinkedHashMap<>();
|
||||||
|
for (Map<String, Object> 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<Integer, List<HallTreeDto.HallNode>> hallsByCenter = new LinkedHashMap<>();
|
||||||
|
for (Map<String, Object> h : hallMapper.findAllHalls()) {
|
||||||
|
String hallId = str(h.get("hallId"));
|
||||||
|
int center = ((Number) h.get("center")).intValue();
|
||||||
|
List<Double> 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<HallTreeDto.Center> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -11,6 +11,8 @@ import jakarta.validation.constraints.Min;
|
|||||||
* @param loungeCount 라운지 수
|
* @param loungeCount 라운지 수
|
||||||
* @param mainEntranceCount 주출입구 수
|
* @param mainEntranceCount 주출입구 수
|
||||||
* @param optionCount 생성할 배치안 개수(기본 3)
|
* @param optionCount 생성할 배치안 개수(기본 3)
|
||||||
|
* @param zoneId 선택 구역(소분류, V60 hall_zone.id — 예 "H1-A"). null이면 홀 전체.
|
||||||
|
* 지정 시 해당 구역 경계 안에서만 부스를 패킹한다(평면도 트리 선택, 2026-07-14).
|
||||||
*/
|
*/
|
||||||
public record AutoLayoutRequest(
|
public record AutoLayoutRequest(
|
||||||
@Min(1) int targetBoothCount,
|
@Min(1) int targetBoothCount,
|
||||||
@ -18,6 +20,7 @@ public record AutoLayoutRequest(
|
|||||||
int stageCount,
|
int stageCount,
|
||||||
int loungeCount,
|
int loungeCount,
|
||||||
int mainEntranceCount,
|
int mainEntranceCount,
|
||||||
@Min(1) int optionCount
|
@Min(1) int optionCount,
|
||||||
|
String zoneId
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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<Center> centers) {
|
||||||
|
|
||||||
|
/** 대분류 — 전시장(제1/제2). */
|
||||||
|
public record Center(int center, String label, List<HallNode> halls) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 중분류 — 홀. dimsM=[폭, 깊이](m, V59 도면 방향 정렬). */
|
||||||
|
public record HallNode(String id, String label, List<Double> dimsM, List<ZoneNode> zones) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 소분류 — 홀 내부 구역(예: 1A/1B). */
|
||||||
|
public record ZoneNode(String id, String label) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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) {
|
||||||
|
}
|
||||||
@ -23,4 +23,13 @@ public interface HallMapper {
|
|||||||
|
|
||||||
/** 홀 비상구 좌표·이격 목록 — 캔버스 비상구 표시용. */
|
/** 홀 비상구 좌표·이격 목록 — 캔버스 비상구 표시용. */
|
||||||
List<Map<String, Object>> findExits(@Param("hallId") String hallId);
|
List<Map<String, Object>> findExits(@Param("hallId") String hallId);
|
||||||
|
|
||||||
|
/** 홀 마스터 전체(전시장 순) — 평면도 선택 트리 대분류·중분류 근거(V60). */
|
||||||
|
List<Map<String, Object>> findAllHalls();
|
||||||
|
|
||||||
|
/** 홀 구역(소분류) 전체 — 평면도 선택 트리(V60 hall_zone). */
|
||||||
|
List<Map<String, Object>> findAllZones();
|
||||||
|
|
||||||
|
/** 구역 단건(경계 좌표) — 구역 제한 자동배치 근거. 없으면 null. */
|
||||||
|
Map<String, Object> findZone(@Param("zoneId") String zoneId);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
@ -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;
|
||||||
@ -50,4 +50,36 @@
|
|||||||
ORDER BY id
|
ORDER BY id
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
<!-- 홀 마스터 전체 — 평면도 선택 트리(대분류 전시장 > 중분류 홀). OUTDOOR 제외. -->
|
||||||
|
<select id="findAllHalls" resultType="map">
|
||||||
|
SELECT id AS "hallId",
|
||||||
|
exhibition_center AS "center",
|
||||||
|
label,
|
||||||
|
width_m AS "widthM",
|
||||||
|
depth_m AS "depthM"
|
||||||
|
FROM hall
|
||||||
|
WHERE id LIKE 'H%'
|
||||||
|
ORDER BY exhibition_center, length(id), id
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- 홀 구역(소분류) 전체 — V60 hall_zone. -->
|
||||||
|
<select id="findAllZones" resultType="map">
|
||||||
|
SELECT id AS "zoneId",
|
||||||
|
hall_id AS "hallId",
|
||||||
|
label,
|
||||||
|
x0, y0, x1, y1
|
||||||
|
FROM hall_zone
|
||||||
|
ORDER BY hall_id, sort
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- 구역 단건(경계) — 구역 제한 자동배치. -->
|
||||||
|
<select id="findZone" resultType="map">
|
||||||
|
SELECT id AS "zoneId",
|
||||||
|
hall_id AS "hallId",
|
||||||
|
label,
|
||||||
|
x0, y0, x1, y1
|
||||||
|
FROM hall_zone
|
||||||
|
WHERE id = #{zoneId}
|
||||||
|
</select>
|
||||||
|
|
||||||
</mapper>
|
</mapper>
|
||||||
|
|||||||
@ -12,7 +12,10 @@ import type {
|
|||||||
AnalyticsPerspective,
|
AnalyticsPerspective,
|
||||||
AutoLayoutOption,
|
AutoLayoutOption,
|
||||||
AutoLayoutRequest,
|
AutoLayoutRequest,
|
||||||
|
BoothDto,
|
||||||
ComplianceReport,
|
ComplianceReport,
|
||||||
|
HallTreeDto,
|
||||||
|
S7PreviewResult,
|
||||||
DashboardData,
|
DashboardData,
|
||||||
OpsData,
|
OpsData,
|
||||||
DesignPlanDto,
|
DesignPlanDto,
|
||||||
@ -175,6 +178,21 @@ export const layoutApi = {
|
|||||||
`${layoutBase(eventId, hallId)}/auto-generate${ai ? '?ai=true' : ''}`,
|
`${layoutBase(eventId, hallId)}/auto-generate${ai ? '?ai=true' : ''}`,
|
||||||
body,
|
body,
|
||||||
),
|
),
|
||||||
|
// "이 안으로 편집 시작" 실저장 — 선택 안 부스를 새 배치안 버전으로 영속(규정 재검증 포함).
|
||||||
|
applyOption: (eventId: string, hallId: string, body: { name: string; booths: BoothDto[] }) =>
|
||||||
|
api.post<LayoutDto>(`${layoutBase(eventId, hallId)}/apply-option`, body),
|
||||||
|
// S7 홀 전경 이미지 생성 — 버튼 클릭 시에만 발행(자동 발행 금지, 2026-07-14).
|
||||||
|
previewS7: (eventId: string, hallId: string, option?: string) =>
|
||||||
|
api.post<S7PreviewResult>(
|
||||||
|
`${layoutBase(eventId, hallId)}/auto-generate/preview${
|
||||||
|
option ? `?option=${encodeURIComponent(option)}` : ''
|
||||||
|
}`,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 평면도 선택 트리 — 대분류(전시장) > 중분류(홀) > 소분류(구역) (V60)
|
||||||
|
export const hallTreeApi = {
|
||||||
|
get: () => api.get<HallTreeDto>('/api/halls/tree'),
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── M3 부스 설계 스튜디오 (SCR-06/09) ──
|
// ── M3 부스 설계 스튜디오 (SCR-06/09) ──
|
||||||
|
|||||||
@ -218,12 +218,15 @@ export interface AutoLayoutRequest {
|
|||||||
loungeCount: number;
|
loungeCount: number;
|
||||||
mainEntranceCount: number;
|
mainEntranceCount: number;
|
||||||
optionCount: number;
|
optionCount: number;
|
||||||
|
/** 선택 구역(소분류, hall_zone.id 예 "H1-A"). null/미지정이면 홀 전체(2026-07-14 트리 선택). */
|
||||||
|
zoneId?: string | null;
|
||||||
}
|
}
|
||||||
export interface AutoLayoutOption {
|
export interface AutoLayoutOption {
|
||||||
optionId: string;
|
optionId: string;
|
||||||
label: string;
|
label: string;
|
||||||
summary: LayoutSummary;
|
summary: LayoutSummary;
|
||||||
s7RenderJobId: string;
|
booths: BoothDto[]; // apply-option 저장 페이로드 — "이 안으로 편집 시작" 실배선
|
||||||
|
s7RenderJobId: string | null; // 항상 null — 이미지는 버튼 클릭 시 previewS7로만 생성(2026-07-14)
|
||||||
aiSummary?: string; // ai=true 시에만: 엔진 metrics 근거 AI 장단점 요약(WISE AI). 없으면 미표시.
|
aiSummary?: string; // ai=true 시에만: 엔진 metrics 근거 AI 장단점 요약(WISE AI). 없으면 미표시.
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -239,6 +242,32 @@ export interface LayoutInterpretResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── 4. M3 부스 설계 스튜디오 (SCR-06/09) ──
|
// ── 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 {
|
export interface DesignZone {
|
||||||
type: string; // demo | consult | reception | storage …
|
type: string; // demo | consult | reception | storage …
|
||||||
ratioPercent: number;
|
ratioPercent: number;
|
||||||
|
|||||||
@ -1,24 +1,28 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { layoutApi, renderApi } from '../../api/endpoints';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { layoutApi, renderApi, hallTreeApi } from '../../api/endpoints';
|
||||||
import { ApiRequestError } from '../../api/client';
|
import { ApiRequestError } from '../../api/client';
|
||||||
import { subscribeRenderJob } from '../../api/websocket';
|
import { subscribeRenderJob } from '../../api/websocket';
|
||||||
import { Button } from '../../components/ui/Button';
|
import { Button } from '../../components/ui/Button';
|
||||||
import { AiLabel } from '../../components/ui/Badge';
|
import { AiLabel } from '../../components/ui/Badge';
|
||||||
import { AiImage } from '../../components/ui/AiImage';
|
import { AiImage } from '../../components/ui/AiImage';
|
||||||
import { ErrorState } from '../../components/ui/States';
|
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';
|
import './auto-layout.css';
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* AI 자동배치 (SCR-03 → SCR-04 브릿지).
|
* AI 자동배치 3단계 위저드 (소유자 지시 2026-07-14 "사용자가 쉽게").
|
||||||
* 조건 입력 → auto-generate로 1·2·3안 생성 → S7 조감(RenderJob) WebSocket 수신 →
|
* ① 어디에 — 평면도 트리 선택: 대분류(전시장) > 중분류(홀) > 소분류(구역 1A/1B…)
|
||||||
* 라디오 선택 → "이 안으로 편집 시작"(병합/적용). 실제 병합은 상위에서 처리(onApply).
|
* ② 어떻게 — 자연어 한 줄 + AI 해석 폼(최종 권위는 폼)
|
||||||
|
* ③ 고르기 — 3안 비교 → 조감 이미지는 버튼 클릭 시에만 생성(쿼터 보호) → 클릭 확대·저장 →
|
||||||
|
* "이 안으로 편집 시작" 시 apply-option으로 실제 저장.
|
||||||
*/
|
*/
|
||||||
interface AutoLayoutDialogProps {
|
interface AutoLayoutDialogProps {
|
||||||
eventId: string;
|
eventId: string;
|
||||||
hallId: string;
|
hallId: string; // 초기 홀(라우트) — 위저드에서 변경 가능
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onApply: (option: AutoLayoutOption) => void;
|
/** 적용 완료(서버 저장 후) — 적용된 홀로 이동/재조회는 상위 책임. */
|
||||||
|
onApply: (option: AutoLayoutOption, appliedHallId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_REQ = {
|
const DEFAULT_REQ = {
|
||||||
@ -30,13 +34,32 @@ const DEFAULT_REQ = {
|
|||||||
optionCount: 3,
|
optionCount: 3,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type Phase = 'place' | 'form' | 'generating' | 'result';
|
||||||
|
|
||||||
export function AutoLayoutDialog({ eventId, hallId, onClose, onApply }: AutoLayoutDialogProps) {
|
export function AutoLayoutDialog({ eventId, hallId, onClose, onApply }: AutoLayoutDialogProps) {
|
||||||
|
const [phase, setPhase] = useState<Phase>('place');
|
||||||
const [req, setReq] = useState(DEFAULT_REQ);
|
const [req, setReq] = useState(DEFAULT_REQ);
|
||||||
const [phase, setPhase] = useState<'form' | 'generating' | 'result'>('form');
|
|
||||||
const [options, setOptions] = useState<AutoLayoutOption[]>([]);
|
const [options, setOptions] = useState<AutoLayoutOption[]>([]);
|
||||||
const [renderJobs, setRenderJobs] = useState<Record<string, RenderJobDto>>({});
|
const [renderJobs, setRenderJobs] = useState<Record<string, RenderJobDto>>({});
|
||||||
|
const [previewJobIds, setPreviewJobIds] = useState<Record<string, string>>({});
|
||||||
|
const [previewBusy, setPreviewBusy] = useState<Record<string, boolean>>({});
|
||||||
const [selected, setSelected] = useState<string | null>(null);
|
const [selected, setSelected] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [applying, setApplying] = useState(false);
|
||||||
|
const [lightbox, setLightbox] = useState<{ url: string; alt: string } | null>(null);
|
||||||
|
|
||||||
|
// ① 평면도 트리 선택 상태
|
||||||
|
const treeQuery = useQuery<HallTreeDto>({ queryKey: ['halls-tree'], queryFn: hallTreeApi.get, staleTime: 600_000 });
|
||||||
|
const [selHall, setSelHall] = useState(hallId);
|
||||||
|
const [selZone, setSelZone] = useState<string>(''); // '' = 홀 전체
|
||||||
|
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<number | null>(null);
|
||||||
|
const activeCenter = centers.find((c) => c.center === selCenter) ?? currentCenter;
|
||||||
|
const activeHall = activeCenter?.halls.find((h) => h.id === selHall) ?? null;
|
||||||
|
|
||||||
// 자연어 조건 해석 상태 (AI가 문장을 폼 값으로 파싱 — 최종 권위는 폼).
|
// 자연어 조건 해석 상태 (AI가 문장을 폼 값으로 파싱 — 최종 권위는 폼).
|
||||||
const [nlText, setNlText] = useState('');
|
const [nlText, setNlText] = useState('');
|
||||||
@ -49,8 +72,7 @@ export function AutoLayoutDialog({ eventId, hallId, onClose, onApply }: AutoLayo
|
|||||||
setInterpreting(true);
|
setInterpreting(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const res = await layoutApi.interpret(eventId, hallId, { text: nlText });
|
const res = await layoutApi.interpret(eventId, selHall, { text: nlText });
|
||||||
// 서버 클램핑된 조건으로 폼 자동 채움 — 사용자가 확인·수정 가능.
|
|
||||||
setReq({
|
setReq({
|
||||||
targetBoothCount: res.request.targetBoothCount,
|
targetBoothCount: res.request.targetBoothCount,
|
||||||
premiumRatio: res.request.premiumRatio,
|
premiumRatio: res.request.premiumRatio,
|
||||||
@ -62,7 +84,6 @@ export function AutoLayoutDialog({ eventId, hallId, onClose, onApply }: AutoLayo
|
|||||||
setNlNote(res.interpretedNote);
|
setNlNote(res.interpretedNote);
|
||||||
setNlDegraded(res.degraded);
|
setNlDegraded(res.degraded);
|
||||||
} catch {
|
} catch {
|
||||||
// 해석 실패도 수동 폼은 유지 — 안내만.
|
|
||||||
setNlNote('AI 해석을 사용할 수 없습니다. 아래 값을 직접 입력해 주세요.');
|
setNlNote('AI 해석을 사용할 수 없습니다. 아래 값을 직접 입력해 주세요.');
|
||||||
setNlDegraded(true);
|
setNlDegraded(true);
|
||||||
} finally {
|
} finally {
|
||||||
@ -70,35 +91,54 @@ export function AutoLayoutDialog({ eventId, hallId, onClose, onApply }: AutoLayo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// S7 조감 RenderJob 구독 (옵션별 s7RenderJobId).
|
// 조감 RenderJob 구독 — 버튼으로 발행한 잡만(자동 발행 없음, 2026-07-14).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (phase !== 'result') return;
|
const entries = Object.entries(previewJobIds);
|
||||||
const unsubs = options
|
if (entries.length === 0) return;
|
||||||
.filter((o) => o.s7RenderJobId)
|
const unsubs = entries.map(([optionId, jobId]) =>
|
||||||
.map((o) =>
|
subscribeRenderJob(jobId, (job) => setRenderJobs((prev) => ({ ...prev, [optionId]: job }))),
|
||||||
subscribeRenderJob(o.s7RenderJobId, (job) =>
|
);
|
||||||
setRenderJobs((prev) => ({ ...prev, [o.optionId]: job })),
|
entries.forEach(async ([optionId, jobId]) => {
|
||||||
),
|
|
||||||
);
|
|
||||||
// 폴백: 구독 즉시 현재 상태 1회 조회
|
|
||||||
options.forEach(async (o) => {
|
|
||||||
if (!o.s7RenderJobId) return;
|
|
||||||
try {
|
try {
|
||||||
const job = await renderApi.status(eventId, o.s7RenderJobId);
|
const job = await renderApi.status(eventId, jobId);
|
||||||
setRenderJobs((prev) => ({ ...prev, [o.optionId]: job }));
|
setRenderJobs((prev) => ({ ...prev, [optionId]: job }));
|
||||||
} catch {
|
} catch {
|
||||||
/* 미구현/미완 무시 */
|
/* 미완 무시 — WS 수신 대기 */
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return () => unsubs.forEach((u) => u());
|
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() {
|
async function generate() {
|
||||||
setError(null);
|
setError(null);
|
||||||
setPhase('generating');
|
setPhase('generating');
|
||||||
|
setRenderJobs({});
|
||||||
|
setPreviewJobIds({});
|
||||||
try {
|
try {
|
||||||
// ai=true — 각 안에 엔진 metrics 근거 AI 장단점 요약 부가(실패해도 배치는 성공).
|
const res = await layoutApi.autoGenerate(
|
||||||
const res = await layoutApi.autoGenerate(eventId, hallId, req, true);
|
eventId,
|
||||||
|
selHall,
|
||||||
|
{ ...req, zoneId: selZone || null },
|
||||||
|
true,
|
||||||
|
);
|
||||||
setOptions(res);
|
setOptions(res);
|
||||||
setSelected(res[0]?.optionId ?? null);
|
setSelected(res[0]?.optionId ?? null);
|
||||||
setPhase('result');
|
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 (
|
return (
|
||||||
<div className="kx-modal" role="dialog" aria-modal="true" aria-labelledby="auto-title">
|
<div className="kx-modal" role="dialog" aria-modal="true" aria-labelledby="auto-title">
|
||||||
@ -125,11 +193,10 @@ export function AutoLayoutDialog({ eventId, hallId, onClose, onApply }: AutoLayo
|
|||||||
<header className="kx-auto__header">
|
<header className="kx-auto__header">
|
||||||
<div>
|
<div>
|
||||||
<h3 id="auto-title" className="kx-auto__title">
|
<h3 id="auto-title" className="kx-auto__title">
|
||||||
<AiLabel>AI 자동배치</AiLabel> 배치안 생성
|
<AiLabel>AI 자동배치</AiLabel> 평면도 안 자동 배치
|
||||||
</h3>
|
</h3>
|
||||||
<p className="kx-auto__sub">
|
<p className="kx-auto__sub">
|
||||||
제약조건을 입력하면 복수 배치안을 생성합니다. 마음에 드는 안을 선택해 편집을
|
전시장 평면도를 고르고 조건을 입력하면, 도면 바닥면 안에 배치안을 생성합니다.
|
||||||
시작하세요.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button className="kx-auto__close" onClick={onClose} aria-label="닫기">
|
<button className="kx-auto__close" onClick={onClose} aria-label="닫기">
|
||||||
@ -137,6 +204,89 @@ export function AutoLayoutDialog({ eventId, hallId, onClose, onApply }: AutoLayo
|
|||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{/* 단계 표시 */}
|
||||||
|
<ol className="kx-auto__steps" aria-label="진행 단계">
|
||||||
|
{steps.map((s, i) => (
|
||||||
|
<li key={s.key} className={i === stepIndex ? 'is-active' : i < stepIndex ? 'is-done' : ''}>
|
||||||
|
{s.label}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
{phase === 'place' && (
|
||||||
|
<div className="kx-auto__body">
|
||||||
|
<div className="kx-auto__place">
|
||||||
|
<div className="kx-field">
|
||||||
|
<label htmlFor="auto-center">전시장 (대분류)</label>
|
||||||
|
<select
|
||||||
|
id="auto-center"
|
||||||
|
className="kx-select"
|
||||||
|
value={activeCenter?.center ?? ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const c = centers.find((x) => x.center === Number(e.target.value));
|
||||||
|
setSelCenter(c?.center ?? null);
|
||||||
|
const first = c?.halls[0];
|
||||||
|
if (first) {
|
||||||
|
setSelHall(first.id);
|
||||||
|
setSelZone('');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{centers.map((c) => (
|
||||||
|
<option key={c.center} value={c.center}>
|
||||||
|
{c.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="kx-field">
|
||||||
|
<label htmlFor="auto-hall">홀 (중분류)</label>
|
||||||
|
<select
|
||||||
|
id="auto-hall"
|
||||||
|
className="kx-select"
|
||||||
|
value={selHall}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSelHall(e.target.value);
|
||||||
|
setSelZone('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(activeCenter?.halls ?? []).map((h) => (
|
||||||
|
<option key={h.id} value={h.id}>
|
||||||
|
{h.label}
|
||||||
|
{h.dimsM.length >= 2 ? ` — ${h.dimsM[0]}×${h.dimsM[1]}m` : ''}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="kx-field">
|
||||||
|
<label htmlFor="auto-zone">구역 (소분류)</label>
|
||||||
|
<select
|
||||||
|
id="auto-zone"
|
||||||
|
className="kx-select"
|
||||||
|
value={selZone}
|
||||||
|
onChange={(e) => setSelZone(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">홀 전체</option>
|
||||||
|
{(activeHall?.zones ?? []).map((z) => (
|
||||||
|
<option key={z.id} value={z.id}>
|
||||||
|
{z.label} 구역
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{treeQuery.isError && (
|
||||||
|
<p className="kx-auto__nl-note is-degraded">⚠ 평면도 목록을 불러오지 못해 현재 홀({hallId}) 기준으로 진행합니다.</p>
|
||||||
|
)}
|
||||||
|
<div className="kx-modal__actions">
|
||||||
|
<Button variant="ghost" onClick={onClose}>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => setPhase('form')}>다음 — 조건 입력</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{phase === 'form' && (
|
{phase === 'form' && (
|
||||||
<div className="kx-auto__body">
|
<div className="kx-auto__body">
|
||||||
<div className="kx-auto__nl">
|
<div className="kx-auto__nl">
|
||||||
@ -207,8 +357,8 @@ export function AutoLayoutDialog({ eventId, hallId, onClose, onApply }: AutoLayo
|
|||||||
</div>
|
</div>
|
||||||
{error && <ErrorState message={error} onRetry={generate} />}
|
{error && <ErrorState message={error} onRetry={generate} />}
|
||||||
<div className="kx-modal__actions">
|
<div className="kx-modal__actions">
|
||||||
<Button variant="ghost" onClick={onClose}>
|
<Button variant="ghost" onClick={() => setPhase('place')}>
|
||||||
취소
|
← 평면도 다시 선택
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ai" leadingIcon="✦" onClick={generate}>
|
<Button variant="ai" leadingIcon="✦" onClick={generate}>
|
||||||
배치안 생성
|
배치안 생성
|
||||||
@ -226,9 +376,11 @@ export function AutoLayoutDialog({ eventId, hallId, onClose, onApply }: AutoLayo
|
|||||||
|
|
||||||
{phase === 'result' && (
|
{phase === 'result' && (
|
||||||
<div className="kx-auto__body">
|
<div className="kx-auto__body">
|
||||||
|
{error && <p className="kx-auto__nl-note is-degraded">⚠ {error}</p>}
|
||||||
<div className="kx-auto__grid">
|
<div className="kx-auto__grid">
|
||||||
{options.map((o) => {
|
{options.map((o) => {
|
||||||
const job = renderJobs[o.optionId];
|
const job = renderJobs[o.optionId];
|
||||||
|
const jobRequested = Boolean(previewJobIds[o.optionId]);
|
||||||
const isSel = o.optionId === selected;
|
const isSel = o.optionId === selected;
|
||||||
return (
|
return (
|
||||||
<label
|
<label
|
||||||
@ -241,19 +393,47 @@ export function AutoLayoutDialog({ eventId, hallId, onClose, onApply }: AutoLayo
|
|||||||
checked={isSel}
|
checked={isSel}
|
||||||
onChange={() => setSelected(o.optionId)}
|
onChange={() => setSelected(o.optionId)}
|
||||||
/>
|
/>
|
||||||
<AiImage
|
{jobRequested ? (
|
||||||
imageUrl={job?.imageUrl ?? null}
|
<div
|
||||||
status={job?.status ?? 'QUEUED'}
|
className={`kx-auto__imgwrap ${job?.imageUrl ? 'is-zoomable' : ''}`}
|
||||||
watermarkText={
|
onClick={(e) => {
|
||||||
job?.watermarkText ?? 'AI 생성 예상 이미지 — 실제 시공 결과와 다를 수 있습니다'
|
if (job?.imageUrl) {
|
||||||
}
|
e.preventDefault();
|
||||||
notice={
|
setLightbox({ url: job.imageUrl, alt: `${o.label} 조감` });
|
||||||
job?.notice ??
|
}
|
||||||
'AI 생성 이미지는 계약·심사 서류에 사용할 수 없습니다 — 시공 기준은 도면입니다'
|
}}
|
||||||
}
|
title={job?.imageUrl ? '클릭하면 확대 이미지를 봅니다' : undefined}
|
||||||
shotLabel="S7 홀 전경"
|
>
|
||||||
alt={`${o.label} 조감`}
|
<AiImage
|
||||||
/>
|
imageUrl={job?.imageUrl ?? null}
|
||||||
|
status={job?.status ?? 'QUEUED'}
|
||||||
|
watermarkText={
|
||||||
|
job?.watermarkText ?? 'AI 생성 예상 이미지 — 실제 시공 결과와 다를 수 있습니다'
|
||||||
|
}
|
||||||
|
notice={
|
||||||
|
job?.notice ??
|
||||||
|
'AI 생성 이미지는 계약·심사 서류에 사용할 수 없습니다 — 시공 기준은 도면입니다'
|
||||||
|
}
|
||||||
|
shotLabel="S7 홀 전경"
|
||||||
|
alt={`${o.label} 조감`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="kx-auto__imgcta">
|
||||||
|
<p>조감 이미지는 필요할 때만 생성합니다.</p>
|
||||||
|
<Button
|
||||||
|
variant="ai"
|
||||||
|
leadingIcon="🖼"
|
||||||
|
disabled={Boolean(previewBusy[o.optionId])}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
requestPreview(o);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{previewBusy[o.optionId] ? '요청 중…' : '조감 이미지 생성'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="kx-auto__card-body">
|
<div className="kx-auto__card-body">
|
||||||
<span className="kx-auto__card-title">{o.label}</span>
|
<span className="kx-auto__card-title">{o.label}</span>
|
||||||
<ul className="kx-auto__metrics tnum">
|
<ul className="kx-auto__metrics tnum">
|
||||||
@ -279,19 +459,50 @@ export function AutoLayoutDialog({ eventId, hallId, onClose, onApply }: AutoLayo
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
<p className="kx-auto__keepnote">
|
||||||
|
생성한 조감 이미지는 자동 저장되어 시각화 갤러리에서 다시 보거나 공사 옥션 참고
|
||||||
|
이미지로 쓸 수 있습니다.
|
||||||
|
</p>
|
||||||
<div className="kx-modal__actions">
|
<div className="kx-modal__actions">
|
||||||
<Button variant="ghost" onClick={() => setPhase('form')}>
|
<Button variant="ghost" onClick={() => setPhase('form')}>
|
||||||
조건 수정
|
조건 수정
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button disabled={!selected || applying} onClick={applySelected}>
|
||||||
disabled={!selectedOption}
|
{applying ? '저장 중…' : '이 안으로 편집 시작'}
|
||||||
onClick={() => selectedOption && onApply(selectedOption)}
|
|
||||||
>
|
|
||||||
이 안으로 편집 시작
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 확대 이미지 팝업(라이트박스) — 클릭 확대 + 저장(다운로드) */}
|
||||||
|
{lightbox && (
|
||||||
|
<div
|
||||||
|
className="kx-lightbox"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={`${lightbox.alt} 확대 보기`}
|
||||||
|
onClick={() => setLightbox(null)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Escape') setLightbox(null);
|
||||||
|
}}
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
<div className="kx-lightbox__inner" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<img src={lightbox.url} alt={lightbox.alt} />
|
||||||
|
<p className="kx-lightbox__notice">
|
||||||
|
AI 생성 예상 이미지 — 실제 시공 결과와 다를 수 있습니다. 계약·심사 서류에 사용 불가.
|
||||||
|
</p>
|
||||||
|
<div className="kx-lightbox__actions">
|
||||||
|
<a className="kx-lightbox__download" href={lightbox.url} download>
|
||||||
|
이미지 저장
|
||||||
|
</a>
|
||||||
|
<Button variant="ghost" onClick={() => setLightbox(null)}>
|
||||||
|
닫기
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
import { useCallback, useMemo, useState } from 'react';
|
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 { useQuery } from '@tanstack/react-query';
|
||||||
import { layoutApi } from '../../api/endpoints';
|
import { layoutApi } from '../../api/endpoints';
|
||||||
|
import { hallAssignApi, type Hall } from '../hallassign/hallAssignApi';
|
||||||
import { ApiRequestError } from '../../api/client';
|
import { ApiRequestError } from '../../api/client';
|
||||||
import { Button } from '../../components/ui/Button';
|
import { Button } from '../../components/ui/Button';
|
||||||
import { Skeleton } from '../../components/ui/States';
|
import { Skeleton } from '../../components/ui/States';
|
||||||
@ -17,6 +18,23 @@ type RightTab = 'props' | 'validation';
|
|||||||
|
|
||||||
export function BoothLayoutEditorPage() {
|
export function BoothLayoutEditorPage() {
|
||||||
const { eventId = '', hallId = '' } = useParams();
|
const { eventId = '', hallId = '' } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// 전시장(홀) 선택 실 운영(소유자 지시 2026-07-14) — 홀 마스터 전체를 드롭다운으로 제공.
|
||||||
|
const hallsQuery = useQuery<Hall[]>({
|
||||||
|
queryKey: ['halls'],
|
||||||
|
queryFn: hallAssignApi.halls,
|
||||||
|
staleTime: 10 * 60 * 1000,
|
||||||
|
});
|
||||||
|
const hallGroups = useMemo(() => {
|
||||||
|
const groups = new Map<number, Hall[]>();
|
||||||
|
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<CanvasLayer>({
|
const [layers, setLayers] = useState<CanvasLayer>({
|
||||||
trench: true,
|
trench: true,
|
||||||
@ -73,10 +91,14 @@ export function BoothLayoutEditorPage() {
|
|||||||
}
|
}
|
||||||
}, [eventId, hallId, layout?.version]);
|
}, [eventId, hallId, layout?.version]);
|
||||||
|
|
||||||
function applyAutoOption(_option: AutoLayoutOption) {
|
function applyAutoOption(_option: AutoLayoutOption, appliedHallId: string) {
|
||||||
// 병합: 실제 부스 좌표는 백엔드 저장/재조회로 반영. 지금은 재조회 트리거.
|
// 다이얼로그가 apply-option으로 서버 저장을 마친 뒤 호출된다(2026-07-14 실배선).
|
||||||
setAutoOpen(false);
|
setAutoOpen(false);
|
||||||
void layoutQuery.refetch();
|
if (appliedHallId && appliedHallId !== hallId) {
|
||||||
|
navigate(`/events/${eventId}/halls/${appliedHallId}/layout`);
|
||||||
|
} else {
|
||||||
|
void layoutQuery.refetch();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const summary = layout?.summary;
|
const summary = layout?.summary;
|
||||||
@ -86,6 +108,25 @@ export function BoothLayoutEditorPage() {
|
|||||||
{/* 상단 툴바 */}
|
{/* 상단 툴바 */}
|
||||||
<header className="kx-editor__toolbar">
|
<header className="kx-editor__toolbar">
|
||||||
<div className="kx-editor__toolbar-left">
|
<div className="kx-editor__toolbar-left">
|
||||||
|
<select
|
||||||
|
className="kx-select kx-editor__hall-select"
|
||||||
|
aria-label="전시장 홀 선택"
|
||||||
|
value={hallId}
|
||||||
|
onChange={(e) => navigate(`/events/${eventId}/halls/${e.target.value}/layout`)}
|
||||||
|
>
|
||||||
|
{!hallsQuery.data?.some((h) => h.id === hallId) && (
|
||||||
|
<option value={hallId}>{hallId || '홀 선택'}</option>
|
||||||
|
)}
|
||||||
|
{hallGroups.map(([center, halls]) => (
|
||||||
|
<optgroup key={center} label={`제${center}전시장`}>
|
||||||
|
{halls.map((h) => (
|
||||||
|
<option key={h.id} value={h.id}>
|
||||||
|
{h.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
<h2 className="kx-editor__hall">{layout?.name ?? '배치안'}</h2>
|
<h2 className="kx-editor__hall">{layout?.name ?? '배치안'}</h2>
|
||||||
<span className="kx-editor__version">
|
<span className="kx-editor__version">
|
||||||
v{layout?.version ?? '—'}
|
v{layout?.version ?? '—'}
|
||||||
|
|||||||
@ -176,3 +176,117 @@
|
|||||||
color: var(--color-error);
|
color: var(--color-error);
|
||||||
font-weight: var(--fw-semibold);
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
@import '../shared.css'; /* .kx-select 등 공용 프리미티브 */
|
||||||
|
|
||||||
.kx-editor {
|
.kx-editor {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@ -27,6 +29,11 @@
|
|||||||
font-weight: var(--fw-bold);
|
font-weight: var(--fw-bold);
|
||||||
color: var(--color-neutral-900);
|
color: var(--color-neutral-900);
|
||||||
}
|
}
|
||||||
|
/* 전시장(홀) 선택 — 실 운영 진입점(공용 .kx-select 위 툴바 보정) */
|
||||||
|
.kx-editor__hall-select {
|
||||||
|
max-width: 200px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
.kx-editor__version {
|
.kx-editor__version {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@ -6,7 +6,8 @@
|
|||||||
* FLOOR_REGIONS: 도면 이미지 안에서 전시 바닥면(1전시장 연노랑·2전시장 라임그린 채색 영역)이
|
* FLOOR_REGIONS: 도면 이미지 안에서 전시 바닥면(1전시장 연노랑·2전시장 라임그린 채색 영역)이
|
||||||
* 차지하는 사각 영역의 이미지 분율 [x0, y0, x1, y1]. 도면 JPG는 여백·범례·미니맵을 포함하므로
|
* 차지하는 사각 영역의 이미지 분율 [x0, y0, x1, y1]. 도면 JPG는 여백·범례·미니맵을 포함하므로
|
||||||
* 이 영역을 홀 좌표계(0,0~W,H m)에 정렬해야 부스가 "평면도 안"에 배치돼 보인다(소유자 지시 2026-07-14).
|
* 이 영역을 홀 좌표계(0,0~W,H m)에 정렬해야 부스가 "평면도 안"에 배치돼 보인다(소유자 지시 2026-07-14).
|
||||||
* 값은 색상 블롭 자동 측정(최대 연결 성분) 산출 — 도면 교체 시 재측정 필요.
|
* 값은 색상 블롭 자동 측정(유의미 블롭 합집합 — 중앙 통로로 갈라진 A/B 구역 포함) 산출.
|
||||||
|
* 도면 교체 시 재측정 필요. 홀 치수는 V59에서 도면 방향(가로×세로)으로 정렬됨.
|
||||||
*/
|
*/
|
||||||
export type FloorRegion = [number, number, number, number];
|
export type FloorRegion = [number, number, number, number];
|
||||||
|
|
||||||
@ -18,9 +19,9 @@ const FLOOR_REGIONS: Record<string, FloorRegion> = {
|
|||||||
H5: [0.0528, 0.4416, 0.9028, 0.8686],
|
H5: [0.0528, 0.4416, 0.9028, 0.8686],
|
||||||
H6: [0.3222, 0.2162, 0.6917, 0.8176],
|
H6: [0.3222, 0.2162, 0.6917, 0.8176],
|
||||||
H7: [0.2583, 0.2027, 0.7306, 0.8547],
|
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],
|
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 {
|
function hallKey(hallId: string | null | undefined): string | null {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user