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.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<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")
|
||||
public ApiResponse<LayoutDto> applyOption(@AuthenticationPrincipal KintexPrincipal principal,
|
||||
@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.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<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);
|
||||
}
|
||||
|
||||
@ -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<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안: 통로 폭·부스 규격을 달리해 서로 구분되는 후보를 결정적으로 생성.
|
||||
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<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()
|
||||
.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<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<>();
|
||||
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<double[]> 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 홀 전경(조감) 이미지 생성 — <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 {
|
||||
Map<String, Object> scene = new LinkedHashMap<>();
|
||||
Map<String, Object> 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<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 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
|
||||
) {
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
/** 홀 마스터 전체(전시장 순) — 평면도 선택 트리 대분류·중분류 근거(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
|
||||
</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>
|
||||
|
||||
@ -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<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) ──
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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<Phase>('place');
|
||||
const [req, setReq] = useState(DEFAULT_REQ);
|
||||
const [phase, setPhase] = useState<'form' | 'generating' | 'result'>('form');
|
||||
const [options, setOptions] = useState<AutoLayoutOption[]>([]);
|
||||
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 [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가 문장을 폼 값으로 파싱 — 최종 권위는 폼).
|
||||
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 })),
|
||||
),
|
||||
const entries = Object.entries(previewJobIds);
|
||||
if (entries.length === 0) return;
|
||||
const unsubs = entries.map(([optionId, jobId]) =>
|
||||
subscribeRenderJob(jobId, (job) => setRenderJobs((prev) => ({ ...prev, [optionId]: job }))),
|
||||
);
|
||||
// 폴백: 구독 즉시 현재 상태 1회 조회
|
||||
options.forEach(async (o) => {
|
||||
if (!o.s7RenderJobId) return;
|
||||
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 (
|
||||
<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">
|
||||
<div>
|
||||
<h3 id="auto-title" className="kx-auto__title">
|
||||
<AiLabel>AI 자동배치</AiLabel> 배치안 생성
|
||||
<AiLabel>AI 자동배치</AiLabel> 평면도 안 자동 배치
|
||||
</h3>
|
||||
<p className="kx-auto__sub">
|
||||
제약조건을 입력하면 복수 배치안을 생성합니다. 마음에 드는 안을 선택해 편집을
|
||||
시작하세요.
|
||||
전시장 평면도를 고르고 조건을 입력하면, 도면 바닥면 안에 배치안을 생성합니다.
|
||||
</p>
|
||||
</div>
|
||||
<button className="kx-auto__close" onClick={onClose} aria-label="닫기">
|
||||
@ -137,6 +204,89 @@ export function AutoLayoutDialog({ eventId, hallId, onClose, onApply }: AutoLayo
|
||||
</button>
|
||||
</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' && (
|
||||
<div className="kx-auto__body">
|
||||
<div className="kx-auto__nl">
|
||||
@ -207,8 +357,8 @@ export function AutoLayoutDialog({ eventId, hallId, onClose, onApply }: AutoLayo
|
||||
</div>
|
||||
{error && <ErrorState message={error} onRetry={generate} />}
|
||||
<div className="kx-modal__actions">
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
취소
|
||||
<Button variant="ghost" onClick={() => setPhase('place')}>
|
||||
← 평면도 다시 선택
|
||||
</Button>
|
||||
<Button variant="ai" leadingIcon="✦" onClick={generate}>
|
||||
배치안 생성
|
||||
@ -226,9 +376,11 @@ export function AutoLayoutDialog({ eventId, hallId, onClose, onApply }: AutoLayo
|
||||
|
||||
{phase === 'result' && (
|
||||
<div className="kx-auto__body">
|
||||
{error && <p className="kx-auto__nl-note is-degraded">⚠ {error}</p>}
|
||||
<div className="kx-auto__grid">
|
||||
{options.map((o) => {
|
||||
const job = renderJobs[o.optionId];
|
||||
const jobRequested = Boolean(previewJobIds[o.optionId]);
|
||||
const isSel = o.optionId === selected;
|
||||
return (
|
||||
<label
|
||||
@ -241,6 +393,17 @@ export function AutoLayoutDialog({ eventId, hallId, onClose, onApply }: AutoLayo
|
||||
checked={isSel}
|
||||
onChange={() => setSelected(o.optionId)}
|
||||
/>
|
||||
{jobRequested ? (
|
||||
<div
|
||||
className={`kx-auto__imgwrap ${job?.imageUrl ? 'is-zoomable' : ''}`}
|
||||
onClick={(e) => {
|
||||
if (job?.imageUrl) {
|
||||
e.preventDefault();
|
||||
setLightbox({ url: job.imageUrl, alt: `${o.label} 조감` });
|
||||
}
|
||||
}}
|
||||
title={job?.imageUrl ? '클릭하면 확대 이미지를 봅니다' : undefined}
|
||||
>
|
||||
<AiImage
|
||||
imageUrl={job?.imageUrl ?? null}
|
||||
status={job?.status ?? 'QUEUED'}
|
||||
@ -254,6 +417,23 @@ export function AutoLayoutDialog({ eventId, hallId, onClose, onApply }: AutoLayo
|
||||
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">
|
||||
<span className="kx-auto__card-title">{o.label}</span>
|
||||
<ul className="kx-auto__metrics tnum">
|
||||
@ -279,19 +459,50 @@ export function AutoLayoutDialog({ eventId, hallId, onClose, onApply }: AutoLayo
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="kx-auto__keepnote">
|
||||
생성한 조감 이미지는 자동 저장되어 시각화 갤러리에서 다시 보거나 공사 옥션 참고
|
||||
이미지로 쓸 수 있습니다.
|
||||
</p>
|
||||
<div className="kx-modal__actions">
|
||||
<Button variant="ghost" onClick={() => setPhase('form')}>
|
||||
조건 수정
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!selectedOption}
|
||||
onClick={() => selectedOption && onApply(selectedOption)}
|
||||
>
|
||||
이 안으로 편집 시작
|
||||
<Button disabled={!selected || applying} onClick={applySelected}>
|
||||
{applying ? '저장 중…' : '이 안으로 편집 시작'}
|
||||
</Button>
|
||||
</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>
|
||||
);
|
||||
|
||||
@ -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<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>({
|
||||
trench: true,
|
||||
@ -73,11 +91,15 @@ export function BoothLayoutEditorPage() {
|
||||
}
|
||||
}, [eventId, hallId, layout?.version]);
|
||||
|
||||
function applyAutoOption(_option: AutoLayoutOption) {
|
||||
// 병합: 실제 부스 좌표는 백엔드 저장/재조회로 반영. 지금은 재조회 트리거.
|
||||
function applyAutoOption(_option: AutoLayoutOption, appliedHallId: string) {
|
||||
// 다이얼로그가 apply-option으로 서버 저장을 마친 뒤 호출된다(2026-07-14 실배선).
|
||||
setAutoOpen(false);
|
||||
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() {
|
||||
{/* 상단 툴바 */}
|
||||
<header className="kx-editor__toolbar">
|
||||
<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>
|
||||
<span className="kx-editor__version">
|
||||
v{layout?.version ?? '—'}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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<string, FloorRegion> = {
|
||||
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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user