////////////////////////////////////////////////////////////////////////////////////////
// KPI Dashboard
////////////////////////////////////////////////////////////////////////////////////////
let selectedPdServiceId; // 제품(서비스) 아이디
let selectedPdService; // 제품(서비스) 이름
let selectedVersionIds = []; // 선택된 버전 아이디 배열
let selectedVersions = []; // 선택된 버전 이름 배열
let selectedAssigneeId; // 선택된 작업자 계정 ID
let selectedAssigneeName; // 선택된 작업자 이름
let selectedStartDate; // 시작 날짜
let selectedEndDate; // 종료 날짜
function execDocReady() {
var pluginGroups = [
[
"../reference/light-blue/lib/bootstrap-datepicker.js",
"../reference/lightblue4/docs/lib/widgster/widgster.js",
"../reference/lightblue4/docs/lib/slimScroll/jquery.slimscroll.min.js",
"../reference/jquery-plugins/datetimepicker-2.5.20/build/jquery.datetimepicker.min.css",
"../reference/jquery-plugins/datetimepicker-2.5.20/build/jquery.datetimepicker.full.min.js"
],
[
"https://cdn.jsdelivr.net/npm/chart.js@3.9.1/dist/chart.min.js"
],
[
"../reference/jquery-plugins/select2-4.0.2/dist/css/select2_lightblue4.css",
"../reference/jquery-plugins/lou-multi-select-0.9.12/css/multiselect-lightblue4.css",
"../reference/jquery-plugins/multiple-select-1.5.2/dist/multiple-select-bluelight.css",
"../reference/jquery-plugins/select2-4.0.2/dist/js/select2.min.js",
"../reference/jquery-plugins/lou-multi-select-0.9.12/js/jquery.quicksearch.js",
"../reference/jquery-plugins/lou-multi-select-0.9.12/js/jquery.multi-select.js",
"../reference/jquery-plugins/multiple-select-1.5.2/dist/multiple-select.min.js"
]
// 추가적인 플러그인 그룹들을 이곳에 추가하면 됩니다.
];
loadPluginGroupsParallelAndSequential(pluginGroups)
.then(function() {
console.log('모든 플러그인 로드 완료');
// 사이드 메뉴 처리
$('.widget').widgster();
setSideMenu("sidebar_menu_requirement", "sidebar_menu_requirement_kpi");
// 스크립트 실행 로직을 이곳에 추가합니다.
// 1. 제품(서비스) 셀렉트 박스 이니시에이터
makePdServiceSelectBox();
// 2. 버전 멀티 셀렉트 박스 이니시에이터
makeVersionMultiSelectBox();
// 3. 작업자 셀렉트 박스 이니시에이터
makeAssigneeSelectBox();
// 4. 날짜 범위 선택기 이니시에이터
makeDateRangePicker();
// 차트는 작업자 선택 후에 렌더링됨
console.log('KPI 대시보드 초기화 완료');
})
.catch(function(e) {
console.error('플러그인 로드 중 오류 발생');
console.error(e);
});
}
////////////////////////////////////////////////////////////////////////////////////////
// Utility Functions
////////////////////////////////////////////////////////////////////////////////////////
/**
* 날짜 포맷 함수 (yyyy-MM-dd)
*/
function formatDate(date) {
var year = date.getFullYear();
var month = String(date.getMonth() + 1).padStart(2, '0');
var day = String(date.getDate()).padStart(2, '0');
return year + '-' + month + '-' + day;
}
////////////////////////////////////////////////////////////////////////////////////////
// 제품 서비스 셀렉트 박스
////////////////////////////////////////////////////////////////////////////////////////
function makePdServiceSelectBox() {
// 제품 서비스 셀렉트 박스 이니시에이터 (작업자 셀렉트 제외)
$(".chzn-select").not("#selected_assignee").each(function() {
$(this).select2($(this).data());
});
// 작업자 셀렉트 박스는 별도로 초기화
$("#selected_assignee").select2({
placeholder: "Select Assignee",
allowClear: true,
width: '100%'
});
console.log("작업자 셀렉트 박스 초기 초기화 완료");
// 제품 서비스 셀렉트 박스 데이터 바인딩
$.ajax({
url: "/auth-user/api/arms/pdServicePure/getPdServiceMonitor.do",
type: "GET",
contentType: "application/json;charset=UTF-8",
dataType: "json",
progress: true,
statusCode: {
200: function(data) {
for (var k in data.response) {
var obj = data.response[k];
var newOption = new Option(obj.c_title, obj.c_id, false, false);
$("#selected_pdService").append(newOption).trigger("change");
}
jSuccess("제품(서비스) 조회가 완료 되었습니다.");
}
},
error: function (e) {
jError("제품(서비스) 조회 중 에러가 발생했습니다.");
}
});
$("#selected_pdService").on("select2:open", function() {
makeSlimScroll(".select2-results__options");
});
// --- select2 ( 제품(서비스) 검색 및 선택 ) 이벤트 --- //
$("#selected_pdService").on("select2:select", function(e) {
selectedPdServiceId = $("#selected_pdService").val();
selectedPdService = $("#selected_pdService").select2("data")[0].text;
console.log("선택된 제품(서비스):", selectedPdService, "ID:", selectedPdServiceId);
// 제품(서비스) 선택 시 자동으로 버전 데이터 바인딩
bind_VersionData_By_PdService();
});
}
////////////////////////////////////////////////////////////////////////////////////////
// 버전 멀티 셀렉트 박스
////////////////////////////////////////////////////////////////////////////////////////
function makeVersionMultiSelectBox() {
$(".multiple-select").multipleSelect({
filter: true,
onClose: function () {
selectedVersions = [];
selectedVersionIds = [];
$("#multiversion option:selected").map(function (a, item) {
selectedVersions.push(item.innerText);
selectedVersionIds.push(item.value);
});
console.log("선택된 버전:", selectedVersions);
console.log("선택된 버전 IDs:", selectedVersionIds);
// 버전 선택 완료 시 작업자 목록 갱신
if (selectedVersionIds.length > 0) {
fetchAssignees();
} else {
jError("버전을 선택해주세요.");
}
}
});
}
function bind_VersionData_By_PdService() {
$(".multiple-select option").remove();
selectedVersionIds = [];
selectedVersions = [];
$.ajax({
url: "/auth-user/api/arms/pdService/getVersionList?c_id=" + selectedPdServiceId,
type: "GET",
dataType: "json",
progress: true,
statusCode: {
200: function (data) {
for (var k in data.response) {
var obj = data.response[k];
selectedVersions.push(obj.c_title);
selectedVersionIds.push(obj.c_id);
var newOption = new Option(obj.c_title, obj.c_id, true, false);
$(".multiple-select").append(newOption);
}
console.log("버전 데이터 바인딩 완료");
console.log("selectedPdServiceId:", selectedPdServiceId);
console.log("selectedVersionIds:", selectedVersionIds);
$(".multiple-select").multipleSelect("refresh");
// 제품/버전 선택 후 작업자 목록 갱신
fetchAssignees();
}
},
error: function (e) {
jError("버전 조회 중 에러가 발생했습니다.");
}
});
}
////////////////////////////////////////////////////////////////////////////////////////
// 날짜 범위 선택기
////////////////////////////////////////////////////////////////////////////////////////
function makeDateRangePicker() {
// 기본값: 최근 30일
var endDate = new Date();
var startDate = new Date();
startDate.setDate(startDate.getDate() - 30);
selectedStartDate = formatDate(startDate);
selectedEndDate = formatDate(endDate);
$("#dateRange").val(selectedStartDate + " ~ " + selectedEndDate);
$("#dateRange").datetimepicker({
timepicker: false,
format: 'Y-m-d',
onShow: function(ct) {
this.setOptions({
maxDate: new Date()
});
},
onSelectDate: function(ct, $input) {
if (!$input.data('startDate')) {
$input.data('startDate', ct);
$input.val(formatDate(ct) + " ~ ");
} else {
var start = $input.data('startDate');
var end = ct;
if (start > end) {
var temp = start;
start = end;
end = temp;
}
selectedStartDate = formatDate(start);
selectedEndDate = formatDate(end);
$input.val(selectedStartDate + " ~ " + selectedEndDate);
$input.data('startDate', null);
console.log("선택된 기간:", selectedStartDate, "~", selectedEndDate);
}
}
});
}
////////////////////////////////////////////////////////////////////////////////////////
// 작업자 셀렉트 박스
////////////////////////////////////////////////////////////////////////////////////////
function makeAssigneeSelectBox() {
// 작업자 셀렉트 박스는 이미 chzn-select로 초기화되었으므로
// 이벤트 핸들러만 등록
bindAssigneeEvents();
}
/**
* 작업자 셀렉트 박스 이벤트 바인딩
*/
function bindAssigneeEvents() {
$("#selected_assignee").on("select2:open", function() {
makeSlimScroll(".select2-results__options");
});
// 작업자 선택 이벤트 - accountId 사용
$("#selected_assignee").on("select2:select", function(e) {
selectedAssigneeId = $("#selected_assignee").val();
selectedAssigneeName = $("#selected_assignee").select2("data")[0].text;
if(selectedAssigneeId !== null && selectedAssigneeName !== null){
loadAllKpiData();
}
});
// 작업자 선택 해제 이벤트
$("#selected_assignee").on("select2:clear", function(e) {
selectedAssigneeId = null;
selectedAssigneeName = null;
// 작업자 선택 해제 시 차트 초기화
clearAllCharts();
});
}
/**
* 작업자 목록 가져오기
*/
function fetchAssignees() {
var urlBuilder = new UrlBuilder()
.setBaseUrl('/auth-user/api/arms/report/full-data/assignee-list');
if (selectedPdServiceId) {
urlBuilder.addQueryParam('pdServiceId', selectedPdServiceId);
}
if (selectedVersionIds && selectedVersionIds.length > 0) {
urlBuilder.addQueryParam('pdServiceVersionIds', selectedVersionIds.join(','));
}
$.ajax({
url: urlBuilder.build(),
type: "GET",
dataType: "json",
progress: true,
success: function(data) {
// 기존 옵션 제거
$("#selected_assignee").empty();
// 빈 옵션 추가 (placeholder용)
$("#selected_assignee").append(new Option("", "", false, false));
// 작업자 옵션 추가
if (data.response && data.response.length > 0) {
for (var k in data.response) {
var obj = data.response[k];
var newOption = new Option(obj.displayName, obj.accountId, false, false);
$("#selected_assignee").append(newOption);
}
jSuccess("작업자 목록 조회가 완료되었습니다. (" + data.response.length + "명)");
} else {
jWarning("선택한 제품/버전에 대한 작업자가 없습니다.");
}
// Select2에 변경 알림
$("#selected_assignee").trigger("change");
},
error: function (xhr, status, error) {
jError("작업자 목록 조회 중 에러가 발생했습니다.");
}
});
}
////////////////////////////////////////////////////////////////////////////////////////
// KPI Data Loading
////////////////////////////////////////////////////////////////////////////////////////
/**
* 필터 유효성 검사
*/
function validateFilters() {
if (!selectedPdServiceId) {
console.log("제품(서비스)가 선택되지 않았습니다.");
return false;
}
if (!selectedVersionIds || selectedVersionIds.length === 0) {
console.log("버전이 선택되지 않았습니다.");
return false;
}
if (!selectedStartDate || !selectedEndDate) {
console.log("조회 기간이 설정되지 않았습니다.");
return false;
}
return true;
}
/**
* 모든 KPI 데이터 로딩
* 작업자 선택 시 호출됨
*/
function loadAllKpiData() {
if (!validateFilters()) {
return;
}
// 작업자가 선택되지 않은 경우 데이터 로드하지 않음
if (!selectedAssigneeId) {
console.log("작업자가 선택되지 않아 데이터를 로드하지 않습니다.");
return;
}
// Mock 데이터로 차트 렌더링 (백엔드 작업 완료 후 실제 API 호출로 교체 예정)
loadMockDataAndRenderCharts();
// TODO: 백엔드 작업 완료 후 아래 주석 해제
// loadScheduleComplianceData();
}
////////////////////////////////////////////////////////////////////////////////////////
// INS-QL-4231: 일정 준수율 분석
////////////////////////////////////////////////////////////////////////////////////////
// 차트 인스턴스 저장
var reqDelayRateChartInstance = null;
var reqDelayRateGaugeInstance = null;
/**
* 일정 준수율 데이터 로딩
*/
function loadScheduleComplianceData() {
console.log("loadScheduleComplianceData 호출");
if (!selectedAssigneeId) {
return;
}
var requestData = {
pdServiceLink: parseInt(selectedPdServiceId),
pdServiceVersionLinks: selectedVersionIds.map(function(id) { return parseInt(id); }),
assigneeId: selectedAssigneeId,
startDate: selectedStartDate,
endDate: selectedEndDate
};
$.ajax({
url: '/auth-user/api/arms/engine-fire/insight/schedule-compliance',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify(requestData),
success: function(data) {
if (data.success && data.response) {
renderScheduleComplianceData(data.response);
}
},
error: function(xhr, status, error) {
jError("일정 준수율 데이터 조회 중 에러가 발생했습니다.");
}
});
}
/**
* 일정 준수율 데이터 렌더링
*/
function renderScheduleComplianceData(data) {
console.log("call renderScheduleComplianceData");
// 유형별 완료 지표 TypeBar 차트 렌더링
var completionData = {};
var colorMap = {};
if (data.issueCompletionRate) {
completionData['이슈 완료율'] = data.issueCompletionRate.completionRate;
colorMap['이슈 완료율'] = '#3b82f6';
}
if (data.requirementCompletionRate) {
completionData['요구사항 완료율'] = data.requirementCompletionRate.completionRate;
colorMap['요구사항 완료율'] = '#0ea5e9';
}
if (data.requirementComplianceRate) {
completionData['일정 준수율'] = data.requirementComplianceRate.complianceRate;
colorMap['일정 준수율'] = '#f59e0b';
}
// TypeBar 스타일로 유형별 완료 지표 차트 렌더링
if (Object.keys(completionData).length > 0) {
renderTypeBarChart('completionIndicatorChart', 'completionIndicatorLegend', completionData, colorMap);
}
// 평균 지연 일수 (프로필영역)
if (data.requirementAverageDelayDays !== undefined) {
$("#avgDelayDays").text(data.requirementAverageDelayDays);
$("#avgDelayDaysProfile").text(data.requirementAverageDelayDays);
}
// 요구사항별 지연율 게이지 차트
if (data.requirementComplianceRate) {
var rcrp = data.requirementComplianceRate;
var delayRate = 100 - rcrp.complianceRate;
renderReqDelayRateGauge(delayRate, rcrp.onTimeCompletedCount, rcrp.delayedCount);
}
}
/**
* 요구사항별 지연율 게이지 차트 렌더링
*/
function renderReqDelayRateGauge(delayRate, onTimeCount, delayedCount) {
console.log("renderReqDelayRateGauge - delayRate:", delayRate);
var canvas = document.getElementById('reqDelayRateGauge');
if (!canvas) return;
if (reqDelayRateGaugeInstance) {
reqDelayRateGaugeInstance.destroy();
}
var color = '#ffce56'; // Yellow/Orange for delay rate
var bgColor = '#2c2c2c';
reqDelayRateGaugeInstance = new Chart(canvas.getContext('2d'), {
type: 'doughnut',
data: {
datasets: [{
data: [delayRate, 100 - delayRate],
backgroundColor: [color, bgColor],
borderWidth: 0
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
rotation: -90, // 위쪽 반원
circumference: 180, // 반원 (180도)
cutout: '65%',
plugins: {
legend: { display: false },
tooltip: { enabled: false },
gaugeNeedle: {
needleValue: delayRate,
needleColor: '#f8f8f8'
}
}
},
plugins: [gaugeNeedlePlugin] // 화살표만 표시
});
$("#reqDelayRateText").text(delayRate.toFixed(1) + "%");
}
////////////////////////////////////////////////////////////////////////////////////////
// 샘플데이터 호출 & 차트 데이터
////////////////////////////////////////////////////////////////////////////////////////
// 차트 인스턴스 저장
var chartInstances = {
requirementStatusChart: null,
issueStatusChart: null,
requirementProcessingChart: null,
issueProcessingChart: null,
requirementProgressGauge: null,
reqDelayRateGauge: null,
completionIndicatorChart: null
};
/**
* 모든 차트 및 프로필 초기화 (작업자 선택 해제 시 호출)
*/
function clearAllCharts() {
console.log("차트 및 프로필 초기화 중...");
// chartInstances에 저장된 모든 차트 destroy
Object.keys(chartInstances).forEach(function(key) {
if (chartInstances[key]) {
chartInstances[key].destroy();
chartInstances[key] = null;
}
});
// reqDelayRateGaugeInstance도 초기화
if (reqDelayRateGaugeInstance) {
reqDelayRateGaugeInstance.destroy();
reqDelayRateGaugeInstance = null;
}
// 이슈 유형 컨테이너 초기화
var issueTypeContainer = document.getElementById('issueTypeContainer');
if (issueTypeContainer) {
issueTypeContainer.innerHTML = '';
}
// 작업자 프로필 정보 초기화
clearAssigneeProfile();
console.log("차트 및 프로필 초기화 완료");
}
/**
* 작업자 프로필 정보 초기화
*/
function clearAssigneeProfile() {
$("#assigneeName").text("-");
$("#assigneeEmail").text("-");
$("#assigneeRole").find("span").text("-");
$("#avgDelayDaysProfile").text("-");
// 프로필 이미지를 기본 아이콘으로 복원
$("#assigneeProfileImage").html('');
}
/**
* 작업자 프로필 정보 렌더링
*/
function renderAssigneeProfile(profileData) {
if (!profileData) {
clearAssigneeProfile();
return;
}
// 선택된 작업자 이름 사용 (Mock 데이터보다 실제 선택된 값 우선)
var displayName = selectedAssigneeName || profileData.name || "-";
$("#assigneeName").text(displayName);
$("#assigneeEmail").text(profileData.email || "-");
$("#assigneeRole").find("span").text(profileData.role || "-");
// 프로필 이미지가 있으면 표시, 없으면 이름 이니셜 표시
if (profileData.profileImage) {
$("#assigneeProfileImage").html('');
} else {
// 이름의 첫 글자를 이니셜로 표시
var initial = displayName.charAt(0).toUpperCase();
$("#assigneeProfileImage").html('' + initial + '');
}
}
/**
* Mock 데이터 로드 및 차트 렌더링
*/
function loadMockDataAndRenderCharts() {
console.log(" loadMockDataAndRenderCharts Mock 데이터 로드 중...");
$.ajax({
url: '/arms/js/data/kpiMockData.json',
type: 'GET',
dataType: 'json',
success: function(mockData) {
console.log("Mock 데이터 로드 완료:", mockData);
// 작업자 프로필 정보 렌더링
if (mockData.assigneeProfile && mockData.assigneeProfile.response) {
renderAssigneeProfile(mockData.assigneeProfile.response);
}
// INS-WL-4111: Workload 측정 차트 렌더링
if (mockData.workload && mockData.workload.response) {
renderWorkloadCharts(mockData.workload.response);
}
// INS-PD-4121: 이슈 처리 속도 차트 렌더링
if (mockData.processingSpeed && mockData.processingSpeed.response) {
renderProcessingSpeedCharts(mockData.processingSpeed.response);
}
// INS-PD-4122: 계획 대비 효율 차트 렌더링
if (mockData.efficiency && mockData.efficiency.response) {
renderEfficiencyChart(mockData.efficiency.response);
}
// INS-QL-4231: 일정 준수율 분석 렌더링
if (mockData.scheduleCompliance && mockData.scheduleCompliance.response) {
renderScheduleComplianceData(mockData.scheduleCompliance.response);
}
jSuccess("Mock 데이터로 차트가 초기화되었습니다.");
},
error: function(xhr, status, error) {
console.error("Mock 데이터 로드 실패:", error);
jError("Mock 데이터 로드 실패: " + error);
}
});
}
/**
* INS-WL-4111: Workload 측정 차트 렌더링
*/
function renderWorkloadCharts(data) {
console.log("call renderWorkloadCharts");
// 요구사항 상태별 분포 (도넛 차트)
if (data.requirementsByStatus) {
renderDoughnutChart('requirementStatusChart', data.requirementsByStatus, {
'open': { label: 'Open', color: '#3b82f6' },
'in_progress': { label: 'In Progress', color: '#f59e0b' },
'resolved': { label: 'Resolved', color: '#10b981' },
'closed': { label: 'Closed', color: '#64748b' }
}, 'total');
}
// 이슈 상태별 분포 (도넛 차트)
if (data.issuesByStatus) {
renderDoughnutChart('issueStatusChart', data.issuesByStatus, {
'open': { label: 'Open', color: '#3b82f6' },
'in_progress': { label: 'In Progress', color: '#f59e0b' },
'resolved': { label: 'Resolved', color: '#10b981' },
'closed': { label: 'Closed', color: '#64748b' }
}, 'total');
}
// 이슈 타입별 분포 (HTML 기반 수평 막대)
if (data.issuesByType) {
renderIssueTypeBar('issueTypeContainer', data.issuesByType, {
'Bug': '#ef4444',
'Story': '#3b82f6',
'Task': '#10b981',
'Sub-task': '#a855f7'
});
}
}
/**
* INS-PD-4121: 이슈 처리 속도 차트 렌더링
*/
function renderProcessingSpeedCharts(data) {
console.log("call renderProcessingSpeedCharts");
if (data.processingTimeByPriority) {
var priorityData = data.processingTimeByPriority;
var labels = Object.keys(priorityData);
var reqData = labels.map(function(key) { return priorityData[key].requirement; });
var issueData = labels.map(function(key) { return priorityData[key].issue; });
// 요구사항 평균 처리 속도
renderProcessingChart('requirementProcessingChart', labels, reqData, '요구사항 처리 일수');
// 이슈 평균 처리 속도
renderProcessingChart('issueProcessingChart', labels, issueData, '이슈 처리 일수');
}
}
/**
* INS-PD-4122: 계획 대비 효율 차트 렌더링
*/
function renderEfficiencyChart(data) {
console.log("call renderEfficiencyChart");
// 전체 효율에서 실제 진척도 계산 (첫 번째 요구사항 기준 또는 평균)
var actualProgress = 0;
if (data.requirementList && data.requirementList.length > 0) {
var totalActual = 0;
data.requirementList.forEach(function(req) {
totalActual += req.actualProgress;
});
actualProgress = totalActual / data.requirementList.length;
}
renderProgressGauge('requirementProgressGauge', actualProgress);
}
/**
* 도넛 차트 렌더링
* 중앙에 표시할 라벨추가
*/
function renderDoughnutChart(canvasId, data, colorMap, centerLabel) {
console.log("call renderDoughnutChart");
var canvas = document.getElementById(canvasId);
if (!canvas) return;
if (chartInstances[canvasId]) {
chartInstances[canvasId].destroy();
}
var labels = [];
var values = [];
var colors = [];
Object.keys(data).forEach(function(key) {
if (colorMap[key]) {
labels.push(colorMap[key].label);
colors.push(colorMap[key].color);
} else {
labels.push(key);
colors.push('#999');
}
values.push(data[key]);
});
// 총 개수 계산
var total = values.reduce(function(sum, val) { return sum + val; }, 0);
chartInstances[canvasId] = new Chart(canvas.getContext('2d'), {
type: 'doughnut',
data: {
labels: labels,
datasets: [{
data: values,
backgroundColor: colors,
borderWidth: 0,
hoverOffset: 4
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
cutout: '78%',
plugins: {
legend: {
display: true,
position: 'right',
labels: {
color: '#ccc',
font: { size: 11, weight: '500' },
usePointStyle: true,
pointStyle: 'circle',
boxWidth: 6,
boxHeight: 6,
padding: 15
}
},
tooltip: {
backgroundColor: 'rgba(51, 51, 51, 0.9)',
titleColor: '#f8f8f8',
bodyColor: '#ccc',
borderColor: 'rgba(255, 255, 255, 0.1)',
borderWidth: 1,
padding: 8,
displayColors: true,
callbacks: {
label: function(context) {
return context.label + ': ' + context.parsed;
}
}
}
}
},
plugins: [{
id: 'centerText',
beforeDraw: function(chart) {
var ctx = chart.ctx;
var chartArea = chart.chartArea;
var centerX = (chartArea.left + chartArea.right) / 2;
var centerY = (chartArea.top + chartArea.bottom) / 2;
ctx.save();
// 총 개수 (큰 글씨)
ctx.fillStyle = '#f8f8f8';
ctx.font = 'bold 24px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(total, centerX, centerY - 8);
// 라벨 (작은 글씨)
if (centerLabel) {
ctx.fillStyle = '#999';
ctx.font = '13px sans-serif';
ctx.fillText(centerLabel, centerX, centerY + 14);
}
ctx.restore();
}
}]
});
}
/**
* 이슈 유형 바 렌더링 (HTML/CSS 기반) -> 글로스효과 Chart.js로 표현이 어려움
*/
function renderIssueTypeBar(containerId, data, colorMap) {
console.log("call renderIssueTypeBar");
var container = document.getElementById(containerId);
if (!container) return;
var labels = Object.keys(data);
var values = Object.values(data);
var max = Math.max.apply(null, values);
var html = labels.map(function(label, index) {
var count = values[index];
var color = colorMap[label] || '#999';
var widthPercent = (count / max) * 100;
return `