Files
sj-sample/dashboard.html

561 lines
31 KiB
HTML

<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DfMA 팀별/개인별 데이터 분석 시스템</title>
<!-- Tailwind CSS CDN -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- SheetJS CDN -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js"></script>
<!-- Google Charts CDN -->
<script type="text/javascript" src="https://www.gstatic.com/charts/loader.js"></script>
<!-- Lucide Icons CDN -->
<script src="https://unpkg.com/lucide@latest"></script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@100;300;400;500;700;900&display=swap');
body { font-family: 'Noto Sans KR', sans-serif; background-color: #f8fafc; }
.google-visualization-tooltip {
border: none !important;
border-radius: 1rem !important;
box-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.1) !important;
padding: 1rem !important;
}
/* 스크롤바 디자인 커스텀 */
.custom-scrollbar::-webkit-scrollbar { width: 6px; }
.custom-scrollbar::-webkit-scrollbar-track { background: #f1f5f9; }
.custom-scrollbar::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 10px; }
</style>
</head>
<body class="p-4 md:p-8">
<!-- Upload Zone -->
<div id="upload-container" class="min-h-[80vh] flex flex-col items-center justify-center">
<div onclick="document.getElementById('file-input').click()"
class="w-full max-w-2xl bg-white border-4 border-dashed border-blue-200 rounded-[2.5rem] p-16 text-center hover:border-blue-400 transition-all cursor-pointer group shadow-2xl shadow-blue-100/50">
<div class="bg-blue-50 w-28 h-28 rounded-[2rem] flex items-center justify-center mx-auto mb-10 group-hover:scale-110 transition-transform shadow-inner text-blue-600">
<i data-lucide="upload" size="56"></i>
</div>
<h2 class="text-4xl font-black text-slate-800 mb-6 tracking-tight">팀별/개인별 데이터 분석 시스템</h2>
<p class="text-slate-500 mb-12 text-xl font-medium leading-relaxed">
엑셀 파일을 업로드하세요. <br/>
</p>
<button class="bg-blue-600 text-white px-12 py-5 rounded-2xl font-black shadow-2xl shadow-blue-300 hover:bg-blue-700 transition-all text-xl active:scale-95">
파일 선택 및 분석 시작
</button>
<input id="file-input" type="file" class="hidden" accept=".xlsx, .xls">
</div>
</div>
<!-- Dashboard Content (Hidden at first) -->
<div id="dashboard-content" class="hidden animate-in fade-in duration-700">
<!-- Header & Filters -->
<div class="bg-white rounded-[2rem] shadow-2xl shadow-slate-200/50 border border-slate-200 p-6 mb-10 flex flex-wrap items-center justify-between gap-8 sticky top-4 z-50 backdrop-blur-xl bg-white/90">
<div class="flex items-center gap-4 min-w-0">
<div class="bg-blue-600 p-3 rounded-2xl text-white shadow-xl shadow-blue-200 shrink-0">
<i data-lucide="activity" size="28"></i>
</div>
<div class="min-w-0">
<h1 id="main-title" class="font-black text-2xl tracking-tighter text-slate-900 leading-none whitespace-nowrap overflow-hidden text-ellipsis"></h1>
</div>
</div>
<div class="flex flex-wrap items-center gap-4">
<div class="flex flex-col gap-1">
<span class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1"></span>
<div class="bg-slate-50 px-4 py-2 rounded-xl border border-slate-200">
<select id="team-select" class="bg-transparent border-none text-sm font-black outline-none cursor-pointer text-slate-700 min-w-[100px] py-1"></select>
</div>
</div>
<div class="flex flex-col gap-1">
<span class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">팀원</span>
<div class="bg-slate-50 px-4 py-2 rounded-xl border border-slate-200 flex items-center gap-2">
<i data-lucide="user" size="14" class="text-blue-500"></i>
<select id="person-select" class="bg-transparent border-none text-sm font-black outline-none cursor-pointer text-blue-600 min-w-[140px] py-1">
<option value="">전체 합계 비교 (직급순)</option>
</select>
</div>
</div>
<div class="flex flex-col gap-1">
<span class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">날짜</span>
<div class="bg-slate-50 px-4 py-2 rounded-xl border border-slate-200 flex items-center gap-3">
<i data-lucide="calendar" size="16" class="text-slate-400"></i>
<input type="date" id="start-date" class="bg-transparent border-none text-sm outline-none font-bold text-slate-700">
<span class="text-slate-300 font-black">~</span>
<input type="date" id="end-date" class="bg-transparent border-none text-sm outline-none font-bold text-slate-700">
</div>
</div>
<button onclick="location.reload()" class="mt-5 p-3 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded-2xl transition-all border border-transparent hover:border-red-100">
<i data-lucide="refresh-cw" size="24"></i>
</button>
</div>
</div>
<!-- KPI Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-10" id="kpi-container">
<!-- Cards will be injected here -->
</div>
<!-- Team Status Area (Matrix Updated with Visuals) -->
<div class="bg-white rounded-[3rem] shadow-2xl shadow-slate-200/50 border border-slate-100 overflow-hidden mb-10">
<div class="p-10 border-b bg-slate-50/50 flex flex-wrap items-center justify-between gap-6">
<h3 class="font-black text-3xl flex items-center gap-4 tracking-tighter text-slate-900">
<i data-lucide="file-text" size="36" class="text-emerald-500"></i> 팀별 현황
</h3>
<!-- 요약 통계를 헤더 내부로 이동하여 슬림하게 변경 -->
<div id="matrix-footer-visual" class="flex items-center gap-6">
<!-- Global statistics will be injected here as compact badges -->
</div>
</div>
<div class="p-10">
<div class="flex flex-col lg:flex-row gap-10">
<!-- Left: Business Unit Ratio (Pie Chart) -->
<div class="lg:w-1/3 bg-slate-50/50 rounded-[2rem] p-6 border border-slate-100 flex flex-col items-center">
<h4 class="text-sm font-black text-slate-400 uppercase tracking-widest mb-4">Business Unit Ratio</h4>
<div id="biz_pie_chart" class="w-full h-[350px]"></div>
</div>
<!-- Right: Project List by Business Unit -->
<div class="lg:w-2/3">
<h4 class="text-sm font-black text-slate-400 uppercase tracking-widest mb-4 ml-2">Projects by Unit</h4>
<div id="matrix-visual-container" class="grid grid-cols-1 md:grid-cols-2 gap-4 max-h-[600px] overflow-y-auto pr-2 custom-scrollbar">
<!-- Project cards will be injected here -->
</div>
</div>
</div>
</div>
</div>
<!-- Charts Area (Individual Status) -->
<div class="bg-white p-10 rounded-[3rem] shadow-2xl shadow-slate-200/50 border border-slate-100 mb-20">
<div class="flex flex-col lg:flex-row lg:items-center justify-between mb-12 gap-6">
<div class="flex items-center gap-5">
<div class="p-4 bg-slate-50 rounded-[1.5rem] border border-slate-100">
<i id="chart-icon" data-lucide="bar-chart-2" class="text-blue-600" size="32"></i>
</div>
<div>
<h3 id="chart-title" class="text-3xl font-black text-slate-900 tracking-tighter">분석 데이터를 기다리는 중...</h3>
<p id="chart-desc" class="text-slate-400 font-bold mt-1">파일 업로드 시 상세 가동률이 표출됩니다.</p>
</div>
</div>
<div id="target-badge" class="hidden flex items-center gap-4 bg-red-50/50 p-5 rounded-[2rem] border border-red-100/50">
<div class="text-right">
<span class="text-[10px] font-black text-red-400 uppercase tracking-widest block mb-1">Target Limit</span>
<div id="target-value" class="text-3xl font-black text-red-600 leading-none">0.00h</div>
</div>
</div>
</div>
<div id="chart_div" class="w-full min-h-[500px]"></div>
</div>
</div>
<script>
// --- Constants & Config ---
const HOLIDAYS = ["2026-01-01", "2026-03-01", "2026-05-05", "2026-06-06", "2026-08-15", "2026-10-03", "2026-10-09", "2026-12-25"];
const RANK_WEIGHTS = { '수석': 1, '책임': 2, '선임': 3, '연구원': 4 };
let fullData = [];
// --- Utils ---
const formatNum = (v) => Number(v || 0).toLocaleString('ko-KR', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
const dStr = (v) => {
if (!v) return "";
let d = (typeof v === 'number') ? new Date(Math.round((v - 25569) * 86400 * 1000)) : new Date(v);
if (isNaN(d.getTime())) return "";
return `${d.getFullYear()}-${("0" + (d.getMonth() + 1)).slice(-2)}-${("0" + d.getDate()).slice(-2)}`;
};
// --- Initialize Google Charts ---
google.charts.load('current', {'packages':['corechart']});
// --- File Handling ---
document.getElementById('file-input').addEventListener('change', function(e) {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = function(evt) {
const workbook = XLSX.read(evt.target.result, {type: 'binary', cellDates: true, dateNF: 'yyyy-mm-dd'});
fullData = XLSX.utils.sheet_to_json(workbook.Sheets[workbook.SheetNames[0]], {header: 1, defval: ""});
initDashboard();
};
reader.readAsBinaryString(file);
});
function initDashboard() {
if (fullData.length < 2) return;
document.getElementById('upload-container').classList.add('hidden');
document.getElementById('dashboard-content').classList.remove('hidden');
const dates = fullData.slice(1).map(r => dStr(r[0])).filter(Boolean).sort();
document.getElementById('start-date').value = dates[0];
document.getElementById('end-date').value = dates[dates.length - 1];
const teams = [...new Set(fullData.slice(1).map(r => String(r[2] || '').trim()))].filter(Boolean).sort();
const teamSelect = document.getElementById('team-select');
teamSelect.innerHTML = teams.map(t => `<option value="${t}">${t}</option>`).join('');
updateFilters();
lucide.createIcons();
}
function updateFilters() {
const team = document.getElementById('team-select').value;
document.getElementById('main-title').innerText = team;
const people = [...new Set(fullData.slice(1).filter(r => String(r[2] || '').trim() === team).map(r => String(r[4] || '').trim()))].filter(Boolean).sort();
const personSelect = document.getElementById('person-select');
const currentVal = personSelect.value;
personSelect.innerHTML = '<option value="">전체 합계 비교 (직급순)</option>' + people.map(p => `<option value="${p}">${p}</option>`).join('');
personSelect.value = people.includes(currentVal) ? currentVal : "";
render();
}
document.getElementById('team-select').addEventListener('change', updateFilters);
document.getElementById('person-select').addEventListener('change', render);
document.getElementById('start-date').addEventListener('change', render);
document.getElementById('end-date').addEventListener('change', render);
function render() {
const team = document.getElementById('team-select').value;
const person = document.getElementById('person-select').value;
const start = document.getElementById('start-date').value;
const end = document.getElementById('end-date').value;
let workDays = 0;
let curr = new Date(start);
const stop = new Date(end);
while (curr <= stop) {
const fmt = dStr(curr);
if (curr.getDay() !== 0 && curr.getDay() !== 6 && !HOLIDAYS.includes(fmt)) workDays++;
curr.setDate(curr.getDate() + 1);
}
const targetH = workDays * 8;
const results = processData(team, person, start, end, targetH);
renderKPIs(results.stats);
renderMatrix(results.matrix);
drawChart(results.chartData, targetH, person);
lucide.createIcons();
}
function processData(teamF, personF, startStr, endStr, targetH) {
let pMap = {}; let mMap = {};
let stats = { totalMH: 0, personCount: 0, extraHolMH: 0, overCount: 0 };
const slots = [
{ biz: 8, proj: 10, time: 12, extra: false },
{ biz: 14, proj: 16, time: 18, extra: false },
{ biz: 19, proj: 21, time: 23, extra: false },
{ biz: 24, proj: 26, time: 28, extra: false },
{ biz: 29, proj: 31, time: 33, extra: false },
{ biz: 34, proj: 36, time: 38, extra: false },
{ biz: 39, proj: 41, time: 44, extra: true }
];
fullData.slice(1).forEach(row => {
const d = dStr(row[0]);
if (!d || d < startStr || d > endStr || String(row[2] || '').trim() !== teamF) return;
const isWeekend = String(row[6] || '').includes("주말");
const name = String(row[4] || '').trim();
const rank = String(row[5] || '연구원').trim();
if (!name) return;
if (!pMap[name]) pMap[name] = { name, rank, normal: 0, extra: 0, holiday: 0, total: 0, projects: {} };
slots.forEach(s => {
const val = parseFloat(String(row[s.time]).replace(/[^0-9.]/g, '')) || 0;
const pName = String(row[s.proj] || '').trim();
const biz = String(row[s.biz] || '공통').trim();
if (val > 0 && pName) {
stats.totalMH += val;
pMap[name].total += val;
let type = 'normal';
if (isWeekend) { type = 'holiday'; pMap[name].holiday += val; stats.extraHolMH += val; }
else if (s.extra) { type = 'extra'; pMap[name].extra += val; stats.extraHolMH += val; }
else { pMap[name].normal += val; }
if (!pMap[name].projects[pName]) pMap[name].projects[pName] = { normal: 0, extra: 0, holiday: 0, total: 0 };
pMap[name].projects[pName].total += val;
pMap[name].projects[pName][type] += val;
if (!mMap[biz]) mMap[biz] = {};
if (!mMap[biz][pName]) {
mMap[biz][pName] = {
total: 0,
ps: new Set(),
rankDetails: {
"수석": { mh: 0, ps: new Set() },
"책임": { mh: 0, ps: new Set() },
"선임": { mh: 0, ps: new Set() },
"연구원": { mh: 0, ps: new Set() }
}
};
}
mMap[biz][pName].total += val;
mMap[biz][pName].ps.add(name);
const rKey = rank.includes("수석") ? "수석" : rank.includes("책임") ? "책임" : rank.includes("선임") ? "선임" : "연구원";
mMap[biz][pName].rankDetails[rKey].mh += val;
mMap[biz][pName].rankDetails[rKey].ps.add(name);
}
});
});
const teamList = Object.values(pMap).sort((a, b) => {
const wa = RANK_WEIGHTS[a.rank.includes('수석') ? '수석' : a.rank.includes('책임') ? '책임' : a.rank.includes('선임') ? '선임' : '연구원'] || 99;
const wb = RANK_WEIGHTS[b.rank.includes('수석') ? '수석' : b.rank.includes('책임') ? '책임' : b.rank.includes('선임') ? '선임' : '연구원'] || 99;
return (wa !== wb) ? wa - wb : a.name.localeCompare(b.name, 'ko');
});
stats.personCount = teamList.length;
teamList.forEach(p => { if (p.total > targetH) stats.overCount++; });
let chartData = [];
if (!personF) {
chartData = teamList.map(p => ({ label: p.name, sub: p.rank, normal: p.normal, extra: p.extra, holiday: p.holiday, total: p.total }));
} else if (pMap[personF]) {
const sel = pMap[personF];
chartData = Object.entries(sel.projects).map(([pn, d]) => ({ label: pn, sub: '프로젝트', normal: d.normal, extra: d.extra, holiday: d.holiday, total: d.total })).sort((a,b) => b.total - a.total);
}
return { stats, matrix: mMap, chartData };
}
function renderKPIs(s) {
const container = document.getElementById('kpi-container');
const cards = [
{ title: "총 투입 시간", val: formatNum(s.totalMH) + 'h', icon: "clock", color: "blue", sub: "Team Total" },
{ title: "참여 실무자", val: s.personCount + '명', icon: "users", color: "emerald", sub: "Active Members" },
{ title: "총 연장/휴일", val: formatNum(s.extraHolMH) + 'h', icon: "trending-up", color: "amber", sub: "Overtime & Weekend" },
{ title: "과부하 위험", val: s.overCount + '명', icon: "alert-circle", color: "red", sub: "Overlimit", isAlert: s.overCount > 0 }
];
container.innerHTML = cards.map(c => `
<div class="bg-white p-7 rounded-[2rem] shadow-xl border-l-[12px] border-${c.color}-500 transition-all hover:-translate-y-1 ${c.isAlert ? 'ring-4 ring-red-500/10' : ''}">
<div class="flex justify-between items-start mb-4">
<div class="p-3 bg-${c.color}-50 rounded-2xl text-${c.color}-600 border border-${c.color}-100 shadow-sm"><i data-lucide="${c.icon}"></i></div>
<span class="text-[10px] font-black text-slate-300 uppercase tracking-widest">${c.sub}</span>
</div>
<div class="text-3xl font-black text-slate-800 mb-2 tracking-tighter leading-none">${c.val}</div>
<div class="text-[11px] font-black text-slate-400 uppercase tracking-[0.2em]">${c.title}</div>
</div>
`).join('');
}
function renderMatrix(m) {
const visualContainer = document.getElementById('matrix-visual-container');
const footerVisual = document.getElementById('matrix-footer-visual');
let bizData = [];
let cardsHtml = "";
let grandTotal = 0;
let grandPeople = new Set();
Object.entries(m).forEach(([biz, projs]) => {
let bizTotal = 0;
let bizProjs = [];
Object.entries(projs).forEach(([projName, d]) => {
bizTotal += d.total;
grandTotal += d.total;
d.ps.forEach(p => grandPeople.add(p));
bizProjs.push({ name: projName, total: d.total, peopleCount: d.ps.size, rankDetails: d.rankDetails });
});
bizData.push([biz, bizTotal]);
cardsHtml += `
<div class="bg-white border border-slate-100 rounded-[1.5rem] p-5 shadow-sm hover:shadow-md transition-shadow">
<div class="flex justify-between items-start mb-4">
<h5 class="font-black text-slate-900 text-lg tracking-tight truncate mr-2">${biz}</h5>
<span class="bg-blue-50 text-blue-600 px-3 py-1 rounded-full text-[11px] font-black">${formatNum(bizTotal)}h</span>
</div>
<div class="space-y-4">
${bizProjs.map(p => `
<div class="border-b border-slate-50 last:border-0 pb-3 last:pb-0 group">
<div class="flex items-center justify-between mb-1">
<span class="text-slate-700 font-bold truncate w-2/3 group-hover:text-blue-500 transition-colors">${p.name}</span>
<div class="flex items-center gap-1 shrink-0">
<span class="text-slate-900 font-black">${formatNum(p.total)}h</span>
<span class="text-[10px] text-slate-400 font-bold">(${p.peopleCount}명)</span>
</div>
</div>
<div class="grid grid-cols-2 gap-x-4 gap-y-1 mt-2">
${Object.entries(p.rankDetails).filter(([r, info]) => info.mh > 0).map(([rank, info]) => `
<div class="flex items-center justify-between text-[10px] bg-slate-50 px-2 py-0.5 rounded">
<span class="text-slate-400 font-bold">${rank}</span>
<span class="text-slate-600 font-black">${formatNum(info.mh)}h <small class="text-slate-400">(${info.ps.size})</small></span>
</div>
`).join('')}
</div>
</div>
`).join('')}
</div>
</div>
`;
});
visualContainer.innerHTML = cardsHtml;
// 푸터 디자인을 컴팩트한 상단 요약 배지 형태로 변경
footerVisual.innerHTML = `
<div class="flex items-center gap-4">
<div class="text-right">
<span class="text-[10px] font-black text-slate-400 uppercase tracking-widest block">Total Volume</span>
<div class="text-xl font-black text-blue-600 leading-none">${formatNum(grandTotal)}h</div>
</div>
<div class="w-px h-8 bg-slate-200"></div>
<div class="text-right">
<span class="text-[10px] font-black text-slate-400 uppercase tracking-widest block">Involved</span>
<div class="text-xl font-black text-slate-900 leading-none">${grandPeople.size}명</div>
</div>
</div>
`;
const dt = new google.visualization.DataTable();
dt.addColumn('string', 'Business Unit');
dt.addColumn('number', 'Total MH');
dt.addRows(bizData);
const options = {
pieHole: 0.5,
colors: ['#3b82f6', '#10b981', '#f59e0b', '#f43f5e', '#8b5cf6', '#64748b'],
legend: { position: 'bottom', textStyle: { fontSize: 11, fontName: 'Noto Sans KR', bold: true, color: '#64748b' } },
chartArea: { width: '90%', height: '80%' },
pieSliceText: 'none',
backgroundColor: 'transparent',
tooltip: { isHtml: true, textStyle: { fontName: 'Noto Sans KR' } }
};
const chart = new google.visualization.PieChart(document.getElementById('biz_pie_chart'));
chart.draw(dt, options);
lucide.createIcons();
}
function drawChart(data, targetH, personF) {
const container = document.getElementById('chart_div');
const titleEl = document.getElementById('chart-title');
const descEl = document.getElementById('chart-desc');
const badgeEl = document.getElementById('target-badge');
const valueEl = document.getElementById('target-value');
const iconEl = document.getElementById('chart-icon');
let displayData = [...data];
if (personF) {
titleEl.innerText = `${personF} 상세 투입 현황`;
descEl.innerText = ""; // 문구 제거
badgeEl.classList.add('hidden');
iconEl.setAttribute('data-lucide', 'user');
// 전체 합계 데이터 추가
if (displayData.length > 0) {
const sum = displayData.reduce((acc, curr) => ({
normal: acc.normal + curr.normal,
extra: acc.extra + curr.extra,
holiday: acc.holiday + curr.holiday,
total: acc.total + curr.total
}), { normal: 0, extra: 0, holiday: 0, total: 0 });
displayData.push({ label: '전체 합계 (Total)', sub: '종합', ...sum });
}
} else {
titleEl.innerText = `개인별 현황`;
descEl.innerText = `(평일 8시간 근무 기준)`;
badgeEl.classList.remove('hidden');
valueEl.innerText = formatNum(targetH) + 'h';
iconEl.setAttribute('data-lucide', 'bar-chart-2');
}
const dt = new google.visualization.DataTable();
dt.addColumn('string', '항목');
dt.addColumn('number', '정규');
dt.addColumn({type: 'string', role: 'tooltip', p:{html:true}});
dt.addColumn('number', '연장');
dt.addColumn({type: 'string', role: 'tooltip', p:{html:true}});
dt.addColumn('number', '휴일');
dt.addColumn({type: 'string', role: 'tooltip', p:{html:true}});
dt.addColumn('number', '전체');
dt.addColumn({type: 'string', role: 'annotation'});
displayData.forEach(d => {
const tooltipHtml = `
<div style="font-family:'Noto Sans KR'; padding:12px; min-width:180px;">
<div style="border-bottom:1px solid #eee; padding-bottom:8px; margin-bottom:8px;">
<b style="font-size:16px; color:#1e293b;">${d.label}</b>
<span style="font-size:11px; color:#94a3b8; margin-left:5px;">${d.sub}</span>
</div>
<div style="display:flex; justify-content:space-between; margin-bottom:5px;">
<span style="color:#64748b;">정규 근무:</span> <b style="color:#1e293b;">${formatNum(d.normal)}h</b>
</div>
<div style="display:flex; justify-content:space-between; margin-bottom:5px;">
<span style="color:#f59e0b;">연장 근무:</span> <b style="color:#d97706;">${formatNum(d.extra)}h</b>
</div>
<div style="display:flex; justify-content:space-between; margin-bottom:8px;">
<span style="color:#ef4444;">휴일 근무:</span> <b style="color:#dc2626;">${formatNum(d.holiday)}h</b>
</div>
<div style="border-top:1px solid #eee; padding-top:8px; display:flex; justify-content:space-between; font-weight:900; font-size:18px; color:#2563eb;">
<span>합계:</span> <span>${formatNum(d.total)}h</span>
</div>
</div>
`;
dt.addRow([
d.label,
d.normal, tooltipHtml,
d.extra, tooltipHtml,
d.holiday, tooltipHtml,
0.0001, (d.total > targetH && !personF ? "🚨 " : "") + formatNum(d.total) + "h"
]);
});
const chartHeight = Math.max(500, displayData.length * 55);
container.style.height = chartHeight + 'px';
const options = {
isStacked: true,
tooltip: { isHtml: true },
colors: ['#3b82f6', '#f59e0b', '#f43f5e', 'transparent'],
chartArea: { width: '70%', height: '90%', left: 200 },
hAxis: { gridlines: { color: '#f1f5f9' }, viewWindow: { min: 0 }, format: '#,##0.00' },
vAxis: { textStyle: { fontSize: 13, bold: true, color: '#475569' } },
annotations: { alwaysOutside: true, textStyle: { fontSize: 13, bold: true, color: '#1e293b' } },
legend: { position: 'top', alignment: 'center' },
series: { 3: { visibleInLegend: false, tooltip: false } },
backgroundColor: 'transparent'
};
const chart = new google.visualization.BarChart(container);
if (!personF) {
google.visualization.events.addListener(chart, 'ready', () => {
const cli = chart.getChartLayoutInterface();
const xPos = cli.getXLocation(targetH);
const svg = container.querySelector('svg');
if(svg && !isNaN(xPos)) {
const area = cli.getChartAreaBoundingBox();
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
line.setAttribute('x1', xPos); line.setAttribute('y1', area.top);
line.setAttribute('x2', xPos); line.setAttribute('y2', area.top + area.height);
line.setAttribute('stroke', '#ef4444'); line.setAttribute('stroke-width', '3'); line.setAttribute('stroke-dasharray', '8,8');
svg.appendChild(line);
}
});
}
chart.draw(dt, options);
}
window.onload = function() {
lucide.createIcons();
}
</script>
</body>
</html>