diff --git a/1.png b/1.png
deleted file mode 100644
index f30ae36..0000000
Binary files a/1.png and /dev/null differ
diff --git a/2.png b/2.png
deleted file mode 100644
index 396451d..0000000
Binary files a/2.png and /dev/null differ
diff --git a/3.png b/3.png
deleted file mode 100644
index 3866bed..0000000
Binary files a/3.png and /dev/null differ
diff --git a/5.png b/5.png
deleted file mode 100644
index 3bb9b07..0000000
Binary files a/5.png and /dev/null differ
diff --git a/6.png b/6.png
new file mode 100644
index 0000000..bbc32e2
Binary files /dev/null and b/6.png differ
diff --git a/7.png b/7.png
new file mode 100644
index 0000000..f29d451
Binary files /dev/null and b/7.png differ
diff --git a/backend/app/center_chair_viewer_template.html b/backend/app/center_chair_viewer_template.html
new file mode 100644
index 0000000..5d68a51
--- /dev/null
+++ b/backend/app/center_chair_viewer_template.html
@@ -0,0 +1,508 @@
+
+
+
+
+
+ center chair people map
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/backend/app/main.py b/backend/app/main.py
index c861b4d..da4b922 100755
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -1,18 +1,22 @@
from __future__ import annotations
-from datetime import datetime
-from pathlib import Path
import csv
+import base64
+from datetime import datetime
from io import BytesIO, StringIO
+import json
import math
+from pathlib import Path
+import re
import shutil
+import struct
import uuid
import ezdxf
from ezdxf import recover
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
from fastapi.middleware.cors import CORSMiddleware
-from fastapi.responses import FileResponse
+from fastapi.responses import FileResponse, HTMLResponse
from fastapi.staticfiles import StaticFiles
from openpyxl import load_workbook
from pydantic import BaseModel, Field
@@ -32,6 +36,10 @@ app.add_middleware(
)
LEGACY_STATIC_DIR = LEGACY_DIR / "static"
+FIXED_OFFICE_SOURCE_KEY = "technical-development-center"
+FIXED_OFFICE_NAME = "기술개발센터"
+FIXED_OFFICE_TEMPLATE_PATH = Path(__file__).with_name("center_chair_viewer_template.html")
+_fixed_office_cache: dict[str, object] | None = None
class MemberPayload(BaseModel):
@@ -239,6 +247,112 @@ def fetch_active_seat_map() -> dict[str, object] | None:
return cur.fetchone()
+def ensure_fixed_office_seat_map(activate: bool = True) -> dict[str, object]:
+ template = parse_fixed_office_template()
+ slots = template["slots"]
+ with get_conn() as conn:
+ with conn.cursor() as cur:
+ cur.execute(
+ """
+ SELECT id
+ FROM seat_maps
+ WHERE source_type = 'fixed_html'
+ AND source_url = %s
+ LIMIT 1
+ """,
+ (FIXED_OFFICE_SOURCE_KEY,),
+ )
+ row = cur.fetchone()
+ if activate:
+ cur.execute("UPDATE seat_maps SET is_active = FALSE, updated_at = NOW() WHERE is_active = TRUE")
+ if row is None:
+ cur.execute(
+ """
+ INSERT INTO seat_maps (
+ name, image_url, source_type, source_url, preview_svg,
+ view_box_min_x, view_box_min_y, view_box_width, view_box_height,
+ image_width, image_height, grid_rows, grid_cols, cell_gap, is_active
+ )
+ VALUES (%s, '', 'fixed_html', %s, '', NULL, NULL, NULL, NULL, NULL, NULL, 1, 1, 0, %s)
+ RETURNING id
+ """,
+ (FIXED_OFFICE_NAME, FIXED_OFFICE_SOURCE_KEY, activate),
+ )
+ seat_map_id = int(cur.fetchone()["id"])
+ else:
+ seat_map_id = int(row["id"])
+ cur.execute(
+ """
+ UPDATE seat_maps
+ SET name = %s,
+ source_type = 'fixed_html',
+ source_url = %s,
+ image_url = '',
+ preview_svg = '',
+ grid_rows = 1,
+ grid_cols = 1,
+ cell_gap = 0,
+ is_active = %s,
+ updated_at = NOW()
+ WHERE id = %s
+ """,
+ (FIXED_OFFICE_NAME, FIXED_OFFICE_SOURCE_KEY, activate, seat_map_id),
+ )
+
+ cur.execute("SELECT id, slot_key FROM seat_slots WHERE seat_map_id = %s", (seat_map_id,))
+ existing_slots = {str(item["slot_key"]): int(item["id"]) for item in cur.fetchall()}
+ incoming_keys = {str(slot["slot_key"]) for slot in slots}
+
+ for slot in slots:
+ slot_key = str(slot["slot_key"])
+ if slot_key in existing_slots:
+ cur.execute(
+ """
+ UPDATE seat_slots
+ SET label = %s, x = %s, y = %s, rotation = %s, layer_name = %s, updated_at = NOW()
+ WHERE seat_map_id = %s AND slot_key = %s
+ """,
+ (
+ slot["label"],
+ slot["x"],
+ slot["y"],
+ slot["rotation"],
+ slot["layer_name"],
+ seat_map_id,
+ slot_key,
+ ),
+ )
+ else:
+ cur.execute(
+ """
+ INSERT INTO seat_slots (seat_map_id, slot_key, label, x, y, rotation, layer_name)
+ VALUES (%s, %s, %s, %s, %s, %s, %s)
+ """,
+ (
+ seat_map_id,
+ slot_key,
+ slot["label"],
+ slot["x"],
+ slot["y"],
+ slot["rotation"],
+ slot["layer_name"],
+ ),
+ )
+
+ if existing_slots:
+ stale_keys = [key for key in existing_slots if key not in incoming_keys]
+ if stale_keys:
+ cur.execute(
+ "DELETE FROM seat_slots WHERE seat_map_id = %s AND slot_key = ANY(%s)",
+ (seat_map_id, stale_keys),
+ )
+ conn.commit()
+ seat_map = fetch_seat_map(seat_map_id)
+ if seat_map is None:
+ raise HTTPException(status_code=500, detail="Fixed office seat map initialization failed.")
+ return seat_map
+
+
def compute_seat_label(row_index: int, col_index: int) -> str:
quotient = row_index
row_label = ""
@@ -255,6 +369,63 @@ def compute_slot_label(index: int) -> str:
return f"CHAIR-{index + 1:03d}"
+def decode_segment_values(raw_base64: str) -> list[int]:
+ decoded = base64.b64decode(raw_base64.encode("ascii"))
+ if not decoded:
+ return []
+ return [item[0] for item in struct.iter_unpack(" dict[str, object]:
+ global _fixed_office_cache
+ if _fixed_office_cache is not None:
+ return _fixed_office_cache
+ if not FIXED_OFFICE_TEMPLATE_PATH.exists():
+ raise HTTPException(status_code=500, detail="Fixed office viewer template not found.")
+
+ html = FIXED_OFFICE_TEMPLATE_PATH.read_text(encoding="utf-8")
+ match = re.search(r"const DATA = (\{.*?\});\n\s*function decodeSegments", html, flags=re.S)
+ if not match:
+ raise HTTPException(status_code=500, detail="Fixed office viewer data not found.")
+ data = json.loads(match.group(1))
+ chair_values = decode_segment_values(str(data["chairSegsB64"]))
+ slots: list[dict[str, object]] = []
+ for index, chair in enumerate(data["chairs"]):
+ slot_key, name, _kind, start, count = chair
+ min_x = math.inf
+ min_y = math.inf
+ max_x = -math.inf
+ max_y = -math.inf
+ start_index = int(start)
+ end_index = start_index + int(count)
+ for item_index in range(start_index, end_index):
+ offset = item_index * 4
+ x1 = chair_values[offset] / 10
+ y1 = chair_values[offset + 1] / 10
+ x2 = chair_values[offset + 2] / 10
+ y2 = chair_values[offset + 3] / 10
+ min_x = min(min_x, x1, x2)
+ min_y = min(min_y, y1, y2)
+ max_x = max(max_x, x1, x2)
+ max_y = max(max_y, y1, y2)
+ slots.append(
+ {
+ "slot_key": str(slot_key),
+ "label": str(slot_key),
+ "x": round((min_x + max_x) / 2, 3),
+ "y": round((min_y + max_y) / 2, 3),
+ "rotation": 0.0,
+ "layer_name": str(name),
+ }
+ )
+ _fixed_office_cache = {
+ "html": html,
+ "data": data,
+ "slots": slots,
+ }
+ return _fixed_office_cache
+
+
def is_chair_layer(layer_name: str) -> bool:
raw = layer_name.strip().lower()
compact = raw.replace("-", "").replace("_", "").replace(" ", "")
@@ -515,12 +686,13 @@ def build_dxf_preview_svg(
)
-def parse_dxf_layout(file_path: Path) -> tuple[dict[str, object], list[dict[str, object]]]:
+def load_dxf_document(file_path: Path) -> ezdxf.document.Drawing:
try:
- document = ezdxf.readfile(file_path)
+ return ezdxf.readfile(file_path)
except OSError:
try:
document, _ = recover.readfile(file_path)
+ return document
except Exception as exc:
kind, preview = inspect_dxf_header(file_path)
if kind == "binary_dxf":
@@ -538,10 +710,65 @@ def parse_dxf_layout(file_path: Path) -> tuple[dict[str, object], list[dict[str,
status_code=400,
detail=f"ASCII DXF로 보이지만 구조를 해석하지 못했습니다. 도면을 다른 DXF 버전으로 다시 저장해보세요. 헤더={preview}",
) from exc
- raise HTTPException(
- status_code=400,
- detail=f"업로드한 파일 형식을 판별하지 못했습니다. 확장자만 dxf인 파일일 수 있습니다. 헤더={preview}",
- ) from exc
+ raise HTTPException(
+ status_code=400,
+ detail=f"업로드한 파일 형식을 판별하지 못했습니다. 확장자만 dxf인 파일일 수 있습니다. 헤더={preview}",
+ ) from exc
+
+
+def entity_to_segments(entity: ezdxf.entities.DXFGraphic, arc_steps: int = 24) -> list[tuple[float, float, float, float]]:
+ entity_type = entity.dxftype()
+ points = get_entity_points(entity)
+ if entity_type in {"LINE", "LWPOLYLINE", "POLYLINE", "SPLINE", "ELLIPSE"} and len(points) >= 2:
+ return [
+ (float(left[0]), float(left[1]), float(right[0]), float(right[1]))
+ for left, right in zip(points[:-1], points[1:])
+ ]
+
+ if entity_type == "CIRCLE":
+ center = entity.dxf.center
+ radius = float(entity.dxf.radius)
+ samples = []
+ for index in range(arc_steps + 1):
+ angle = (math.tau * index) / arc_steps
+ samples.append(
+ (
+ float(center.x) + radius * math.cos(angle),
+ float(center.y) + radius * math.sin(angle),
+ )
+ )
+ return [
+ (float(left[0]), float(left[1]), float(right[0]), float(right[1]))
+ for left, right in zip(samples[:-1], samples[1:])
+ ]
+
+ if entity_type == "ARC":
+ center = entity.dxf.center
+ radius = float(entity.dxf.radius)
+ start_angle = math.radians(float(entity.dxf.start_angle))
+ end_angle = math.radians(float(entity.dxf.end_angle))
+ if end_angle <= start_angle:
+ end_angle += math.tau
+ samples = []
+ for index in range(arc_steps + 1):
+ ratio = index / arc_steps
+ angle = start_angle + (end_angle - start_angle) * ratio
+ samples.append(
+ (
+ float(center.x) + radius * math.cos(angle),
+ float(center.y) + radius * math.sin(angle),
+ )
+ )
+ return [
+ (float(left[0]), float(left[1]), float(right[0]), float(right[1]))
+ for left, right in zip(samples[:-1], samples[1:])
+ ]
+
+ return []
+
+
+def build_dxf_artifacts(file_path: Path) -> tuple[dict[str, object], list[dict[str, object]], dict[str, object]]:
+ document = load_dxf_document(file_path)
modelspace = document.modelspace()
base_entities = [entity for entity in modelspace if entity.dxftype() in {"LINE", "LWPOLYLINE", "POLYLINE", "CIRCLE", "ARC", "INSERT", "SPLINE", "ELLIPSE"}]
all_entities: list[ezdxf.entities.DXFGraphic] = []
@@ -615,6 +842,73 @@ def parse_dxf_layout(file_path: Path) -> tuple[dict[str, object], list[dict[str,
"image_height": None,
"cell_gap": 0,
}
+
+ slot_map = {str(slot["slot_key"]): slot for slot in slots}
+ chair_segments: list[list[float]] = []
+ chair_items: list[dict[str, object]] = []
+ background_segments: list[list[float]] = []
+
+ for entity in visible_entities:
+ segments = entity_to_segments(entity)
+ if not segments:
+ continue
+
+ if is_chair_layer(entity.dxf.layer):
+ slot = slot_map.get(str(entity.dxf.handle))
+ if not slot:
+ continue
+ start_index = len(chair_segments)
+ min_seg_x = math.inf
+ min_seg_y = math.inf
+ max_seg_x = -math.inf
+ max_seg_y = -math.inf
+ for x1, y1, x2, y2 in segments:
+ chair_segments.append([round(x1, 3), round(y1, 3), round(x2, 3), round(y2, 3)])
+ min_seg_x = min(min_seg_x, x1, x2)
+ min_seg_y = min(min_seg_y, y1, y2)
+ max_seg_x = max(max_seg_x, x1, x2)
+ max_seg_y = max(max_seg_y, y1, y2)
+ chair_items.append(
+ {
+ "key": str(slot["slot_key"]),
+ "label": slot["label"],
+ "kind": "chair",
+ "start": start_index,
+ "count": len(segments),
+ "min_x": round(min_seg_x, 3),
+ "min_y": round(min_seg_y, 3),
+ "max_x": round(max_seg_x, 3),
+ "max_y": round(max_seg_y, 3),
+ }
+ )
+ continue
+
+ for x1, y1, x2, y2 in segments:
+ background_segments.append([round(x1, 3), round(y1, 3), round(x2, 3), round(y2, 3)])
+
+ viewer_data = {
+ "meta": {
+ "background_segment_count": len(background_segments),
+ "chair_count": len(chair_items),
+ "chair_segment_count": len(chair_segments),
+ "world": {
+ "min_x": round(min_x, 3),
+ "min_y": round(min_y, 3),
+ "max_x": round(min_x + width, 3),
+ "max_y": round(min_y + height, 3),
+ "width": round(width, 3),
+ "height": round(height, 3),
+ },
+ },
+ "background_segments": background_segments,
+ "chair_segments": chair_segments,
+ "chairs": chair_items,
+ }
+ return metadata, slots, viewer_data
+
+
+def parse_dxf_layout(file_path: Path) -> tuple[dict[str, object], list[dict[str, object]]]:
+ metadata, slots, _viewer_data = build_dxf_artifacts(file_path)
return metadata, slots
@@ -660,14 +954,101 @@ def fetch_seat_layout(seat_map_id: int) -> dict[str, object]:
(seat_map_id,),
)
placements = cur.fetchall()
+ viewer_data: dict[str, object] | None = None
+ if seat_map["source_type"] == "fixed_html" and seat_map.get("source_url") == FIXED_OFFICE_SOURCE_KEY:
+ template = parse_fixed_office_template()
+ viewer_data = {
+ "meta": {
+ "chair_count": len(template["slots"]),
+ "office": FIXED_OFFICE_NAME,
+ }
+ }
+ elif seat_map["source_type"] == "dxf" and seat_map.get("source_url"):
+ filename = Path(str(seat_map["source_url"])).name
+ source_path = UPLOAD_DIR / filename
+ if source_path.exists():
+ try:
+ _metadata, _slots, viewer_data = build_dxf_artifacts(source_path)
+ except Exception:
+ viewer_data = None
return {
"seat_map": seat_map,
"members": members,
"slots": slots,
"placements": placements,
+ "viewer_data": viewer_data,
}
+def build_center_chair_viewer_html(layout: dict[str, object]) -> str:
+ slot_key_by_id = {
+ int(slot["id"]): str(slot["slot_key"])
+ for slot in layout.get("slots", [])
+ if slot.get("id") is not None and slot.get("slot_key") is not None
+ }
+ placed_keys: list[str] = []
+ for placement in layout.get("placements", []):
+ slot_id = placement.get("seat_slot_id")
+ if slot_id is None:
+ continue
+ slot_key = slot_key_by_id.get(int(slot_id))
+ if slot_key:
+ placed_keys.append(slot_key)
+
+ seat_map = layout.get("seat_map") or {}
+ placed_literal = json.dumps(sorted(set(placed_keys)), ensure_ascii=False, separators=(",", ":"))
+ if seat_map.get("source_type") == "fixed_html":
+ html = parse_fixed_office_template()["html"]
+ else:
+ viewer_data = layout.get("viewer_data")
+ if not isinstance(viewer_data, dict):
+ raise HTTPException(status_code=404, detail="DXF viewer data not found.")
+ template_path = Path(__file__).with_name("center_chair_viewer_template.html")
+ if not template_path.exists():
+ raise HTTPException(status_code=500, detail="Viewer template not found.")
+ html = template_path.read_text(encoding="utf-8")
+ data_literal = json.dumps(viewer_data, ensure_ascii=False, separators=(",", ":"))
+ html = re.sub(
+ r"const DATA = .*?;\n\s*function decodeSegments",
+ f"const DATA = {data_literal};\n function decodeSegments",
+ html,
+ count=1,
+ flags=re.S,
+ )
+ html = html.replace(
+ 'const STORAGE_KEY = "ptc-chair-selection";\n const placed = new Set(JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]"));',
+ f"const STORAGE_KEY = null;\n const placed = new Set({placed_literal});",
+ 1,
+ )
+ html = html.replace(
+ "function persistPlaced() {\n localStorage.setItem(STORAGE_KEY, JSON.stringify([...placed]));\n }",
+ "function persistPlaced() {\n return;\n }",
+ 1,
+ )
+ bridge_script = """
+
+"""
+ html = html.replace("
-
+
+
+
+