Add multi-office seat maps and dev/prod DB sync protocol

This commit is contained in:
hyunho
2026-03-27 16:34:43 +09:00
parent 1d15cf9b9b
commit d66614123e
16 changed files with 3427 additions and 146 deletions

View File

@@ -0,0 +1,931 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>center chair people map</title>
<style>
:root {
--ink: #152330;
--muted: #627286;
--paper: rgba(255,255,255,0.86);
--line: rgba(21,35,48,0.1);
--accent: #0f766e;
--bg: #edf2f6;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "IBM Plex Sans KR", "Pretendard", sans-serif;
color: var(--ink);
background:
radial-gradient(circle at top left, rgba(15,118,110,0.11), transparent 22%),
linear-gradient(180deg, #f5f8fb 0%, #e8eef3 100%);
}
.page {
min-height: 100vh;
padding: 0;
}
.shell {
min-height: 100vh;
}
.panel {
border-radius: 0;
border: none;
background: transparent;
backdrop-filter: none;
box-shadow: none;
}
.actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
button {
border: none;
border-radius: 999px;
padding: 10px 14px;
font: inherit;
font-weight: 700;
cursor: pointer;
color: white;
background: linear-gradient(135deg, #0f766e, #115e59);
box-shadow: 0 10px 22px rgba(15,118,110,0.18);
}
button.alt {
color: var(--ink);
background: rgba(255,255,255,0.9);
border: 1px solid var(--line);
box-shadow: none;
}
.viewer {
position: relative;
overflow: hidden;
min-height: 100vh;
}
.viewer-head {
position: absolute;
top: 16px;
left: 16px;
right: 16px;
z-index: 2;
display: flex;
justify-content: space-between;
gap: 12px;
pointer-events: none;
}
.chip {
padding: 10px 12px;
border-radius: 16px;
background: rgba(255,255,255,0.82);
border: 1px solid rgba(255,255,255,0.94);
color: var(--muted);
font-size: 13px;
font-weight: 700;
box-shadow: 0 8px 24px rgba(21,35,48,0.08);
}
.viewer-actions {
position: absolute;
left: 16px;
top: 64px;
z-index: 2;
display: flex;
gap: 8px;
}
.mapper {
position: absolute;
top: 76px;
left: 50%;
transform: translateX(-50%);
width: min(94vw, 1320px);
max-height: min(56vh, 560px);
overflow: hidden;
z-index: 4;
border-radius: 20px;
background: rgba(234, 239, 247, 0.95);
border: 1px solid rgba(101, 119, 146, 0.22);
box-shadow: 0 18px 36px rgba(15, 23, 42, 0.2);
display: flex;
flex-direction: column;
backdrop-filter: blur(6px);
}
.hidden-off {
display: none !important;
}
.mapper-head {
padding: 10px 14px;
border-bottom: 1px solid rgba(101,119,146,0.18);
font-size: 12px;
color: #51607a;
font-weight: 700;
line-height: 1.35;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
background: rgba(255,255,255,0.6);
}
.mapper-head strong {
display: block;
color: #17243b;
font-size: 20px;
margin-bottom: 2px;
}
.mapper-head .alt {
padding: 8px 10px;
font-size: 12px;
white-space: nowrap;
}
.org-chart {
margin: 0;
padding: 14px;
overflow: auto;
display: grid;
gap: 12px;
}
.org-top {
margin: 0 auto;
width: min(100%, 420px);
border-radius: 14px;
overflow: hidden;
border: 1px solid rgba(67, 84, 118, 0.25);
background: #fff;
}
.org-top-title {
background: #1e2f4d;
color: #fff;
text-align: center;
font-size: 34px;
font-weight: 800;
line-height: 1.1;
padding: 16px 12px;
letter-spacing: -0.03em;
}
.org-top-members {
padding: 10px;
display: grid;
gap: 6px;
background: rgba(255,255,255,0.95);
}
.org-teams {
display: grid;
grid-template-columns: repeat(7, minmax(160px, 1fr));
gap: 10px;
align-items: start;
}
.org-team {
border: 1px solid rgba(110, 126, 152, 0.25);
border-radius: 10px;
overflow: hidden;
background: rgba(255,255,255,0.95);
min-width: 0;
}
.org-team h4 {
margin: 0;
padding: 9px 10px;
font-size: 14px;
color: #21324e;
font-weight: 800;
border-bottom: 1px solid rgba(110, 126, 152, 0.2);
background: rgba(240, 245, 252, 0.96);
}
.org-members {
padding: 7px;
display: grid;
gap: 6px;
}
.org-person {
border: 1px solid rgba(116, 133, 161, 0.25);
background: rgba(255,255,255,0.95);
border-radius: 8px;
padding: 6px 8px;
cursor: pointer;
transition: background 120ms ease, border-color 120ms ease;
min-width: 0;
}
.org-person.active {
border-color: rgba(15,118,110,0.6);
background: rgba(15,118,110,0.11);
}
.org-person.assigned {
border-color: rgba(37,99,235,0.5);
background: rgba(37,99,235,0.1);
}
.org-person strong {
display: block;
font-size: 13px;
line-height: 1.3;
color: #15233a;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.org-person small {
display: block;
color: #5a6a86;
font-size: 11px;
line-height: 1.25;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 980px) {
.mapper {
top: 72px;
width: min(96vw, 920px);
max-height: 58vh;
}
.viewer-actions {
top: 64px;
left: 12px;
right: 12px;
flex-wrap: wrap;
}
.mapper-head strong {
font-size: 16px;
}
.org-top-title {
font-size: 24px;
}
.org-teams {
grid-template-columns: repeat(3, minmax(150px, 1fr));
}
}
canvas {
width: 100%;
height: 100%;
display: block;
cursor: grab;
}
canvas.dragging { cursor: grabbing; }
.tooltip {
position: absolute;
min-width: 170px;
padding: 12px 14px;
border-radius: 16px;
background: rgba(17,24,39,0.94);
color: white;
pointer-events: none;
opacity: 0;
transform: translate(12px, 12px);
transition: opacity 120ms ease;
z-index: 3;
}
.tooltip.visible { opacity: 1; }
.tooltip strong { display: block; margin-bottom: 6px; font-size: 14px; }
.tooltip div { font-size: 12px; line-height: 1.45; color: rgba(255,255,255,0.82); }
</style>
</head>
<body>
<div class="page">
<div class="shell">
<main class="panel viewer">
<div class="viewer-head">
<div class="chip" id="scale-chip"></div>
<div class="chip" id="hover-chip">chair hover: none</div>
</div>
<div class="viewer-actions">
<button type="button" id="fit-btn">전체 맞춤</button>
<button type="button" class="alt" id="clear-btn">선택 지우기</button>
</div>
<aside class="mapper hidden-off">
<div class="mapper-head">
<div id="mapper-status">
<strong>조직 현황</strong>
<span>선택 인원 없음</span>
</div>
<button type="button" class="alt" id="clear-assign-btn">매칭 초기화</button>
</div>
<div class="org-chart" id="org-chart"></div>
</aside>
<canvas id="canvas"></canvas>
<div class="tooltip" id="tooltip"></div>
</main>
</div>
</div>
<script src="./center_chair_people_payload.js"></script>
<script>
const DATA = window.CHAIR_MAP_DATA;
function decodeSegments(base64) {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
return new Int32Array(bytes.buffer);
}
const bgTileRanges = DATA.bgTileRanges;
const bgSegValues = decodeSegments(DATA.bgSegsB64);
const chairSegValues = decodeSegments(DATA.chairSegsB64);
const chairs = DATA.chairs.map(([key, name, kind, start, count]) => ({
key, name, kind, start, count
}));
const meta = DATA.meta;
const world = meta.headerBounds;
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
const tooltip = document.getElementById("tooltip");
const scaleChip = document.getElementById("scale-chip");
const hoverChip = document.getElementById("hover-chip");
const STORAGE_KEY = "ptc-chair-selection";
const PEOPLE_STORAGE_KEY = "ptc-chair-people";
const ASSIGN_STORAGE_KEY = "ptc-chair-assignments";
const ACTIVE_PERSON_STORAGE_KEY = "ptc-chair-active-person";
const clearAssignBtn = document.getElementById("clear-assign-btn");
const orgChartEl = document.getElementById("org-chart");
const mapperStatus = document.getElementById("mapper-status");
// Prevent stale auto-highlights from previous sessions.
localStorage.removeItem(STORAGE_KEY);
localStorage.removeItem(ACTIVE_PERSON_STORAGE_KEY);
localStorage.removeItem(ASSIGN_STORAGE_KEY);
const placed = new Set();
let people = JSON.parse(localStorage.getItem(PEOPLE_STORAGE_KEY) || "[]");
let chairAssignments = {};
let activePersonId = null;
const ORG_TEMPLATE = {
top: {
name: "총괄기획실",
count: 53,
members: [
{ name: "장종찬", dept: "총괄기획실", title: "기획실장" },
{ name: "김원식", dept: "총괄기획실", title: "전무이사" },
],
},
teams: [
{ name: "경영기획팀", count: 6, members: ["김우진", "임민정", "국혜린", "최선아", "김윤재", "이미영"] },
{ name: "인재성장팀", count: 5, members: ["조태희", "최근혜", "류원준", "주안기", "정성호"] },
{ name: "ERP 기획팀", count: 5, members: ["류호성", "문형식", "최요제", "황대일", "이채봉"] },
{ name: "디자인기획팀", count: 17, members: ["신혜영", "정은혜", "김태식", "최예은", "채선영", "최영환", "윤봄이", "이예진", "허유나", "마희연", "김수현", "박지영", "권순호", "정두휘", "김정석", "정지윤", "양숙영"] },
{ name: "기술기획팀", count: 11, members: ["김원기", "홍아름", "이경민", "김혜인", "황동환", "최찬호", "이태훈", "김신지", "조찬영", "김용연", "한치영"] },
{ name: "협업증진팀", count: 3, members: ["성형일", "박주한", "한승민"] },
{ name: "솔루션통합팀", count: 4, members: ["권혁진", "염승호", "윤준수", "김지영"] },
],
};
const chairGeometry = chairs.map((chair) => {
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
const path = new Path2D();
const hitSegments = new Float32Array(chair.count * 4);
let segCursor = 0;
for (let i = chair.start; i < chair.start + chair.count; i += 1) {
const offset = i * 4;
const x1 = chairSegValues[offset] / 10;
const y1 = chairSegValues[offset + 1] / 10;
const x2 = chairSegValues[offset + 2] / 10;
const y2 = chairSegValues[offset + 3] / 10;
path.moveTo(x1, y1);
path.lineTo(x2, y2);
hitSegments[segCursor] = x1;
hitSegments[segCursor + 1] = y1;
hitSegments[segCursor + 2] = x2;
hitSegments[segCursor + 3] = y2;
segCursor += 4;
minX = Math.min(minX, x1, x2);
minY = Math.min(minY, y1, y2);
maxX = Math.max(maxX, x1, x2);
maxY = Math.max(maxY, y1, y2);
}
return {
...chair,
minX,
minY,
maxX,
maxY,
area: Math.max(1, (maxX - minX) * (maxY - minY)),
path,
hitSegments,
};
});
function renumberChairKeys(chairItems) {
if (!chairItems.length) return;
const heights = chairItems
.map((chair) => Math.max(1, chair.maxY - chair.minY))
.sort((a, b) => a - b);
const medianHeight = heights[Math.floor(heights.length / 2)] || 1;
const rowTolerance = Math.max(40, medianHeight * 0.9);
const sorted = [...chairItems].sort((a, b) => {
const ay = (a.minY + a.maxY) * 0.5;
const by = (b.minY + b.maxY) * 0.5;
if (Math.abs(by - ay) > rowTolerance) return by - ay; // top -> bottom
const ax = (a.minX + a.maxX) * 0.5;
const bx = (b.minX + b.maxX) * 0.5;
return ax - bx; // left -> right
});
sorted.forEach((chair, index) => {
chair.key = String(index + 1);
chair.seatNo = index + 1;
});
}
renumberChairKeys(chairGeometry);
const PICK_GRID_SIZE = 1800;
const chairPickGrid = new Map();
function pickGridKey(gx, gy) {
return `${gx},${gy}`;
}
chairGeometry.forEach((chair, index) => {
const minGX = Math.floor(chair.minX / PICK_GRID_SIZE);
const maxGX = Math.floor(chair.maxX / PICK_GRID_SIZE);
const minGY = Math.floor(chair.minY / PICK_GRID_SIZE);
const maxGY = Math.floor(chair.maxY / PICK_GRID_SIZE);
for (let gx = minGX; gx <= maxGX; gx += 1) {
for (let gy = minGY; gy <= maxGY; gy += 1) {
const key = pickGridKey(gx, gy);
if (!chairPickGrid.has(key)) chairPickGrid.set(key, []);
chairPickGrid.get(key).push(index);
}
}
});
const camera = { scale: 1, offsetX: 0, offsetY: 0 };
let pixelRatio = window.devicePixelRatio || 1;
let pointer = { x: 0, y: 0 };
let dragging = false;
let dragStart = null;
let hovered = null;
let rafPending = false;
function normalizePeople(raw) {
return raw
.map((person, index) => {
if (!person || !person.name) return null;
return {
id: person.id || `person-${index + 1}`,
name: String(person.name).trim(),
dept: String(person.dept || "").trim(),
title: String(person.title || "").trim(),
};
})
.filter(Boolean);
}
function createTemplatePeople() {
const generated = [];
let seq = 1;
ORG_TEMPLATE.top.members.forEach((member) => {
generated.push({
id: `org-${seq++}`,
name: member.name,
dept: member.dept,
title: member.title,
});
});
ORG_TEMPLATE.teams.forEach((team) => {
team.members.forEach((name) => {
generated.push({
id: `org-${seq++}`,
name,
dept: team.name,
title: "선임",
});
});
});
return generated;
}
people = normalizePeople(people);
const templateReady = people.some((person) => person.name === "장종찬" && person.dept === "총괄기획실");
if (!templateReady) {
people = createTemplatePeople();
localStorage.setItem(PEOPLE_STORAGE_KEY, JSON.stringify(people));
}
const chairKeySet = new Set(chairGeometry.map((chair) => chair.key));
chairAssignments = Object.fromEntries(
Object.entries(chairAssignments).filter(([chairKey, personId]) => (
chairKeySet.has(chairKey) && people.some((person) => person.id === personId)
))
);
if (activePersonId && !people.some((person) => person.id === activePersonId)) activePersonId = null;
function persistPeople() {
localStorage.setItem(PEOPLE_STORAGE_KEY, JSON.stringify(people));
}
function persistAssignments() {
localStorage.setItem(ASSIGN_STORAGE_KEY, JSON.stringify(chairAssignments));
}
function persistActivePerson() {
if (!activePersonId) localStorage.removeItem(ACTIVE_PERSON_STORAGE_KEY);
else localStorage.setItem(ACTIVE_PERSON_STORAGE_KEY, activePersonId);
}
function assignmentCount() {
return Object.keys(chairAssignments).length;
}
function getPersonById(id) {
return people.find((person) => person.id === id) || null;
}
function getChairByPerson(personId) {
for (const [chairKey, assignedPersonId] of Object.entries(chairAssignments)) {
if (assignedPersonId === personId) return chairKey;
}
return null;
}
function renderPeopleList() {
const activePerson = getPersonById(activePersonId);
const countText = `${assignmentCount()} / ${people.length} 매칭`;
mapperStatus.innerHTML = `<strong>조직 현황</strong><span>${activePerson ? `${activePerson.name} 선택됨` : "선택 인원 없음"} · ${countText}</span>`;
const findPerson = (dept, name) => people.find((person) => person.dept === dept && person.name === name) || null;
const personCard = (person, roleText) => {
if (!person) return "";
const chairKey = getChairByPerson(person.id);
const assignedClass = chairKey ? " assigned" : "";
const activeClass = person.id === activePersonId ? " active" : "";
return `
<article class="org-person${assignedClass}${activeClass}" data-person-id="${person.id}">
<strong>${person.name}</strong>
<small>${person.title || roleText || "-"}</small>
<small>${chairKey ? `좌석 ${chairKey}` : "좌석 미지정"}</small>
</article>
`;
};
const topHtml = ORG_TEMPLATE.top.members
.map((member) => personCard(findPerson(member.dept, member.name), member.title))
.join("");
const teamsHtml = ORG_TEMPLATE.teams.map((team) => {
const membersHtml = team.members
.map((name) => personCard(findPerson(team.name, name), "선임"))
.join("");
return `
<section class="org-team">
<h4>${team.name} (${team.count})</h4>
<div class="org-members">${membersHtml}</div>
</section>
`;
}).join("");
orgChartEl.innerHTML = `
<section class="org-top">
<div class="org-top-title">${ORG_TEMPLATE.top.name} (${ORG_TEMPLATE.top.count})</div>
<div class="org-top-members">${topHtml}</div>
</section>
<section class="org-teams">${teamsHtml}</section>
`;
}
function worldToScreen(x, y) {
return {
x: x * camera.scale + camera.offsetX,
y: (world.maxY - y + world.minY) * camera.scale + camera.offsetY,
};
}
function screenToWorld(x, y) {
return {
x: (x - camera.offsetX) / camera.scale,
y: world.maxY + world.minY - (y - camera.offsetY) / camera.scale,
};
}
function resize() {
pixelRatio = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = Math.round(rect.width * pixelRatio);
canvas.height = Math.round(rect.height * pixelRatio);
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
fit();
}
function fit() {
const rect = canvas.getBoundingClientRect();
const width = world.maxX - world.minX;
const height = world.maxY - world.minY;
const pad = 36;
const scaleX = (rect.width - pad * 2) / width;
const scaleY = (rect.height - pad * 2) / height;
camera.scale = Math.min(scaleX, scaleY);
camera.offsetX = pad - world.minX * camera.scale + (rect.width - pad * 2 - width * camera.scale) / 2;
camera.offsetY = pad - world.minY * camera.scale + (rect.height - pad * 2 - height * camera.scale) / 2;
requestDraw();
}
function drawGrid(width, height) {
ctx.save();
ctx.strokeStyle = "rgba(21,35,48,0.05)";
ctx.lineWidth = 1;
for (let x = 120; x < width; x += 120) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, height);
ctx.stroke();
}
for (let y = 120; y < height; y += 120) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(width, y);
ctx.stroke();
}
ctx.restore();
}
function pickChair(screenX, screenY) {
const threshold = 12;
const pointerWorld = screenToWorld(screenX, screenY);
const thresholdWorld = threshold / camera.scale;
const thresholdWorldSq = thresholdWorld * thresholdWorld;
const minGX = Math.floor((pointerWorld.x - thresholdWorld) / PICK_GRID_SIZE);
const maxGX = Math.floor((pointerWorld.x + thresholdWorld) / PICK_GRID_SIZE);
const minGY = Math.floor((pointerWorld.y - thresholdWorld) / PICK_GRID_SIZE);
const maxGY = Math.floor((pointerWorld.y + thresholdWorld) / PICK_GRID_SIZE);
const candidateIndexes = [];
const seen = new Set();
for (let gx = minGX; gx <= maxGX; gx += 1) {
for (let gy = minGY; gy <= maxGY; gy += 1) {
const candidates = chairPickGrid.get(pickGridKey(gx, gy));
if (!candidates) continue;
for (const index of candidates) {
if (seen.has(index)) continue;
seen.add(index);
candidateIndexes.push(index);
}
}
}
let best = null;
for (const index of candidateIndexes) {
const chair = chairGeometry[index];
if (
pointerWorld.x < chair.minX - thresholdWorld ||
pointerWorld.x > chair.maxX + thresholdWorld ||
pointerWorld.y < chair.minY - thresholdWorld ||
pointerWorld.y > chair.maxY + thresholdWorld
) continue;
let distSq = Infinity;
for (let i = 0; i < chair.hitSegments.length; i += 4) {
const x1 = chair.hitSegments[i];
const y1 = chair.hitSegments[i + 1];
const x2 = chair.hitSegments[i + 2];
const y2 = chair.hitSegments[i + 3];
const dx = x2 - x1;
const dy = y2 - y1;
const len2 = dx * dx + dy * dy;
let segDistSq;
if (len2 === 0) {
const px = pointerWorld.x - x1;
const py = pointerWorld.y - y1;
segDistSq = px * px + py * py;
} else {
let t = ((pointerWorld.x - x1) * dx + (pointerWorld.y - y1) * dy) / len2;
t = Math.max(0, Math.min(1, t));
const lx = x1 + t * dx;
const ly = y1 + t * dy;
const px = pointerWorld.x - lx;
const py = pointerWorld.y - ly;
segDistSq = px * px + py * py;
}
if (segDistSq < distSq) distSq = segDistSq;
if (distSq <= thresholdWorldSq * 0.3) break;
}
if (distSq > thresholdWorldSq) continue;
const dist = Math.sqrt(distSq) * camera.scale;
if (!best) {
best = { chair, dist };
continue;
}
const distGap = dist - best.dist;
if (distGap < -0.75) {
best = { chair, dist };
continue;
}
if (Math.abs(distGap) <= 2) {
const areaGap = chair.area - best.chair.area;
if (areaGap < -1) {
best = { chair, dist };
continue;
}
if (
Math.abs(areaGap) <= 1 &&
chair.kind === "block" &&
best.chair.kind !== "block"
) {
best = { chair, dist };
}
}
}
return best ? best.chair : null;
}
function renderTooltip() {
if (!hovered) {
tooltip.classList.remove("visible");
hoverChip.textContent = "chair hover: none";
return;
}
hoverChip.textContent = `chair hover: ${hovered.name}`;
tooltip.innerHTML = `
<strong>${hovered.name}</strong>
<div>chair key: ${hovered.key}</div>
<div>${placed.has(hovered.key) ? "선택됨" : "클릭하면 선택"}</div>
<div>${chairAssignments[hovered.key] ? `배치: ${(getPersonById(chairAssignments[hovered.key]) || { name: "알수없음" }).name}` : "배치 인원 없음"}</div>
`;
tooltip.style.left = `${pointer.x + 14}px`;
tooltip.style.top = `${pointer.y + 14}px`;
tooltip.classList.add("visible");
}
function requestDraw() {
if (rafPending) return;
rafPending = true;
window.requestAnimationFrame(() => {
rafPending = false;
draw();
});
}
function applyWorldTransform() {
ctx.setTransform(
pixelRatio * camera.scale,
0,
0,
-pixelRatio * camera.scale,
pixelRatio * camera.offsetX,
pixelRatio * ((world.maxY + world.minY) * camera.scale + camera.offsetY)
);
}
function draw() {
const rect = canvas.getBoundingClientRect();
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
ctx.clearRect(0, 0, rect.width, rect.height);
drawGrid(rect.width, rect.height);
const viewA = screenToWorld(0, rect.height);
const viewB = screenToWorld(rect.width, 0);
const viewMinX = Math.min(viewA.x, viewB.x);
const viewMaxX = Math.max(viewA.x, viewB.x);
const viewMinY = Math.min(viewA.y, viewB.y);
const viewMaxY = Math.max(viewA.y, viewB.y);
ctx.save();
applyWorldTransform();
ctx.strokeStyle = "rgba(100, 116, 139, 0.28)";
ctx.lineWidth = 1 / camera.scale;
const tileSize = meta.backgroundTileSize;
const tileMinX = Math.floor(viewMinX / tileSize);
const tileMaxX = Math.floor(viewMaxX / tileSize);
const tileMinY = Math.floor(viewMinY / tileSize);
const tileMaxY = Math.floor(viewMaxY / tileSize);
for (let tx = tileMinX; tx <= tileMaxX; tx += 1) {
for (let ty = tileMinY; ty <= tileMaxY; ty += 1) {
const range = bgTileRanges[`${tx},${ty}`];
if (!range) continue;
const start = range[0];
const count = range[1];
for (let i = start; i < start + count; i += 1) {
const offset = i * 4;
const x1 = bgSegValues[offset] / 10;
const y1 = bgSegValues[offset + 1] / 10;
const x2 = bgSegValues[offset + 2] / 10;
const y2 = bgSegValues[offset + 3] / 10;
if (
Math.max(x1, x2) < viewMinX ||
Math.min(x1, x2) > viewMaxX ||
Math.max(y1, y2) < viewMinY ||
Math.min(y1, y2) > viewMaxY
) continue;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
}
}
}
ctx.restore();
hovered = dragging ? null : pickChair(pointer.x, pointer.y);
ctx.save();
applyWorldTransform();
ctx.lineWidth = 1.45 / camera.scale;
ctx.lineCap = "round";
ctx.lineJoin = "round";
for (const chair of chairGeometry) {
if (chair.maxX < viewMinX || chair.minX > viewMaxX || chair.maxY < viewMinY || chair.minY > viewMaxY) continue;
const active = hovered && hovered.key === chair.key;
const selected = placed.has(chair.key);
const assignedPersonId = chairAssignments[chair.key];
const activePersonChair = activePersonId && assignedPersonId === activePersonId;
const assigned = Boolean(assignedPersonId);
const baseWidth = chair.kind === "block" ? 1.45 : 1.35;
ctx.strokeStyle = activePersonChair
? "rgba(234, 179, 8, 1)"
: assigned
? "rgba(37, 99, 235, 0.98)"
: selected
? "rgba(220, 38, 38, 0.98)"
: active
? "rgba(15, 118, 110, 0.98)"
: chair.kind === "group"
? "rgba(16, 134, 149, 0.74)"
: "rgba(21, 149, 142, 0.8)";
ctx.lineWidth = (activePersonChair ? 2.8 : assigned ? 2.4 : selected ? 2.6 : active ? 2.1 : baseWidth) / camera.scale;
ctx.stroke(chair.path);
}
ctx.restore();
scaleChip.textContent = `scale ${camera.scale.toFixed(4)}x`;
renderTooltip();
}
function persistPlaced() {
localStorage.setItem(STORAGE_KEY, JSON.stringify([...placed]));
}
canvas.addEventListener("pointerdown", (event) => {
dragging = true;
dragStart = { x: event.clientX, y: event.clientY, offsetX: camera.offsetX, offsetY: camera.offsetY };
canvas.classList.add("dragging");
});
window.addEventListener("pointerup", (event) => {
if (dragging && dragStart) {
const move = Math.hypot(event.clientX - dragStart.x, event.clientY - dragStart.y);
if (move < 4) {
const rect = canvas.getBoundingClientRect();
const picked = pickChair(event.clientX - rect.left, event.clientY - rect.top);
if (picked) {
if (placed.has(picked.key)) placed.delete(picked.key);
else placed.add(picked.key);
persistPlaced();
if (activePersonId) {
const currentChair = getChairByPerson(activePersonId);
if (chairAssignments[picked.key] === activePersonId) {
delete chairAssignments[picked.key];
} else {
if (currentChair && currentChair !== picked.key) delete chairAssignments[currentChair];
chairAssignments[picked.key] = activePersonId;
}
persistAssignments();
renderPeopleList();
}
}
}
}
dragging = false;
dragStart = null;
canvas.classList.remove("dragging");
requestDraw();
});
window.addEventListener("pointermove", (event) => {
const rect = canvas.getBoundingClientRect();
pointer = { x: event.clientX - rect.left, y: event.clientY - rect.top };
if (dragging && dragStart) {
camera.offsetX = dragStart.offsetX + (event.clientX - dragStart.x);
camera.offsetY = dragStart.offsetY + (event.clientY - dragStart.y);
}
requestDraw();
});
canvas.addEventListener("wheel", (event) => {
event.preventDefault();
const rect = canvas.getBoundingClientRect();
const mx = event.clientX - rect.left;
const my = event.clientY - rect.top;
const before = screenToWorld(mx, my);
const factor = event.deltaY < 0 ? 1.08 : 0.92;
camera.scale = Math.max(0.002, Math.min(2, camera.scale * factor));
const after = worldToScreen(before.x, before.y);
camera.offsetX += mx - after.x;
camera.offsetY += my - after.y;
requestDraw();
}, { passive: false });
document.getElementById("fit-btn").addEventListener("click", fit);
document.getElementById("clear-btn").addEventListener("click", () => {
placed.clear();
persistPlaced();
requestDraw();
});
clearAssignBtn.addEventListener("click", () => {
chairAssignments = {};
persistAssignments();
renderPeopleList();
requestDraw();
});
orgChartEl.addEventListener("click", (event) => {
const item = event.target.closest(".org-person[data-person-id]");
if (!item) return;
const personId = item.getAttribute("data-person-id");
activePersonId = personId === activePersonId ? null : personId;
persistActivePerson();
renderPeopleList();
requestDraw();
});
window.addEventListener("resize", resize);
renderPeopleList();
resize();
</script>
</body>
</html>

View File

@@ -0,0 +1,932 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>center chair people map 6f</title>
<style>
:root {
--ink: #152330;
--muted: #627286;
--paper: rgba(255,255,255,0.86);
--line: rgba(21,35,48,0.1);
--accent: #0f766e;
--bg: #edf2f6;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "IBM Plex Sans KR", "Pretendard", sans-serif;
color: var(--ink);
background:
radial-gradient(circle at top left, rgba(15,118,110,0.11), transparent 22%),
linear-gradient(180deg, #f5f8fb 0%, #e8eef3 100%);
}
.page {
min-height: 100vh;
padding: 0;
}
.shell {
min-height: 100vh;
}
.panel {
border-radius: 0;
border: none;
background: transparent;
backdrop-filter: none;
box-shadow: none;
}
.actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
button {
border: none;
border-radius: 999px;
padding: 10px 14px;
font: inherit;
font-weight: 700;
cursor: pointer;
color: white;
background: linear-gradient(135deg, #0f766e, #115e59);
box-shadow: 0 10px 22px rgba(15,118,110,0.18);
}
button.alt {
color: var(--ink);
background: rgba(255,255,255,0.9);
border: 1px solid var(--line);
box-shadow: none;
}
.viewer {
position: relative;
overflow: hidden;
min-height: 100vh;
}
.viewer-head {
position: absolute;
top: 16px;
left: 16px;
right: 16px;
z-index: 2;
display: flex;
justify-content: space-between;
gap: 12px;
pointer-events: none;
}
.chip {
padding: 10px 12px;
border-radius: 16px;
background: rgba(255,255,255,0.82);
border: 1px solid rgba(255,255,255,0.94);
color: var(--muted);
font-size: 13px;
font-weight: 700;
box-shadow: 0 8px 24px rgba(21,35,48,0.08);
}
.viewer-actions {
position: absolute;
left: 16px;
top: 64px;
z-index: 2;
display: flex;
gap: 8px;
}
.mapper {
position: absolute;
top: 76px;
left: 50%;
transform: translateX(-50%);
width: min(94vw, 1320px);
max-height: min(56vh, 560px);
overflow: hidden;
z-index: 4;
border-radius: 20px;
background: rgba(234, 239, 247, 0.95);
border: 1px solid rgba(101, 119, 146, 0.22);
box-shadow: 0 18px 36px rgba(15, 23, 42, 0.2);
display: flex;
flex-direction: column;
backdrop-filter: blur(6px);
}
.hidden-off {
display: none !important;
}
.mapper-head {
padding: 10px 14px;
border-bottom: 1px solid rgba(101,119,146,0.18);
font-size: 12px;
color: #51607a;
font-weight: 700;
line-height: 1.35;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
background: rgba(255,255,255,0.6);
}
.mapper-head strong {
display: block;
color: #17243b;
font-size: 20px;
margin-bottom: 2px;
}
.mapper-head .alt {
padding: 8px 10px;
font-size: 12px;
white-space: nowrap;
}
.org-chart {
margin: 0;
padding: 14px;
overflow: auto;
display: grid;
gap: 12px;
}
.org-top {
margin: 0 auto;
width: min(100%, 420px);
border-radius: 14px;
overflow: hidden;
border: 1px solid rgba(67, 84, 118, 0.25);
background: #fff;
}
.org-top-title {
background: #1e2f4d;
color: #fff;
text-align: center;
font-size: 34px;
font-weight: 800;
line-height: 1.1;
padding: 16px 12px;
letter-spacing: -0.03em;
}
.org-top-members {
padding: 10px;
display: grid;
gap: 6px;
background: rgba(255,255,255,0.95);
}
.org-teams {
display: grid;
grid-template-columns: repeat(7, minmax(160px, 1fr));
gap: 10px;
align-items: start;
}
.org-team {
border: 1px solid rgba(110, 126, 152, 0.25);
border-radius: 10px;
overflow: hidden;
background: rgba(255,255,255,0.95);
min-width: 0;
}
.org-team h4 {
margin: 0;
padding: 9px 10px;
font-size: 14px;
color: #21324e;
font-weight: 800;
border-bottom: 1px solid rgba(110, 126, 152, 0.2);
background: rgba(240, 245, 252, 0.96);
}
.org-members {
padding: 7px;
display: grid;
gap: 6px;
}
.org-person {
border: 1px solid rgba(116, 133, 161, 0.25);
background: rgba(255,255,255,0.95);
border-radius: 8px;
padding: 6px 8px;
cursor: pointer;
transition: background 120ms ease, border-color 120ms ease;
min-width: 0;
}
.org-person.active {
border-color: rgba(15,118,110,0.6);
background: rgba(15,118,110,0.11);
}
.org-person.assigned {
border-color: rgba(37,99,235,0.5);
background: rgba(37,99,235,0.1);
}
.org-person strong {
display: block;
font-size: 13px;
line-height: 1.3;
color: #15233a;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.org-person small {
display: block;
color: #5a6a86;
font-size: 11px;
line-height: 1.25;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 980px) {
.mapper {
top: 72px;
width: min(96vw, 920px);
max-height: 58vh;
}
.viewer-actions {
top: 64px;
left: 12px;
right: 12px;
flex-wrap: wrap;
}
.mapper-head strong {
font-size: 16px;
}
.org-top-title {
font-size: 24px;
}
.org-teams {
grid-template-columns: repeat(3, minmax(150px, 1fr));
}
}
canvas {
width: 100%;
height: 100%;
display: block;
cursor: grab;
}
canvas.dragging { cursor: grabbing; }
.tooltip {
position: absolute;
min-width: 170px;
padding: 12px 14px;
border-radius: 16px;
background: rgba(17,24,39,0.94);
color: white;
pointer-events: none;
opacity: 0;
transform: translate(12px, 12px);
transition: opacity 120ms ease;
z-index: 3;
}
.tooltip.visible { opacity: 1; }
.tooltip strong { display: block; margin-bottom: 6px; font-size: 14px; }
.tooltip div { font-size: 12px; line-height: 1.45; color: rgba(255,255,255,0.82); }
</style>
</head>
<body>
<div class="page">
<div class="shell">
<main class="panel viewer">
<div class="viewer-head">
<div class="chip" id="scale-chip"></div>
<div class="chip" id="hover-chip">chair hover: none</div>
</div>
<div class="viewer-actions">
<button type="button" id="fit-btn">전체 맞춤</button>
<button type="button" class="alt" id="clear-btn">선택 지우기</button>
</div>
<aside class="mapper hidden-off">
<div class="mapper-head">
<div id="mapper-status">
<strong>조직 현황</strong>
<span>선택 인원 없음</span>
</div>
<button type="button" class="alt" id="clear-assign-btn">매칭 초기화</button>
</div>
<div class="org-chart" id="org-chart"></div>
</aside>
<canvas id="canvas"></canvas>
<div class="tooltip" id="tooltip"></div>
</main>
</div>
</div>
<script src="./center_chair_people_payload_6f.js"></script>
<script>
const DATA = window.CHAIR_MAP_DATA;
function decodeSegments(base64) {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
return new Int32Array(bytes.buffer);
}
const bgTileRanges = DATA.bgTileRanges;
const bgSegValues = decodeSegments(DATA.bgSegsB64);
const chairSegValues = decodeSegments(DATA.chairSegsB64);
const chairs = DATA.chairs.map(([key, name, kind, start, count]) => ({
key, name, kind, start, count
}));
const meta = DATA.meta;
const world = meta.headerBounds;
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
const tooltip = document.getElementById("tooltip");
const scaleChip = document.getElementById("scale-chip");
const hoverChip = document.getElementById("hover-chip");
const STORAGE_KEY = "ptc-chair-selection";
const PEOPLE_STORAGE_KEY = "ptc-chair-people";
const ASSIGN_STORAGE_KEY = "ptc-chair-assignments";
const ACTIVE_PERSON_STORAGE_KEY = "ptc-chair-active-person";
const clearAssignBtn = document.getElementById("clear-assign-btn");
const orgChartEl = document.getElementById("org-chart");
const mapperStatus = document.getElementById("mapper-status");
// Prevent stale auto-highlights from previous sessions.
localStorage.removeItem(STORAGE_KEY);
localStorage.removeItem(ACTIVE_PERSON_STORAGE_KEY);
localStorage.removeItem(ASSIGN_STORAGE_KEY);
const placed = new Set();
let people = JSON.parse(localStorage.getItem(PEOPLE_STORAGE_KEY) || "[]");
let chairAssignments = {};
let activePersonId = null;
const ORG_TEMPLATE = {
top: {
name: "총괄기획실",
count: 53,
members: [
{ name: "장종찬", dept: "총괄기획실", title: "기획실장" },
{ name: "김원식", dept: "총괄기획실", title: "전무이사" },
],
},
teams: [
{ name: "경영기획팀", count: 6, members: ["김우진", "임민정", "국혜린", "최선아", "김윤재", "이미영"] },
{ name: "인재성장팀", count: 5, members: ["조태희", "최근혜", "류원준", "주안기", "정성호"] },
{ name: "ERP 기획팀", count: 5, members: ["류호성", "문형식", "최요제", "황대일", "이채봉"] },
{ name: "디자인기획팀", count: 17, members: ["신혜영", "정은혜", "김태식", "최예은", "채선영", "최영환", "윤봄이", "이예진", "허유나", "마희연", "김수현", "박지영", "권순호", "정두휘", "김정석", "정지윤", "양숙영"] },
{ name: "기술기획팀", count: 11, members: ["김원기", "홍아름", "이경민", "김혜인", "황동환", "최찬호", "이태훈", "김신지", "조찬영", "김용연", "한치영"] },
{ name: "협업증진팀", count: 3, members: ["성형일", "박주한", "한승민"] },
{ name: "솔루션통합팀", count: 4, members: ["권혁진", "염승호", "윤준수", "김지영"] },
],
};
const chairGeometry = chairs.map((chair) => {
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
const path = new Path2D();
const hitSegments = new Float32Array(chair.count * 4);
let segCursor = 0;
for (let i = chair.start; i < chair.start + chair.count; i += 1) {
const offset = i * 4;
const x1 = chairSegValues[offset] / 10;
const y1 = chairSegValues[offset + 1] / 10;
const x2 = chairSegValues[offset + 2] / 10;
const y2 = chairSegValues[offset + 3] / 10;
path.moveTo(x1, y1);
path.lineTo(x2, y2);
hitSegments[segCursor] = x1;
hitSegments[segCursor + 1] = y1;
hitSegments[segCursor + 2] = x2;
hitSegments[segCursor + 3] = y2;
segCursor += 4;
minX = Math.min(minX, x1, x2);
minY = Math.min(minY, y1, y2);
maxX = Math.max(maxX, x1, x2);
maxY = Math.max(maxY, y1, y2);
}
return {
...chair,
minX,
minY,
maxX,
maxY,
area: Math.max(1, (maxX - minX) * (maxY - minY)),
path,
hitSegments,
};
});
function renumberChairKeys(chairItems) {
if (!chairItems.length) return;
const heights = chairItems
.map((chair) => Math.max(1, chair.maxY - chair.minY))
.sort((a, b) => a - b);
const medianHeight = heights[Math.floor(heights.length / 2)] || 1;
const rowTolerance = Math.max(40, medianHeight * 0.9);
const sorted = [...chairItems].sort((a, b) => {
const ay = (a.minY + a.maxY) * 0.5;
const by = (b.minY + b.maxY) * 0.5;
if (Math.abs(by - ay) > rowTolerance) return by - ay; // top -> bottom
const ax = (a.minX + a.maxX) * 0.5;
const bx = (b.minX + b.maxX) * 0.5;
return ax - bx; // left -> right
});
sorted.forEach((chair, index) => {
chair.key = String(index + 1);
chair.seatNo = index + 1;
});
}
renumberChairKeys(chairGeometry);
const PICK_GRID_SIZE = 1800;
const chairPickGrid = new Map();
function pickGridKey(gx, gy) {
return `${gx},${gy}`;
}
chairGeometry.forEach((chair, index) => {
const minGX = Math.floor(chair.minX / PICK_GRID_SIZE);
const maxGX = Math.floor(chair.maxX / PICK_GRID_SIZE);
const minGY = Math.floor(chair.minY / PICK_GRID_SIZE);
const maxGY = Math.floor(chair.maxY / PICK_GRID_SIZE);
for (let gx = minGX; gx <= maxGX; gx += 1) {
for (let gy = minGY; gy <= maxGY; gy += 1) {
const key = pickGridKey(gx, gy);
if (!chairPickGrid.has(key)) chairPickGrid.set(key, []);
chairPickGrid.get(key).push(index);
}
}
});
const camera = { scale: 1, offsetX: 0, offsetY: 0 };
let pixelRatio = window.devicePixelRatio || 1;
let pointer = { x: 0, y: 0 };
let dragging = false;
let dragStart = null;
let hovered = null;
let rafPending = false;
function normalizePeople(raw) {
return raw
.map((person, index) => {
if (!person || !person.name) return null;
return {
id: person.id || `person-${index + 1}`,
name: String(person.name).trim(),
dept: String(person.dept || "").trim(),
title: String(person.title || "").trim(),
};
})
.filter(Boolean);
}
function createTemplatePeople() {
const generated = [];
let seq = 1;
ORG_TEMPLATE.top.members.forEach((member) => {
generated.push({
id: `org-${seq++}`,
name: member.name,
dept: member.dept,
title: member.title,
});
});
ORG_TEMPLATE.teams.forEach((team) => {
team.members.forEach((name) => {
generated.push({
id: `org-${seq++}`,
name,
dept: team.name,
title: "선임",
});
});
});
return generated;
}
people = normalizePeople(people);
const templateReady = people.some((person) => person.name === "장종찬" && person.dept === "총괄기획실");
if (!templateReady) {
people = createTemplatePeople();
localStorage.setItem(PEOPLE_STORAGE_KEY, JSON.stringify(people));
}
const chairKeySet = new Set(chairGeometry.map((chair) => chair.key));
chairAssignments = Object.fromEntries(
Object.entries(chairAssignments).filter(([chairKey, personId]) => (
chairKeySet.has(chairKey) && people.some((person) => person.id === personId)
))
);
if (activePersonId && !people.some((person) => person.id === activePersonId)) activePersonId = null;
function persistPeople() {
localStorage.setItem(PEOPLE_STORAGE_KEY, JSON.stringify(people));
}
function persistAssignments() {
localStorage.setItem(ASSIGN_STORAGE_KEY, JSON.stringify(chairAssignments));
}
function persistActivePerson() {
if (!activePersonId) localStorage.removeItem(ACTIVE_PERSON_STORAGE_KEY);
else localStorage.setItem(ACTIVE_PERSON_STORAGE_KEY, activePersonId);
}
function assignmentCount() {
return Object.keys(chairAssignments).length;
}
function getPersonById(id) {
return people.find((person) => person.id === id) || null;
}
function getChairByPerson(personId) {
for (const [chairKey, assignedPersonId] of Object.entries(chairAssignments)) {
if (assignedPersonId === personId) return chairKey;
}
return null;
}
function renderPeopleList() {
const activePerson = getPersonById(activePersonId);
const countText = `${assignmentCount()} / ${people.length} 매칭`;
mapperStatus.innerHTML = `<strong>조직 현황</strong><span>${activePerson ? `${activePerson.name} 선택됨` : "선택 인원 없음"} · ${countText}</span>`;
const findPerson = (dept, name) => people.find((person) => person.dept === dept && person.name === name) || null;
const personCard = (person, roleText) => {
if (!person) return "";
const chairKey = getChairByPerson(person.id);
const assignedClass = chairKey ? " assigned" : "";
const activeClass = person.id === activePersonId ? " active" : "";
return `
<article class="org-person${assignedClass}${activeClass}" data-person-id="${person.id}">
<strong>${person.name}</strong>
<small>${person.title || roleText || "-"}</small>
<small>${chairKey ? `좌석 ${chairKey}` : "좌석 미지정"}</small>
</article>
`;
};
const topHtml = ORG_TEMPLATE.top.members
.map((member) => personCard(findPerson(member.dept, member.name), member.title))
.join("");
const teamsHtml = ORG_TEMPLATE.teams.map((team) => {
const membersHtml = team.members
.map((name) => personCard(findPerson(team.name, name), "선임"))
.join("");
return `
<section class="org-team">
<h4>${team.name} (${team.count})</h4>
<div class="org-members">${membersHtml}</div>
</section>
`;
}).join("");
orgChartEl.innerHTML = `
<section class="org-top">
<div class="org-top-title">${ORG_TEMPLATE.top.name} (${ORG_TEMPLATE.top.count})</div>
<div class="org-top-members">${topHtml}</div>
</section>
<section class="org-teams">${teamsHtml}</section>
`;
}
function worldToScreen(x, y) {
return {
x: x * camera.scale + camera.offsetX,
y: (world.maxY - y + world.minY) * camera.scale + camera.offsetY,
};
}
function screenToWorld(x, y) {
return {
x: (x - camera.offsetX) / camera.scale,
y: world.maxY + world.minY - (y - camera.offsetY) / camera.scale,
};
}
function resize() {
pixelRatio = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = Math.round(rect.width * pixelRatio);
canvas.height = Math.round(rect.height * pixelRatio);
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
fit();
}
function fit() {
const rect = canvas.getBoundingClientRect();
const width = world.maxX - world.minX;
const height = world.maxY - world.minY;
const pad = 36;
const scaleX = (rect.width - pad * 2) / width;
const scaleY = (rect.height - pad * 2) / height;
camera.scale = Math.min(scaleX, scaleY);
camera.offsetX = pad - world.minX * camera.scale + (rect.width - pad * 2 - width * camera.scale) / 2;
camera.offsetY = pad - world.minY * camera.scale + (rect.height - pad * 2 - height * camera.scale) / 2;
requestDraw();
}
function drawGrid(width, height) {
ctx.save();
ctx.strokeStyle = "rgba(21,35,48,0.05)";
ctx.lineWidth = 1;
for (let x = 120; x < width; x += 120) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, height);
ctx.stroke();
}
for (let y = 120; y < height; y += 120) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(width, y);
ctx.stroke();
}
ctx.restore();
}
function pickChair(screenX, screenY) {
const threshold = 12;
const pointerWorld = screenToWorld(screenX, screenY);
const thresholdWorld = threshold / camera.scale;
const thresholdWorldSq = thresholdWorld * thresholdWorld;
const minGX = Math.floor((pointerWorld.x - thresholdWorld) / PICK_GRID_SIZE);
const maxGX = Math.floor((pointerWorld.x + thresholdWorld) / PICK_GRID_SIZE);
const minGY = Math.floor((pointerWorld.y - thresholdWorld) / PICK_GRID_SIZE);
const maxGY = Math.floor((pointerWorld.y + thresholdWorld) / PICK_GRID_SIZE);
const candidateIndexes = [];
const seen = new Set();
for (let gx = minGX; gx <= maxGX; gx += 1) {
for (let gy = minGY; gy <= maxGY; gy += 1) {
const candidates = chairPickGrid.get(pickGridKey(gx, gy));
if (!candidates) continue;
for (const index of candidates) {
if (seen.has(index)) continue;
seen.add(index);
candidateIndexes.push(index);
}
}
}
let best = null;
for (const index of candidateIndexes) {
const chair = chairGeometry[index];
if (
pointerWorld.x < chair.minX - thresholdWorld ||
pointerWorld.x > chair.maxX + thresholdWorld ||
pointerWorld.y < chair.minY - thresholdWorld ||
pointerWorld.y > chair.maxY + thresholdWorld
) continue;
let distSq = Infinity;
for (let i = 0; i < chair.hitSegments.length; i += 4) {
const x1 = chair.hitSegments[i];
const y1 = chair.hitSegments[i + 1];
const x2 = chair.hitSegments[i + 2];
const y2 = chair.hitSegments[i + 3];
const dx = x2 - x1;
const dy = y2 - y1;
const len2 = dx * dx + dy * dy;
let segDistSq;
if (len2 === 0) {
const px = pointerWorld.x - x1;
const py = pointerWorld.y - y1;
segDistSq = px * px + py * py;
} else {
let t = ((pointerWorld.x - x1) * dx + (pointerWorld.y - y1) * dy) / len2;
t = Math.max(0, Math.min(1, t));
const lx = x1 + t * dx;
const ly = y1 + t * dy;
const px = pointerWorld.x - lx;
const py = pointerWorld.y - ly;
segDistSq = px * px + py * py;
}
if (segDistSq < distSq) distSq = segDistSq;
if (distSq <= thresholdWorldSq * 0.3) break;
}
if (distSq > thresholdWorldSq) continue;
const dist = Math.sqrt(distSq) * camera.scale;
if (!best) {
best = { chair, dist };
continue;
}
const distGap = dist - best.dist;
if (distGap < -0.75) {
best = { chair, dist };
continue;
}
if (Math.abs(distGap) <= 2) {
const areaGap = chair.area - best.chair.area;
if (areaGap < -1) {
best = { chair, dist };
continue;
}
if (
Math.abs(areaGap) <= 1 &&
chair.kind === "block" &&
best.chair.kind !== "block"
) {
best = { chair, dist };
}
}
}
return best ? best.chair : null;
}
function renderTooltip() {
if (!hovered) {
tooltip.classList.remove("visible");
hoverChip.textContent = "chair hover: none";
return;
}
hoverChip.textContent = `chair hover: ${hovered.name}`;
tooltip.innerHTML = `
<strong>${hovered.name}</strong>
<div>chair key: ${hovered.key}</div>
<div>${placed.has(hovered.key) ? "선택됨" : "클릭하면 선택"}</div>
<div>${chairAssignments[hovered.key] ? `배치: ${(getPersonById(chairAssignments[hovered.key]) || { name: "알수없음" }).name}` : "배치 인원 없음"}</div>
`;
tooltip.style.left = `${pointer.x + 14}px`;
tooltip.style.top = `${pointer.y + 14}px`;
tooltip.classList.add("visible");
}
function requestDraw() {
if (rafPending) return;
rafPending = true;
window.requestAnimationFrame(() => {
rafPending = false;
draw();
});
}
function applyWorldTransform() {
ctx.setTransform(
pixelRatio * camera.scale,
0,
0,
-pixelRatio * camera.scale,
pixelRatio * camera.offsetX,
pixelRatio * ((world.maxY + world.minY) * camera.scale + camera.offsetY)
);
}
function draw() {
const rect = canvas.getBoundingClientRect();
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
ctx.clearRect(0, 0, rect.width, rect.height);
drawGrid(rect.width, rect.height);
const viewA = screenToWorld(0, rect.height);
const viewB = screenToWorld(rect.width, 0);
const viewMinX = Math.min(viewA.x, viewB.x);
const viewMaxX = Math.max(viewA.x, viewB.x);
const viewMinY = Math.min(viewA.y, viewB.y);
const viewMaxY = Math.max(viewA.y, viewB.y);
ctx.save();
applyWorldTransform();
ctx.strokeStyle = "rgba(100, 116, 139, 0.28)";
ctx.lineWidth = 1 / camera.scale;
const tileSize = meta.backgroundTileSize;
const tileMinX = Math.floor(viewMinX / tileSize);
const tileMaxX = Math.floor(viewMaxX / tileSize);
const tileMinY = Math.floor(viewMinY / tileSize);
const tileMaxY = Math.floor(viewMaxY / tileSize);
for (let tx = tileMinX; tx <= tileMaxX; tx += 1) {
for (let ty = tileMinY; ty <= tileMaxY; ty += 1) {
const range = bgTileRanges[`${tx},${ty}`];
if (!range) continue;
const start = range[0];
const count = range[1];
for (let i = start; i < start + count; i += 1) {
const offset = i * 4;
const x1 = bgSegValues[offset] / 10;
const y1 = bgSegValues[offset + 1] / 10;
const x2 = bgSegValues[offset + 2] / 10;
const y2 = bgSegValues[offset + 3] / 10;
if (
Math.max(x1, x2) < viewMinX ||
Math.min(x1, x2) > viewMaxX ||
Math.max(y1, y2) < viewMinY ||
Math.min(y1, y2) > viewMaxY
) continue;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
}
}
}
ctx.restore();
hovered = dragging ? null : pickChair(pointer.x, pointer.y);
ctx.save();
applyWorldTransform();
ctx.lineWidth = 1.45 / camera.scale;
ctx.lineCap = "round";
ctx.lineJoin = "round";
for (const chair of chairGeometry) {
if (chair.maxX < viewMinX || chair.minX > viewMaxX || chair.maxY < viewMinY || chair.minY > viewMaxY) continue;
const active = hovered && hovered.key === chair.key;
const selected = placed.has(chair.key);
const assignedPersonId = chairAssignments[chair.key];
const activePersonChair = activePersonId && assignedPersonId === activePersonId;
const assigned = Boolean(assignedPersonId);
const baseWidth = chair.kind === "block" ? 1.45 : 1.35;
ctx.strokeStyle = activePersonChair
? "rgba(234, 179, 8, 1)"
: assigned
? "rgba(37, 99, 235, 0.98)"
: selected
? "rgba(220, 38, 38, 0.98)"
: active
? "rgba(15, 118, 110, 0.98)"
: chair.kind === "group"
? "rgba(16, 134, 149, 0.74)"
: "rgba(21, 149, 142, 0.8)";
ctx.lineWidth = (activePersonChair ? 2.8 : assigned ? 2.4 : selected ? 2.6 : active ? 2.1 : baseWidth) / camera.scale;
ctx.stroke(chair.path);
}
ctx.restore();
scaleChip.textContent = `scale ${camera.scale.toFixed(4)}x`;
renderTooltip();
}
function persistPlaced() {
localStorage.setItem(STORAGE_KEY, JSON.stringify([...placed]));
}
canvas.addEventListener("pointerdown", (event) => {
dragging = true;
dragStart = { x: event.clientX, y: event.clientY, offsetX: camera.offsetX, offsetY: camera.offsetY };
canvas.classList.add("dragging");
});
window.addEventListener("pointerup", (event) => {
if (dragging && dragStart) {
const move = Math.hypot(event.clientX - dragStart.x, event.clientY - dragStart.y);
if (move < 4) {
const rect = canvas.getBoundingClientRect();
const picked = pickChair(event.clientX - rect.left, event.clientY - rect.top);
if (picked) {
if (placed.has(picked.key)) placed.delete(picked.key);
else placed.add(picked.key);
persistPlaced();
if (activePersonId) {
const currentChair = getChairByPerson(activePersonId);
if (chairAssignments[picked.key] === activePersonId) {
delete chairAssignments[picked.key];
} else {
if (currentChair && currentChair !== picked.key) delete chairAssignments[currentChair];
chairAssignments[picked.key] = activePersonId;
}
persistAssignments();
renderPeopleList();
}
}
}
}
dragging = false;
dragStart = null;
canvas.classList.remove("dragging");
requestDraw();
});
window.addEventListener("pointermove", (event) => {
const rect = canvas.getBoundingClientRect();
pointer = { x: event.clientX - rect.left, y: event.clientY - rect.top };
if (dragging && dragStart) {
camera.offsetX = dragStart.offsetX + (event.clientX - dragStart.x);
camera.offsetY = dragStart.offsetY + (event.clientY - dragStart.y);
}
requestDraw();
});
canvas.addEventListener("wheel", (event) => {
event.preventDefault();
const rect = canvas.getBoundingClientRect();
const mx = event.clientX - rect.left;
const my = event.clientY - rect.top;
const before = screenToWorld(mx, my);
const factor = event.deltaY < 0 ? 1.08 : 0.92;
camera.scale = Math.max(0.002, Math.min(2, camera.scale * factor));
const after = worldToScreen(before.x, before.y);
camera.offsetX += mx - after.x;
camera.offsetY += my - after.y;
requestDraw();
}, { passive: false });
document.getElementById("fit-btn").addEventListener("click", fit);
document.getElementById("clear-btn").addEventListener("click", () => {
placed.clear();
persistPlaced();
requestDraw();
});
clearAssignBtn.addEventListener("click", () => {
chairAssignments = {};
persistAssignments();
renderPeopleList();
requestDraw();
});
orgChartEl.addEventListener("click", (event) => {
const item = event.target.closest(".org-person[data-person-id]");
if (!item) return;
const personId = item.getAttribute("data-person-id");
activePersonId = personId === activePersonId ? null : personId;
persistActivePerson();
renderPeopleList();
requestDraw();
});
window.addEventListener("resize", resize);
renderPeopleList();
resize();
</script>
</body>
</html>

View File

@@ -0,0 +1,932 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>center chair people map 7f</title>
<style>
:root {
--ink: #152330;
--muted: #627286;
--paper: rgba(255,255,255,0.86);
--line: rgba(21,35,48,0.1);
--accent: #0f766e;
--bg: #edf2f6;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "IBM Plex Sans KR", "Pretendard", sans-serif;
color: var(--ink);
background:
radial-gradient(circle at top left, rgba(15,118,110,0.11), transparent 22%),
linear-gradient(180deg, #f5f8fb 0%, #e8eef3 100%);
}
.page {
min-height: 100vh;
padding: 0;
}
.shell {
min-height: 100vh;
}
.panel {
border-radius: 0;
border: none;
background: transparent;
backdrop-filter: none;
box-shadow: none;
}
.actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
button {
border: none;
border-radius: 999px;
padding: 10px 14px;
font: inherit;
font-weight: 700;
cursor: pointer;
color: white;
background: linear-gradient(135deg, #0f766e, #115e59);
box-shadow: 0 10px 22px rgba(15,118,110,0.18);
}
button.alt {
color: var(--ink);
background: rgba(255,255,255,0.9);
border: 1px solid var(--line);
box-shadow: none;
}
.viewer {
position: relative;
overflow: hidden;
min-height: 100vh;
}
.viewer-head {
position: absolute;
top: 16px;
left: 16px;
right: 16px;
z-index: 2;
display: flex;
justify-content: space-between;
gap: 12px;
pointer-events: none;
}
.chip {
padding: 10px 12px;
border-radius: 16px;
background: rgba(255,255,255,0.82);
border: 1px solid rgba(255,255,255,0.94);
color: var(--muted);
font-size: 13px;
font-weight: 700;
box-shadow: 0 8px 24px rgba(21,35,48,0.08);
}
.viewer-actions {
position: absolute;
left: 16px;
top: 64px;
z-index: 2;
display: flex;
gap: 8px;
}
.mapper {
position: absolute;
top: 76px;
left: 50%;
transform: translateX(-50%);
width: min(94vw, 1320px);
max-height: min(56vh, 560px);
overflow: hidden;
z-index: 4;
border-radius: 20px;
background: rgba(234, 239, 247, 0.95);
border: 1px solid rgba(101, 119, 146, 0.22);
box-shadow: 0 18px 36px rgba(15, 23, 42, 0.2);
display: flex;
flex-direction: column;
backdrop-filter: blur(6px);
}
.hidden-off {
display: none !important;
}
.mapper-head {
padding: 10px 14px;
border-bottom: 1px solid rgba(101,119,146,0.18);
font-size: 12px;
color: #51607a;
font-weight: 700;
line-height: 1.35;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
background: rgba(255,255,255,0.6);
}
.mapper-head strong {
display: block;
color: #17243b;
font-size: 20px;
margin-bottom: 2px;
}
.mapper-head .alt {
padding: 8px 10px;
font-size: 12px;
white-space: nowrap;
}
.org-chart {
margin: 0;
padding: 14px;
overflow: auto;
display: grid;
gap: 12px;
}
.org-top {
margin: 0 auto;
width: min(100%, 420px);
border-radius: 14px;
overflow: hidden;
border: 1px solid rgba(67, 84, 118, 0.25);
background: #fff;
}
.org-top-title {
background: #1e2f4d;
color: #fff;
text-align: center;
font-size: 34px;
font-weight: 800;
line-height: 1.1;
padding: 16px 12px;
letter-spacing: -0.03em;
}
.org-top-members {
padding: 10px;
display: grid;
gap: 6px;
background: rgba(255,255,255,0.95);
}
.org-teams {
display: grid;
grid-template-columns: repeat(7, minmax(160px, 1fr));
gap: 10px;
align-items: start;
}
.org-team {
border: 1px solid rgba(110, 126, 152, 0.25);
border-radius: 10px;
overflow: hidden;
background: rgba(255,255,255,0.95);
min-width: 0;
}
.org-team h4 {
margin: 0;
padding: 9px 10px;
font-size: 14px;
color: #21324e;
font-weight: 800;
border-bottom: 1px solid rgba(110, 126, 152, 0.2);
background: rgba(240, 245, 252, 0.96);
}
.org-members {
padding: 7px;
display: grid;
gap: 6px;
}
.org-person {
border: 1px solid rgba(116, 133, 161, 0.25);
background: rgba(255,255,255,0.95);
border-radius: 8px;
padding: 6px 8px;
cursor: pointer;
transition: background 120ms ease, border-color 120ms ease;
min-width: 0;
}
.org-person.active {
border-color: rgba(15,118,110,0.6);
background: rgba(15,118,110,0.11);
}
.org-person.assigned {
border-color: rgba(37,99,235,0.5);
background: rgba(37,99,235,0.1);
}
.org-person strong {
display: block;
font-size: 13px;
line-height: 1.3;
color: #15233a;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.org-person small {
display: block;
color: #5a6a86;
font-size: 11px;
line-height: 1.25;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 980px) {
.mapper {
top: 72px;
width: min(96vw, 920px);
max-height: 58vh;
}
.viewer-actions {
top: 64px;
left: 12px;
right: 12px;
flex-wrap: wrap;
}
.mapper-head strong {
font-size: 16px;
}
.org-top-title {
font-size: 24px;
}
.org-teams {
grid-template-columns: repeat(3, minmax(150px, 1fr));
}
}
canvas {
width: 100%;
height: 100%;
display: block;
cursor: grab;
}
canvas.dragging { cursor: grabbing; }
.tooltip {
position: absolute;
min-width: 170px;
padding: 12px 14px;
border-radius: 16px;
background: rgba(17,24,39,0.94);
color: white;
pointer-events: none;
opacity: 0;
transform: translate(12px, 12px);
transition: opacity 120ms ease;
z-index: 3;
}
.tooltip.visible { opacity: 1; }
.tooltip strong { display: block; margin-bottom: 6px; font-size: 14px; }
.tooltip div { font-size: 12px; line-height: 1.45; color: rgba(255,255,255,0.82); }
</style>
</head>
<body>
<div class="page">
<div class="shell">
<main class="panel viewer">
<div class="viewer-head">
<div class="chip" id="scale-chip"></div>
<div class="chip" id="hover-chip">chair hover: none</div>
</div>
<div class="viewer-actions">
<button type="button" id="fit-btn">전체 맞춤</button>
<button type="button" class="alt" id="clear-btn">선택 지우기</button>
</div>
<aside class="mapper hidden-off">
<div class="mapper-head">
<div id="mapper-status">
<strong>조직 현황</strong>
<span>선택 인원 없음</span>
</div>
<button type="button" class="alt" id="clear-assign-btn">매칭 초기화</button>
</div>
<div class="org-chart" id="org-chart"></div>
</aside>
<canvas id="canvas"></canvas>
<div class="tooltip" id="tooltip"></div>
</main>
</div>
</div>
<script src="./center_chair_people_payload_7f.js"></script>
<script>
const DATA = window.CHAIR_MAP_DATA;
function decodeSegments(base64) {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
return new Int32Array(bytes.buffer);
}
const bgTileRanges = DATA.bgTileRanges;
const bgSegValues = decodeSegments(DATA.bgSegsB64);
const chairSegValues = decodeSegments(DATA.chairSegsB64);
const chairs = DATA.chairs.map(([key, name, kind, start, count]) => ({
key, name, kind, start, count
}));
const meta = DATA.meta;
const world = meta.headerBounds;
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
const tooltip = document.getElementById("tooltip");
const scaleChip = document.getElementById("scale-chip");
const hoverChip = document.getElementById("hover-chip");
const STORAGE_KEY = "ptc-chair-selection";
const PEOPLE_STORAGE_KEY = "ptc-chair-people";
const ASSIGN_STORAGE_KEY = "ptc-chair-assignments";
const ACTIVE_PERSON_STORAGE_KEY = "ptc-chair-active-person";
const clearAssignBtn = document.getElementById("clear-assign-btn");
const orgChartEl = document.getElementById("org-chart");
const mapperStatus = document.getElementById("mapper-status");
// Prevent stale auto-highlights from previous sessions.
localStorage.removeItem(STORAGE_KEY);
localStorage.removeItem(ACTIVE_PERSON_STORAGE_KEY);
localStorage.removeItem(ASSIGN_STORAGE_KEY);
const placed = new Set();
let people = JSON.parse(localStorage.getItem(PEOPLE_STORAGE_KEY) || "[]");
let chairAssignments = {};
let activePersonId = null;
const ORG_TEMPLATE = {
top: {
name: "총괄기획실",
count: 53,
members: [
{ name: "장종찬", dept: "총괄기획실", title: "기획실장" },
{ name: "김원식", dept: "총괄기획실", title: "전무이사" },
],
},
teams: [
{ name: "경영기획팀", count: 6, members: ["김우진", "임민정", "국혜린", "최선아", "김윤재", "이미영"] },
{ name: "인재성장팀", count: 5, members: ["조태희", "최근혜", "류원준", "주안기", "정성호"] },
{ name: "ERP 기획팀", count: 5, members: ["류호성", "문형식", "최요제", "황대일", "이채봉"] },
{ name: "디자인기획팀", count: 17, members: ["신혜영", "정은혜", "김태식", "최예은", "채선영", "최영환", "윤봄이", "이예진", "허유나", "마희연", "김수현", "박지영", "권순호", "정두휘", "김정석", "정지윤", "양숙영"] },
{ name: "기술기획팀", count: 11, members: ["김원기", "홍아름", "이경민", "김혜인", "황동환", "최찬호", "이태훈", "김신지", "조찬영", "김용연", "한치영"] },
{ name: "협업증진팀", count: 3, members: ["성형일", "박주한", "한승민"] },
{ name: "솔루션통합팀", count: 4, members: ["권혁진", "염승호", "윤준수", "김지영"] },
],
};
const chairGeometry = chairs.map((chair) => {
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
const path = new Path2D();
const hitSegments = new Float32Array(chair.count * 4);
let segCursor = 0;
for (let i = chair.start; i < chair.start + chair.count; i += 1) {
const offset = i * 4;
const x1 = chairSegValues[offset] / 10;
const y1 = chairSegValues[offset + 1] / 10;
const x2 = chairSegValues[offset + 2] / 10;
const y2 = chairSegValues[offset + 3] / 10;
path.moveTo(x1, y1);
path.lineTo(x2, y2);
hitSegments[segCursor] = x1;
hitSegments[segCursor + 1] = y1;
hitSegments[segCursor + 2] = x2;
hitSegments[segCursor + 3] = y2;
segCursor += 4;
minX = Math.min(minX, x1, x2);
minY = Math.min(minY, y1, y2);
maxX = Math.max(maxX, x1, x2);
maxY = Math.max(maxY, y1, y2);
}
return {
...chair,
minX,
minY,
maxX,
maxY,
area: Math.max(1, (maxX - minX) * (maxY - minY)),
path,
hitSegments,
};
});
function renumberChairKeys(chairItems) {
if (!chairItems.length) return;
const heights = chairItems
.map((chair) => Math.max(1, chair.maxY - chair.minY))
.sort((a, b) => a - b);
const medianHeight = heights[Math.floor(heights.length / 2)] || 1;
const rowTolerance = Math.max(40, medianHeight * 0.9);
const sorted = [...chairItems].sort((a, b) => {
const ay = (a.minY + a.maxY) * 0.5;
const by = (b.minY + b.maxY) * 0.5;
if (Math.abs(by - ay) > rowTolerance) return by - ay; // top -> bottom
const ax = (a.minX + a.maxX) * 0.5;
const bx = (b.minX + b.maxX) * 0.5;
return ax - bx; // left -> right
});
sorted.forEach((chair, index) => {
chair.key = String(index + 1);
chair.seatNo = index + 1;
});
}
renumberChairKeys(chairGeometry);
const PICK_GRID_SIZE = 1800;
const chairPickGrid = new Map();
function pickGridKey(gx, gy) {
return `${gx},${gy}`;
}
chairGeometry.forEach((chair, index) => {
const minGX = Math.floor(chair.minX / PICK_GRID_SIZE);
const maxGX = Math.floor(chair.maxX / PICK_GRID_SIZE);
const minGY = Math.floor(chair.minY / PICK_GRID_SIZE);
const maxGY = Math.floor(chair.maxY / PICK_GRID_SIZE);
for (let gx = minGX; gx <= maxGX; gx += 1) {
for (let gy = minGY; gy <= maxGY; gy += 1) {
const key = pickGridKey(gx, gy);
if (!chairPickGrid.has(key)) chairPickGrid.set(key, []);
chairPickGrid.get(key).push(index);
}
}
});
const camera = { scale: 1, offsetX: 0, offsetY: 0 };
let pixelRatio = window.devicePixelRatio || 1;
let pointer = { x: 0, y: 0 };
let dragging = false;
let dragStart = null;
let hovered = null;
let rafPending = false;
function normalizePeople(raw) {
return raw
.map((person, index) => {
if (!person || !person.name) return null;
return {
id: person.id || `person-${index + 1}`,
name: String(person.name).trim(),
dept: String(person.dept || "").trim(),
title: String(person.title || "").trim(),
};
})
.filter(Boolean);
}
function createTemplatePeople() {
const generated = [];
let seq = 1;
ORG_TEMPLATE.top.members.forEach((member) => {
generated.push({
id: `org-${seq++}`,
name: member.name,
dept: member.dept,
title: member.title,
});
});
ORG_TEMPLATE.teams.forEach((team) => {
team.members.forEach((name) => {
generated.push({
id: `org-${seq++}`,
name,
dept: team.name,
title: "선임",
});
});
});
return generated;
}
people = normalizePeople(people);
const templateReady = people.some((person) => person.name === "장종찬" && person.dept === "총괄기획실");
if (!templateReady) {
people = createTemplatePeople();
localStorage.setItem(PEOPLE_STORAGE_KEY, JSON.stringify(people));
}
const chairKeySet = new Set(chairGeometry.map((chair) => chair.key));
chairAssignments = Object.fromEntries(
Object.entries(chairAssignments).filter(([chairKey, personId]) => (
chairKeySet.has(chairKey) && people.some((person) => person.id === personId)
))
);
if (activePersonId && !people.some((person) => person.id === activePersonId)) activePersonId = null;
function persistPeople() {
localStorage.setItem(PEOPLE_STORAGE_KEY, JSON.stringify(people));
}
function persistAssignments() {
localStorage.setItem(ASSIGN_STORAGE_KEY, JSON.stringify(chairAssignments));
}
function persistActivePerson() {
if (!activePersonId) localStorage.removeItem(ACTIVE_PERSON_STORAGE_KEY);
else localStorage.setItem(ACTIVE_PERSON_STORAGE_KEY, activePersonId);
}
function assignmentCount() {
return Object.keys(chairAssignments).length;
}
function getPersonById(id) {
return people.find((person) => person.id === id) || null;
}
function getChairByPerson(personId) {
for (const [chairKey, assignedPersonId] of Object.entries(chairAssignments)) {
if (assignedPersonId === personId) return chairKey;
}
return null;
}
function renderPeopleList() {
const activePerson = getPersonById(activePersonId);
const countText = `${assignmentCount()} / ${people.length} 매칭`;
mapperStatus.innerHTML = `<strong>조직 현황</strong><span>${activePerson ? `${activePerson.name} 선택됨` : "선택 인원 없음"} · ${countText}</span>`;
const findPerson = (dept, name) => people.find((person) => person.dept === dept && person.name === name) || null;
const personCard = (person, roleText) => {
if (!person) return "";
const chairKey = getChairByPerson(person.id);
const assignedClass = chairKey ? " assigned" : "";
const activeClass = person.id === activePersonId ? " active" : "";
return `
<article class="org-person${assignedClass}${activeClass}" data-person-id="${person.id}">
<strong>${person.name}</strong>
<small>${person.title || roleText || "-"}</small>
<small>${chairKey ? `좌석 ${chairKey}` : "좌석 미지정"}</small>
</article>
`;
};
const topHtml = ORG_TEMPLATE.top.members
.map((member) => personCard(findPerson(member.dept, member.name), member.title))
.join("");
const teamsHtml = ORG_TEMPLATE.teams.map((team) => {
const membersHtml = team.members
.map((name) => personCard(findPerson(team.name, name), "선임"))
.join("");
return `
<section class="org-team">
<h4>${team.name} (${team.count})</h4>
<div class="org-members">${membersHtml}</div>
</section>
`;
}).join("");
orgChartEl.innerHTML = `
<section class="org-top">
<div class="org-top-title">${ORG_TEMPLATE.top.name} (${ORG_TEMPLATE.top.count})</div>
<div class="org-top-members">${topHtml}</div>
</section>
<section class="org-teams">${teamsHtml}</section>
`;
}
function worldToScreen(x, y) {
return {
x: x * camera.scale + camera.offsetX,
y: (world.maxY - y + world.minY) * camera.scale + camera.offsetY,
};
}
function screenToWorld(x, y) {
return {
x: (x - camera.offsetX) / camera.scale,
y: world.maxY + world.minY - (y - camera.offsetY) / camera.scale,
};
}
function resize() {
pixelRatio = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = Math.round(rect.width * pixelRatio);
canvas.height = Math.round(rect.height * pixelRatio);
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
fit();
}
function fit() {
const rect = canvas.getBoundingClientRect();
const width = world.maxX - world.minX;
const height = world.maxY - world.minY;
const pad = 36;
const scaleX = (rect.width - pad * 2) / width;
const scaleY = (rect.height - pad * 2) / height;
camera.scale = Math.min(scaleX, scaleY);
camera.offsetX = pad - world.minX * camera.scale + (rect.width - pad * 2 - width * camera.scale) / 2;
camera.offsetY = pad - world.minY * camera.scale + (rect.height - pad * 2 - height * camera.scale) / 2;
requestDraw();
}
function drawGrid(width, height) {
ctx.save();
ctx.strokeStyle = "rgba(21,35,48,0.05)";
ctx.lineWidth = 1;
for (let x = 120; x < width; x += 120) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, height);
ctx.stroke();
}
for (let y = 120; y < height; y += 120) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(width, y);
ctx.stroke();
}
ctx.restore();
}
function pickChair(screenX, screenY) {
const threshold = 12;
const pointerWorld = screenToWorld(screenX, screenY);
const thresholdWorld = threshold / camera.scale;
const thresholdWorldSq = thresholdWorld * thresholdWorld;
const minGX = Math.floor((pointerWorld.x - thresholdWorld) / PICK_GRID_SIZE);
const maxGX = Math.floor((pointerWorld.x + thresholdWorld) / PICK_GRID_SIZE);
const minGY = Math.floor((pointerWorld.y - thresholdWorld) / PICK_GRID_SIZE);
const maxGY = Math.floor((pointerWorld.y + thresholdWorld) / PICK_GRID_SIZE);
const candidateIndexes = [];
const seen = new Set();
for (let gx = minGX; gx <= maxGX; gx += 1) {
for (let gy = minGY; gy <= maxGY; gy += 1) {
const candidates = chairPickGrid.get(pickGridKey(gx, gy));
if (!candidates) continue;
for (const index of candidates) {
if (seen.has(index)) continue;
seen.add(index);
candidateIndexes.push(index);
}
}
}
let best = null;
for (const index of candidateIndexes) {
const chair = chairGeometry[index];
if (
pointerWorld.x < chair.minX - thresholdWorld ||
pointerWorld.x > chair.maxX + thresholdWorld ||
pointerWorld.y < chair.minY - thresholdWorld ||
pointerWorld.y > chair.maxY + thresholdWorld
) continue;
let distSq = Infinity;
for (let i = 0; i < chair.hitSegments.length; i += 4) {
const x1 = chair.hitSegments[i];
const y1 = chair.hitSegments[i + 1];
const x2 = chair.hitSegments[i + 2];
const y2 = chair.hitSegments[i + 3];
const dx = x2 - x1;
const dy = y2 - y1;
const len2 = dx * dx + dy * dy;
let segDistSq;
if (len2 === 0) {
const px = pointerWorld.x - x1;
const py = pointerWorld.y - y1;
segDistSq = px * px + py * py;
} else {
let t = ((pointerWorld.x - x1) * dx + (pointerWorld.y - y1) * dy) / len2;
t = Math.max(0, Math.min(1, t));
const lx = x1 + t * dx;
const ly = y1 + t * dy;
const px = pointerWorld.x - lx;
const py = pointerWorld.y - ly;
segDistSq = px * px + py * py;
}
if (segDistSq < distSq) distSq = segDistSq;
if (distSq <= thresholdWorldSq * 0.3) break;
}
if (distSq > thresholdWorldSq) continue;
const dist = Math.sqrt(distSq) * camera.scale;
if (!best) {
best = { chair, dist };
continue;
}
const distGap = dist - best.dist;
if (distGap < -0.75) {
best = { chair, dist };
continue;
}
if (Math.abs(distGap) <= 2) {
const areaGap = chair.area - best.chair.area;
if (areaGap < -1) {
best = { chair, dist };
continue;
}
if (
Math.abs(areaGap) <= 1 &&
chair.kind === "block" &&
best.chair.kind !== "block"
) {
best = { chair, dist };
}
}
}
return best ? best.chair : null;
}
function renderTooltip() {
if (!hovered) {
tooltip.classList.remove("visible");
hoverChip.textContent = "chair hover: none";
return;
}
hoverChip.textContent = `chair hover: ${hovered.name}`;
tooltip.innerHTML = `
<strong>${hovered.name}</strong>
<div>chair key: ${hovered.key}</div>
<div>${placed.has(hovered.key) ? "선택됨" : "클릭하면 선택"}</div>
<div>${chairAssignments[hovered.key] ? `배치: ${(getPersonById(chairAssignments[hovered.key]) || { name: "알수없음" }).name}` : "배치 인원 없음"}</div>
`;
tooltip.style.left = `${pointer.x + 14}px`;
tooltip.style.top = `${pointer.y + 14}px`;
tooltip.classList.add("visible");
}
function requestDraw() {
if (rafPending) return;
rafPending = true;
window.requestAnimationFrame(() => {
rafPending = false;
draw();
});
}
function applyWorldTransform() {
ctx.setTransform(
pixelRatio * camera.scale,
0,
0,
-pixelRatio * camera.scale,
pixelRatio * camera.offsetX,
pixelRatio * ((world.maxY + world.minY) * camera.scale + camera.offsetY)
);
}
function draw() {
const rect = canvas.getBoundingClientRect();
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
ctx.clearRect(0, 0, rect.width, rect.height);
drawGrid(rect.width, rect.height);
const viewA = screenToWorld(0, rect.height);
const viewB = screenToWorld(rect.width, 0);
const viewMinX = Math.min(viewA.x, viewB.x);
const viewMaxX = Math.max(viewA.x, viewB.x);
const viewMinY = Math.min(viewA.y, viewB.y);
const viewMaxY = Math.max(viewA.y, viewB.y);
ctx.save();
applyWorldTransform();
ctx.strokeStyle = "rgba(100, 116, 139, 0.28)";
ctx.lineWidth = 1 / camera.scale;
const tileSize = meta.backgroundTileSize;
const tileMinX = Math.floor(viewMinX / tileSize);
const tileMaxX = Math.floor(viewMaxX / tileSize);
const tileMinY = Math.floor(viewMinY / tileSize);
const tileMaxY = Math.floor(viewMaxY / tileSize);
for (let tx = tileMinX; tx <= tileMaxX; tx += 1) {
for (let ty = tileMinY; ty <= tileMaxY; ty += 1) {
const range = bgTileRanges[`${tx},${ty}`];
if (!range) continue;
const start = range[0];
const count = range[1];
for (let i = start; i < start + count; i += 1) {
const offset = i * 4;
const x1 = bgSegValues[offset] / 10;
const y1 = bgSegValues[offset + 1] / 10;
const x2 = bgSegValues[offset + 2] / 10;
const y2 = bgSegValues[offset + 3] / 10;
if (
Math.max(x1, x2) < viewMinX ||
Math.min(x1, x2) > viewMaxX ||
Math.max(y1, y2) < viewMinY ||
Math.min(y1, y2) > viewMaxY
) continue;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
}
}
}
ctx.restore();
hovered = dragging ? null : pickChair(pointer.x, pointer.y);
ctx.save();
applyWorldTransform();
ctx.lineWidth = 1.45 / camera.scale;
ctx.lineCap = "round";
ctx.lineJoin = "round";
for (const chair of chairGeometry) {
if (chair.maxX < viewMinX || chair.minX > viewMaxX || chair.maxY < viewMinY || chair.minY > viewMaxY) continue;
const active = hovered && hovered.key === chair.key;
const selected = placed.has(chair.key);
const assignedPersonId = chairAssignments[chair.key];
const activePersonChair = activePersonId && assignedPersonId === activePersonId;
const assigned = Boolean(assignedPersonId);
const baseWidth = chair.kind === "block" ? 1.45 : 1.35;
ctx.strokeStyle = activePersonChair
? "rgba(234, 179, 8, 1)"
: assigned
? "rgba(37, 99, 235, 0.98)"
: selected
? "rgba(220, 38, 38, 0.98)"
: active
? "rgba(15, 118, 110, 0.98)"
: chair.kind === "group"
? "rgba(16, 134, 149, 0.74)"
: "rgba(21, 149, 142, 0.8)";
ctx.lineWidth = (activePersonChair ? 2.8 : assigned ? 2.4 : selected ? 2.6 : active ? 2.1 : baseWidth) / camera.scale;
ctx.stroke(chair.path);
}
ctx.restore();
scaleChip.textContent = `scale ${camera.scale.toFixed(4)}x`;
renderTooltip();
}
function persistPlaced() {
localStorage.setItem(STORAGE_KEY, JSON.stringify([...placed]));
}
canvas.addEventListener("pointerdown", (event) => {
dragging = true;
dragStart = { x: event.clientX, y: event.clientY, offsetX: camera.offsetX, offsetY: camera.offsetY };
canvas.classList.add("dragging");
});
window.addEventListener("pointerup", (event) => {
if (dragging && dragStart) {
const move = Math.hypot(event.clientX - dragStart.x, event.clientY - dragStart.y);
if (move < 4) {
const rect = canvas.getBoundingClientRect();
const picked = pickChair(event.clientX - rect.left, event.clientY - rect.top);
if (picked) {
if (placed.has(picked.key)) placed.delete(picked.key);
else placed.add(picked.key);
persistPlaced();
if (activePersonId) {
const currentChair = getChairByPerson(activePersonId);
if (chairAssignments[picked.key] === activePersonId) {
delete chairAssignments[picked.key];
} else {
if (currentChair && currentChair !== picked.key) delete chairAssignments[currentChair];
chairAssignments[picked.key] = activePersonId;
}
persistAssignments();
renderPeopleList();
}
}
}
}
dragging = false;
dragStart = null;
canvas.classList.remove("dragging");
requestDraw();
});
window.addEventListener("pointermove", (event) => {
const rect = canvas.getBoundingClientRect();
pointer = { x: event.clientX - rect.left, y: event.clientY - rect.top };
if (dragging && dragStart) {
camera.offsetX = dragStart.offsetX + (event.clientX - dragStart.x);
camera.offsetY = dragStart.offsetY + (event.clientY - dragStart.y);
}
requestDraw();
});
canvas.addEventListener("wheel", (event) => {
event.preventDefault();
const rect = canvas.getBoundingClientRect();
const mx = event.clientX - rect.left;
const my = event.clientY - rect.top;
const before = screenToWorld(mx, my);
const factor = event.deltaY < 0 ? 1.08 : 0.92;
camera.scale = Math.max(0.002, Math.min(2, camera.scale * factor));
const after = worldToScreen(before.x, before.y);
camera.offsetX += mx - after.x;
camera.offsetY += my - after.y;
requestDraw();
}, { passive: false });
document.getElementById("fit-btn").addEventListener("click", fit);
document.getElementById("clear-btn").addEventListener("click", () => {
placed.clear();
persistPlaced();
requestDraw();
});
clearAssignBtn.addEventListener("click", () => {
chairAssignments = {};
persistAssignments();
renderPeopleList();
requestDraw();
});
orgChartEl.addEventListener("click", (event) => {
const item = event.target.closest(".org-person[data-person-id]");
if (!item) return;
const personId = item.getAttribute("data-person-id");
activePersonId = personId === activePersonId ? null : personId;
persistActivePerson();
renderPeopleList();
requestDraw();
});
window.addEventListener("resize", resize);
renderPeopleList();
resize();
</script>
</body>
</html>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long