---
code: "INT-20"
id: "c-icicle"
tab: "dynamic-2"
kind: "interactive"
title: "Zoomable Icicle · 줌형 아이시클"
library: "SVG"
deps: ["svg"]
load: "low"
pasteKind: "custom-mount"
family: "hierarchy"
dataShape: "zoomable stacked hierarchy bands"
tasks: ["explore data", "filter subsets", "monitor change", "show hierarchy", "drill into hierarchy"]
tags: ["interactive", "exploration", "linked view", "hierarchy", "tree", "icicle", "svg", "zoomable", "줌형", "아이시클"]
gallery: "https://graph.heal7.com/#INT-20"
js: "/sku/INT-20.js"
---

# [INT-20] Zoomable Icicle · 줌형 아이시클

> AI 붙여넣기 카드. 갤러리 런타임(`main.js`)이 아니라 이 파일을 가져와라.

## 언제 가져가나
계층형 비중을 띠 모양으로 쌓고 클릭한 노드 기준으로 깊이를 다시 잡아 들어가는 구조 탐색형 그래프.

**응용** 파일시스템 용량, 카테고리→세부항목 구조, 제품 계보, 소프트웨어 패키지 분석.

## 데이터 모양
- family: `hierarchy`
- dataShape: zoomable stacked hierarchy bands
- tasks: explore data, filter subsets, monitor change, show hierarchy, drill into hierarchy
- tags: interactive, exploration, linked view, hierarchy, tree, icicle, svg, zoomable, 줌형, 아이시클


## 의존
svg · load `low` · library `SVG`

## 붙여넣는 법
1. 라이브러리 없이 host 엘리먼트에 직접 그린다
2. 함수 전체를 가져간다
3. `getElementById('원본id')` 만 대상 노드로 바꾼다

```js
const T = {
  "bg": "transparent",
  "fg": "#ece8f5",
  "sub": "#a59bbd",
  "grid": "rgba(255,255,255,.08)",
  "gold": "#e8c069",
  "royal": "#6b8cff",
  "rose": "#e07a9f",
  "teal": "#6dd2c4",
  "plum": "#b487e8"
};
const isDark = () => document.documentElement.getAttribute('data-theme') !== 'light';
const host = document.querySelector('#chart');
const factory = function(){
  const dom = document.getElementById('c-icicle'); if(!dom) return;
  const palette = [T.gold, T.royal, T.teal, T.rose, T.plum];
  const root = {
    name:'Commerce Suite',
    children:[
      {name:'Acquisition', children:[
        {name:'SEO', value:22},
        {name:'Paid Ads', value:18},
        {name:'Partners', value:12},
        {name:'Referral', value:9}
      ]},
      {name:'Activation', children:[
        {name:'Signup', value:14},
        {name:'Onboarding', value:12},
        {name:'Trial', value:10},
        {name:'Education', value:7}
      ]},
      {name:'Retention', children:[
        {name:'CRM', value:16},
        {name:'Community', value:11},
        {name:'Support', value:8},
        {name:'Success', value:10}
      ]},
      {name:'Revenue', children:[
        {name:'Pro', value:20},
        {name:'Enterprise', value:14},
        {name:'Add-ons', value:8},
        {name:'Renewal', value:11}
      ]}
    ]
  };
  const escapeHtml = str => String(str).replace(/[&<>"']/g, ch => ({
    '&':'&amp;',
    '<':'&lt;',
    '>':'&gt;',
    '"':'&quot;',
    '\'':'&#39;'
  }[ch]));
  const idMap = new Map();
  let seq = 0;
  function sum(node){
    if(Array.isArray(node.children) && node.children.length){
      node.value = node.children.reduce((acc, child) => acc + sum(child), 0);
    } else {
      node.value = node.value || 0;
    }
    return node.value;
  }
  function annotate(node, parent, topIndex){
    node._id = `icicle-${++seq}`;
    node._parent = parent || null;
    node._topIndex = typeof topIndex === 'number' ? topIndex : 0;
    idMap.set(node._id, node);
    if(Array.isArray(node.children)){
      node.children.forEach((child, idx) => annotate(child, node, parent ? node._topIndex : idx));
    }
  }
  function depth(node){
    if(!Array.isArray(node.children) || !node.children.length) return 0;
    return 1 + Math.max(...node.children.map(depth));
  }
  function shortLabel(label, width){
    if(width < 46) return '';
    const maxChars = Math.max(3, Math.floor(width / 7));
    if(label.length <= maxChars) return label;
    return `${label.slice(0, maxChars - 1)}…`;
  }
  sum(root);
  annotate(root, null);
  let focus = root;
  function render(){
    const W = 400;
    const H = 280;
    const headerH = 34;
    const footerH = 12;
    const rowGap = 5;
    const visibleDepth = Math.max(1, depth(focus) + 1);
    const rowH = Math.max(32, (H - headerH - footerH) / visibleDepth);
    const crumbs = [];
    for(let p = focus; p; p = p._parent) crumbs.unshift(p);
    const nodes = [];
    const branchColor = node => palette[node._topIndex % palette.length];
    const fillOpacity = level => Math.max(0.24, 0.8 - level * 0.13);
    function paint(node, x, y, width, level){
      if(width < 2) return;
      const hasChildren = Array.isArray(node.children) && node.children.length;
      const color = level === 0 ? T.gold : branchColor(node);
      const label = shortLabel(node.name, width);
      const valueText = width > 92 ? `${node.value}` : '';
      nodes.push(`
        <g class="icicle-node${hasChildren ? ' clickable' : ''}" data-node="${node._id}" transform="translate(${x},${y})" style="${hasChildren ? 'cursor:pointer' : ''}">
          <rect width="${Math.max(0, width - 2)}" height="${rowH - rowGap}" rx="6"
                fill="${level === 0 ? 'rgba(255,255,255,.04)' : color}" fill-opacity="${level === 0 ? 1 : fillOpacity(level)}"
                stroke="${color}" stroke-width="${level === 0 ? 1.8 : 1.2}"></rect>
          ${label ? `<text x="10" y="${rowH / 2 - 2}" fill="${level === 0 ? T.gold : T.fg}" font-size="${level === 0 ? 12 : 10.5}" font-weight="${level === 0 ? 700 : 600}">${escapeHtml(label)}</text>` : ''}
          ${valueText ? `<text x="${Math.max(36, width - 12)}" y="${rowH / 2 - 2}" text-anchor="end" fill="${T.sub}" font-size="9.5">${valueText}</text>` : ''}
          <title>${escapeHtml(`${node.name} · ${node.value}`)}</title>
        </g>`);
      if(!hasChildren) return;
      let cursor = x;
      node.children.forEach(child => {
        const childWidth = width * (child.value / node.value);
        paint(child, cursor, y + rowH, childWidth, level + 1);
        cursor += childWidth;
      });
    }
    paint(focus, 0, headerH, W, 0);
    const crumbHtml = crumbs.map((node, idx) => {
      const active = idx === crumbs.length - 1;
      return `<tspan fill="${active ? T.gold : T.sub}" font-weight="${active ? 700 : 500}">${escapeHtml(node.name)}</tspan>`;
    }).join(`<tspan fill="${T.sub}"> / </tspan>`);
    dom.innerHTML = `
      <svg viewBox="0 0 ${W} ${H}" width="100%" height="100%" preserveAspectRatio="xMidYMid meet" style="overflow:visible;display:block">
        <rect x="0" y="0" width="${W}" height="${H}" rx="10" fill="rgba(255,255,255,.02)" stroke="rgba(255,255,255,.04)"></rect>
        ${focus._parent ? `
          <g data-up="1" style="cursor:pointer">
            <rect x="10" y="8" width="46" height="20" rx="10" fill="rgba(255,255,255,.05)" stroke="${T.grid}"></rect>
            <text x="33" y="22" text-anchor="middle" fill="${T.fg}" font-size="10.5" font-weight="600">상위</text>
          </g>` : `
          <g data-home="1" style="cursor:pointer">
            <rect x="10" y="8" width="46" height="20" rx="10" fill="rgba(255,255,255,.05)" stroke="${T.grid}"></rect>
            <text x="33" y="22" text-anchor="middle" fill="${T.fg}" font-size="10.5" font-weight="600">전체</text>
          </g>`}
        <text x="66" y="22" fill="${T.sub}" font-size="10.5">${crumbHtml}</text>
        ${nodes.join('')}
        <text x="${W - 12}" y="${H - 8}" text-anchor="end" fill="${T.sub}" font-size="10">click a band to zoom</text>
      </svg>`;
    const up = dom.querySelector('[data-up]');
    if(up){
      up.addEventListener('click', () => {
        if(focus._parent){
          focus = focus._parent;
          render();
        }
      });
    }
    const home = dom.querySelector('[data-home]');
    if(home){
      home.addEventListener('click', () => {
        focus = root;
        render();
      });
    }
    dom.querySelectorAll('.icicle-node.clickable').forEach(nodeEl => {
      nodeEl.addEventListener('click', () => {
        const node = idMap.get(nodeEl.dataset.node);
        if(node && node !== focus){
          focus = node;
          render();
        }
      });
    });
  }
  render();
  charts.push({resize: render});
};
factory();
```

## 원본 factory (갤러리 정본 발췌)
- file: `frontend/js/graph-gallery/main.js`
- lines: 2092-2248
- gallery: https://graph.heal7.com/#INT-20
- raw js: https://graph.heal7.com/sku/INT-20.js

```js
function(){
  const dom = document.getElementById('c-icicle'); if(!dom) return;
  const palette = [T.gold, T.royal, T.teal, T.rose, T.plum];
  const root = {
    name:'Commerce Suite',
    children:[
      {name:'Acquisition', children:[
        {name:'SEO', value:22},
        {name:'Paid Ads', value:18},
        {name:'Partners', value:12},
        {name:'Referral', value:9}
      ]},
      {name:'Activation', children:[
        {name:'Signup', value:14},
        {name:'Onboarding', value:12},
        {name:'Trial', value:10},
        {name:'Education', value:7}
      ]},
      {name:'Retention', children:[
        {name:'CRM', value:16},
        {name:'Community', value:11},
        {name:'Support', value:8},
        {name:'Success', value:10}
      ]},
      {name:'Revenue', children:[
        {name:'Pro', value:20},
        {name:'Enterprise', value:14},
        {name:'Add-ons', value:8},
        {name:'Renewal', value:11}
      ]}
    ]
  };
  const escapeHtml = str => String(str).replace(/[&<>"']/g, ch => ({
    '&':'&amp;',
    '<':'&lt;',
    '>':'&gt;',
    '"':'&quot;',
    '\'':'&#39;'
  }[ch]));
  const idMap = new Map();
  let seq = 0;
  function sum(node){
    if(Array.isArray(node.children) && node.children.length){
      node.value = node.children.reduce((acc, child) => acc + sum(child), 0);
    } else {
      node.value = node.value || 0;
    }
    return node.value;
  }
  function annotate(node, parent, topIndex){
    node._id = `icicle-${++seq}`;
    node._parent = parent || null;
    node._topIndex = typeof topIndex === 'number' ? topIndex : 0;
    idMap.set(node._id, node);
    if(Array.isArray(node.children)){
      node.children.forEach((child, idx) => annotate(child, node, parent ? node._topIndex : idx));
    }
  }
  function depth(node){
    if(!Array.isArray(node.children) || !node.children.length) return 0;
    return 1 + Math.max(...node.children.map(depth));
  }
  function shortLabel(label, width){
    if(width < 46) return '';
    const maxChars = Math.max(3, Math.floor(width / 7));
    if(label.length <= maxChars) return label;
    return `${label.slice(0, maxChars - 1)}…`;
  }
  sum(root);
  annotate(root, null);
  let focus = root;
  function render(){
    const W = 400;
    const H = 280;
    const headerH = 34;
    const footerH = 12;
    const rowGap = 5;
    const visibleDepth = Math.max(1, depth(focus) + 1);
    const rowH = Math.max(32, (H - headerH - footerH) / visibleDepth);
    const crumbs = [];
    for(let p = focus; p; p = p._parent) crumbs.unshift(p);
    const nodes = [];
    const branchColor = node => palette[node._topIndex % palette.length];
    const fillOpacity = level => Math.max(0.24, 0.8 - level * 0.13);
    function paint(node, x, y, width, level){
      if(width < 2) return;
      const hasChildren = Array.isArray(node.children) && node.children.length;
      const color = level === 0 ? T.gold : branchColor(node);
      const label = shortLabel(node.name, width);
      const valueText = width > 92 ? `${node.value}` : '';
      nodes.push(`
        <g class="icicle-node${hasChildren ? ' clickable' : ''}" data-node="${node._id}" transform="translate(${x},${y})" style="${hasChildren ? 'cursor:pointer' : ''}">
          <rect width="${Math.max(0, width - 2)}" height="${rowH - rowGap}" rx="6"
                fill="${level === 0 ? 'rgba(255,255,255,.04)' : color}" fill-opacity="${level === 0 ? 1 : fillOpacity(level)}"
                stroke="${color}" stroke-width="${level === 0 ? 1.8 : 1.2}"></rect>
          ${label ? `<text x="10" y="${rowH / 2 - 2}" fill="${level === 0 ? T.gold : T.fg}" font-size="${level === 0 ? 12 : 10.5}" font-weight="${level === 0 ? 700 : 600}">${escapeHtml(label)}</text>` : ''}
          ${valueText ? `<text x="${Math.max(36, width - 12)}" y="${rowH / 2 - 2}" text-anchor="end" fill="${T.sub}" font-size="9.5">${valueText}</text>` : ''}
          <title>${escapeHtml(`${node.name} · ${node.value}`)}</title>
        </g>`);
      if(!hasChildren) return;
      let cursor = x;
      node.children.forEach(child => {
        const childWidth = width * (child.value / node.value);
        paint(child, cursor, y + rowH, childWidth, level + 1);
        cursor += childWidth;
      });
    }
    paint(focus, 0, headerH, W, 0);
    const crumbHtml = crumbs.map((node, idx) => {
      const active = idx === crumbs.length - 1;
      return `<tspan fill="${active ? T.gold : T.sub}" font-weight="${active ? 700 : 500}">${escapeHtml(node.name)}</tspan>`;
    }).join(`<tspan fill="${T.sub}"> / </tspan>`);
    dom.innerHTML = `
      <svg viewBox="0 0 ${W} ${H}" width="100%" height="100%" preserveAspectRatio="xMidYMid meet" style="overflow:visible;display:block">
        <rect x="0" y="0" width="${W}" height="${H}" rx="10" fill="rgba(255,255,255,.02)" stroke="rgba(255,255,255,.04)"></rect>
        ${focus._parent ? `
          <g data-up="1" style="cursor:pointer">
            <rect x="10" y="8" width="46" height="20" rx="10" fill="rgba(255,255,255,.05)" stroke="${T.grid}"></rect>
            <text x="33" y="22" text-anchor="middle" fill="${T.fg}" font-size="10.5" font-weight="600">상위</text>
          </g>` : `
          <g data-home="1" style="cursor:pointer">
            <rect x="10" y="8" width="46" height="20" rx="10" fill="rgba(255,255,255,.05)" stroke="${T.grid}"></rect>
            <text x="33" y="22" text-anchor="middle" fill="${T.fg}" font-size="10.5" font-weight="600">전체</text>
          </g>`}
        <text x="66" y="22" fill="${T.sub}" font-size="10.5">${crumbHtml}</text>
        ${nodes.join('')}
        <text x="${W - 12}" y="${H - 8}" text-anchor="end" fill="${T.sub}" font-size="10">click a band to zoom</text>
      </svg>`;
    const up = dom.querySelector('[data-up]');
    if(up){
      up.addEventListener('click', () => {
        if(focus._parent){
          focus = focus._parent;
          render();
        }
      });
    }
    const home = dom.querySelector('[data-home]');
    if(home){
      home.addEventListener('click', () => {
        focus = root;
        render();
      });
    }
    dom.querySelectorAll('.icicle-node.clickable').forEach(nodeEl => {
      nodeEl.addEventListener('click', () => {
        const node = idMap.get(nodeEl.dataset.node);
        if(node && node !== focus){
          focus = node;
          render();
        }
      });
    });
  }
  render();
  charts.push({resize: render});
}
```

