// ===== 기본 팔레트/유틸은 이전 그대로 사용 ===== var defaultColorSet = [ "rgba(255,255,51,0.71)","rgba(151,78,163,0.73)","rgba(77,175,74,0.65)", "rgba(255,127,0,0.7)","rgba(166,86,40,0.7)","rgba(44,145,157,0.9)", "rgba(227,26,27,0.66)","rgba(55,125,184,0.62)","rgba(142,223,138,0.9)", "rgba(156,148,255,0.7)","rgba(58,186,161,0.9)" ]; function stableFallbackColor(status, palette){ let h=0,s=String(status||''); for(let i=0;i{const k=String(s); if(k && !map[k]){ map[k]=palette[cursor%palette.length]; cursor++; }}); g[key]=map; g[curKey]=cursor; return map; } // ===== 메인 차트 ===== function drawCircularPacking(target, psServiceName, rawData) { var chartDom = document.getElementById(target); var myChart = echarts.init(chartDom); var option; let reqCount = 0; let statusCounts = {}; let statusDataArr = []; if (rawData) run(rawData); function run(rawData) { const dataWrap = prepareData(rawData); const armsStatusOrder = []; const uniqueArmsMap = new Map(); // ARMS ID -> status 매핑 (중복 제거용) dataWrap.seriesData.forEach(el => { if (el.depth === 2) { // "제품명.버전명.ARMS:123" 형태에서 "ARMS:123" 부분만 추출 const armsKey = el.id.split('.').pop(); // 마지막 부분만 (ARMS:123) // 같은 ARMS가 여러 버전에 속해도 한 번만 카운트 if (!uniqueArmsMap.has(armsKey)) { uniqueArmsMap.set(armsKey, el.status); // 처음 등장한 상태 저장 } } }); reqCount = uniqueArmsMap.size; // 고유 ARMS 개수만 카운트 // 고유 ARMS의 상태별 카운트 const seenStatuses = new Set(); uniqueArmsMap.forEach((status) => { statusCounts[status] = (statusCounts[status] || 0) + 1; if (!seenStatuses.has(status)) { armsStatusOrder.push(status); seenStatuses.add(status); } }); statusDataArr = Object.entries(statusCounts).map(([name, value]) => ({ name, value })); window.__ARMS_STATUS_COLORS__ = getOrBuildColorMap('ARMS_STATUS_COLORS', armsStatusOrder, defaultColorSet); initChart(dataWrap.seriesData, dataWrap.maxDepth); } function prepareData(rawData) { const seriesData = []; let maxDepth = 0; // ctx: { productName, versionName, armsTitle, armsStatus } function convert(source, basePath, depth, ctx) { if (source == null) return; if (maxDepth > 5) return; maxDepth = Math.max(depth, maxDepth); const lastDot = basePath.lastIndexOf('.'); const nodeKey = lastDot >= 0 ? basePath.slice(lastDot + 1) : basePath; const nodeVersionName = (depth === 1) ? nodeKey : (ctx?.versionName || null); // 푸시: 노드 자체 메타 + 상위 컨텍스트 seriesData.push({ id: basePath, value: source.$count, status: source.$status, // 이 노드 상태(ARMS: 상태 / ALM: almState 그대로일 수 있음) linked: source.$linked, // ALM: 연결 이슈 수 sub: source.$sub, // ALM: 하위 이슈 수 workers: source.$workers, // ALM: 작업자 배열(있으면) depth: depth, productName: ctx?.productName || psServiceName, versionName: nodeVersionName, armsTitle: ctx?.armsTitle || (source.$title || null), armsStatus: ctx?.armsStatus || null, index: seriesData.length }); // 다음 컨텍스트 갱신 const nextCtx = { ...(ctx||{}) }; if (depth === 0) { // 루트 nextCtx.productName = psServiceName; } else if (depth === 1) { // 버전 nextCtx.versionName = basePath.slice(basePath.lastIndexOf('.') + 1); } else if (depth === 2) { // ARMS nextCtx.armsTitle = source.$title || nextCtx.armsTitle || null; nextCtx.armsStatus = source.$status || nextCtx.armsStatus || null; } for (var key in source) { if (source.hasOwnProperty(key) && !key.match(/^\$/)) { var path = basePath + '.' + key; convert(source[key], path, depth + 1, nextCtx); } } } convert(rawData, psServiceName, 0, { productName: psServiceName }); return { seriesData, maxDepth }; } function initChart(seriesData, maxDepth) { var displayRoot = stratify(); function stratify() { return d3 .stratify() .parentId(function (d) { const i = d.id.lastIndexOf('.'); return i >= 0 ? d.id.substring(0, i) : null; }) (seriesData) .sum(function (d) { return d.value || 0; }) .sort(function (a, b) { return b.value - a.value; }); } function overallLayout(params, api) { var context = params.context; d3.pack().size([api.getWidth() - 2, api.getHeight() - 2]).padding(3)(displayRoot); context.nodes = {}; displayRoot.descendants().forEach(function (node) { context.nodes[node.id] = node; }); } function renderItem(params, api) { var context = params.context; if (!context.layout) { context.layout = true; overallLayout(params, api); } var nodePath = api.value('id'); var node = context.nodes[nodePath]; if (!node) return; var isLeaf = !node.children || !node.children.length; var nodeName = isLeaf ? nodePath.slice(nodePath.lastIndexOf('.') + 1).split(/(?=[A-Z][^A-Z])/g).join('\n') : ''; var z2 = api.value('depth') * 2; return { type: 'circle', shape: { cx: node.x, cy: node.y, r: node.r }, z2: z2, textContent: { type: 'text', style: { text: nodeName, fontFamily: 'Arial', width: node.r * 1.3, overflow: 'truncate', fontSize: node.r / 3 }, emphasis: { style: { overflow: null, fontSize: Math.max(node.r / 3, 12) } } }, textConfig: { position: 'inside' }, style: { fill: api.visual('color') }, emphasis: { style: { fontFamily: 'Arial', fontSize: 12, shadowBlur: 20, shadowOffsetX: 3, shadowOffsetY: 5, shadowColor: 'rgba(0,0,0,0.3)' } } }; } option = { dataset: { source: seriesData }, tooltip: { confine: true }, visualMap: [{ show: false, min: 0, max: maxDepth, dimension: 'depth', inRange: {} }], hoverLayerThreshold: Infinity, series: { type: 'custom', renderItem: renderItem, progressive: 0, coordinateSystem: 'none', itemStyle: { color: function(params) { const d = params.data || {}; const cmap = window.__ARMS_STATUS_COLORS__ || {}; let base; if (d.depth === 2) { // ARMS 노드: 고유 색 base = cmap[d.status] || stableFallbackColor(d.status, defaultColorSet); } else if (d.depth >= 3) { // ALM 노드: 부모 ARMS 색 const p = d.armsStatus || d.status; base = cmap[p] || stableFallbackColor(p, defaultColorSet); if (d.depth === 3) { base = adjustAlpha(base, 0.9); // 약간 진하게 } else if (d.depth === 4) { base = adjustAlpha(base, 0.6); // 더 연하게 } } else { base = "rgba(55,125,184,0.20)"; } return base; } }, tooltip: { confine: true, formatter: function (params) { const d = params.data || {}; const depth = d.depth; // 공통 메타 (prepareData에서 이미 싣고 있음) const product = d.productName || '-'; const version = d.versionName || '-'; const armsTitle = d.armsTitle || '-'; const armsStat = d.armsStatus || (depth === 2 ? d.status : d.parentStatus) || '-'; const workers = Array.isArray(d.workers) && d.workers.length ? d.workers.join(', ') : '-'; const sub = d.sub ?? 0; const linked = d.linked ?? 0; // 뎁스별 템플릿 if (depth === 0) { // 제품 레벨 return [ `제품 : ${product}` ].join('
'); } if (depth === 1) { // 버전 레벨 return [ `제품 : ${product}`, `버전 : ${version}` ].join('
'); } if (depth === 2) { // ARMS(요구사항) 레벨 return [ `제품 : ${product}`, `버전 : ${version}`, `요구사항 제목 : ${armsTitle}`, `요구사항 상태 : ${armsStat}` ].join('
'); } // depth >= 3 → ALM 레벨 return [ `제품 : ${product}`, `버전 : ${version}`, `요구사항 제목 : ${armsTitle}`, `요구사항 상태 : ${armsStat}`, `작업자 명 : ${workers}`, `하위 이슈 : ${sub}`, `연결 이슈 : ${linked}` ].join('
'); } } }, graphic: [{ type: 'group', left: 20, top: 20, children: [{ type: 'text', z: 100, left: 0, top: 0, style: { text: ['{a| Total }','{a| ' + reqCount + '}'].join('\n'), rich: { a: { fontSize: 13, fontWeight: 'bold', lineHeight: 20, fontFamily: 'Arial', fill: 'white' } } } }] }] }; option && myChart.setOption(option, true); // 하단 레전드(ARMS만) if (target === 'modal_circular') { drawModalChartWithFooter(statusDataArr, reqCount); } else { drawChartWithFooter(statusDataArr, reqCount); } myChart.on('click', { seriesIndex: 0 }, function (params) { drillDown(params.data.id); }); function drillDown(targetNodeId) { displayRoot = stratify(); if (targetNodeId != null) { displayRoot = displayRoot.descendants().find(function (node) { return node.data.id === targetNodeId; }); } displayRoot.parent = null; myChart.setOption({ dataset: { source: seriesData } }); } myChart.getZr().on('click', function (event) { if (!event.target) drillDown(); }); } function adjustAlpha(rgba, factor) { const m = rgba.match(/rgba?\(([^)]+)\)/); if (!m) return rgba; const parts = m[1].split(',').map(p=>p.trim()); if (parts.length < 4) parts.push(1); // 없으면 alpha=1 let a = parseFloat(parts[3]); a = Math.min(1, Math.max(0, a * factor)); return `rgba(${parts[0]},${parts[1]},${parts[2]},${a})`; } // ===== 레전드(ARMS) ===== function replaceNaN(value){ return isNaN(value) ? " - " : value; } function drawChartWithFooter(dataArr,total) { const existing = document.querySelector('#'+target+' .chart-footer'); if (existing) existing.remove(); const chartFooter = document.createElement("div"); chartFooter.classList.add("chart-footer"); dataArr.forEach((data) => { const item = document.createElement("div"); const portion = replaceNaN(+(data.value*100/ +total).toFixed(0)); item.classList.add("footer-item"); const cmap = window.__ARMS_STATUS_COLORS__ || {}; item.style.borderColor = cmap[data.name] || stableFallbackColor(data.name, defaultColorSet); item.innerHTML = `
${data.name}
${data.value} (${portion}%)
`; chartFooter.appendChild(item); }); chartDom.appendChild(chartFooter); $('.chart-footer .footer-item', chartDom).css('margin-top', '50px'); const items = chartFooter.querySelectorAll('.footer-item'); const n = items.length, rem = n % 4, q = Math.floor(n / 4); items.forEach((it, i) => { if (rem === 1 && Math.floor(i / 4) === q) it.style.width = '100%'; else if (rem === 2 && Math.floor(i / 4) >= q) it.style.width = '50%'; else if (rem === 3 && Math.floor(i / 4) >= q) it.style.width = '33.33%'; else it.style.width = '25%'; }); } function drawModalChartWithFooter(dataArr,total) { const existing = document.querySelector('#'+target+' .modal-chart-footer'); if (existing) existing.remove(); const chartFooter = document.createElement("div"); chartFooter.classList.add("modal-chart-footer"); dataArr.forEach((data) => { const item = document.createElement("div"); const portion = replaceNaN(+(data.value*100/ +total).toFixed(0)); item.classList.add("footer-item"); const cmap = window.__ARMS_STATUS_COLORS__ || {}; item.style.borderColor = cmap[data.name] || stableFallbackColor(data.name, defaultColorSet); item.innerHTML = `
${data.name}
${data.value} (${portion}%)
`; chartFooter.appendChild(item); }); chartDom.appendChild(chartFooter); const items = chartFooter.querySelectorAll('.footer-item'); const n = items.length; items.forEach((it) => { it.style.height = 100 / n + '%'; }); } window.addEventListener('resize', function () { myChart.resize(); }); }