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:
zio 2026-07-14 23:36:07 +09:00
parent 0cc7f26c56
commit 6c0d364fc9
18 changed files with 791 additions and 78 deletions

View File

@ -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,

View File

@ -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);
}

View File

@ -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) {

View File

@ -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);
}
}

View File

@ -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
) {
}

View File

@ -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) {
}
}

View File

@ -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) {
}

View File

@ -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);
}

View File

@ -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;

View File

@ -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;

View File

@ -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>

View File

@ -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) ──

View File

@ -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;

View File

@ -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>
);

View File

@ -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 ?? '—'}

View File

@ -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);
}

View File

@ -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;

View File

@ -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 {