GODRICH
#css애니메이션#버튼#로딩#전환효과#스크롤#대시보드#실전ui#디자인트렌드

css 조직도 만들기 9가지 — 가지 폭을 계산해 부모를 자식 한가운데 놓는 트리 장면

조직도는 박스와 선만 그리면 될 것 같지만, 가지 폭을 재지 않으면 형제 박스가 겹치고 가지가 접힐 때 남은 박스가 튀고 부모가 바뀌면 서로 다른 부모의 가로선이 겹친다 — 01번·03번·07번이 하나씩 푼다.

조직도 아홉 장면을 화면에서 바로 본다

자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip

css 조직도 만들기 9가지 — 가지 폭부터 재야 박스가 겹치지 않는다

아홉 장면은 같은 열한 칸짜리 조직을 쓴다. 대표 아래에 개발·디자인·운영이 있고, 개발 밑에 프론트·백엔드·데이터, 디자인 밑에 브랜드·제품, 운영 밑에 영업·지원이 붙는다. 캔버스 안쪽은 480×300(W·H)이고, 박스와 글자와 선은 모두 2d 컨텍스트로 그려서 CSS 는 같은 16:10 비율로 늘려 보여 주는 일만 한다.

핵심은 배치 계산 두 함수다. measure 가 맨 아래 잎부터 폭을 더해 올라가고, place 가 그 폭대로 자식 무리를 왼쪽에서 오른쪽으로 깔면서 부모를 첫째와 마지막 자식의 한가운데에 놓는다. 01 이 이 뼈대를 보여 주고, 02~09 는 같은 함수를 부른 채 그 위에 선 긋기·접기·경로 강조·확대·부채꼴·옮기기·검색·합산을 하나씩 얹는다. 여기 힘을 주고받는 시뮬레이션은 없다. 같은 입력이면 늘 같은 자리가 나오는 결정적 계산이라서, 노드가 서로 밀어 내며 자리를 찾는 네트워크 그래프 9종 — 힘으로 밀고 당겨 스스로 배치되는 캔버스와 결이 다르다.

시간은 requestAnimationFrame 의 타임스탬프를 16밀리초(FIXED_DT) 단위로 모아 쓰고, 프레임 간격이 160밀리초를 넘으면 160 으로 잘라 탭을 다시 열었을 때 장면이 한꺼번에 튀지 않게 한다. 난수·Date·fetch·저장소·라이브러리·외부 주소가 없어서 같은 시각에는 언제나 같은 그림이 나온다. 목록 폭이 정해진 사이드바 형태의 트리는 파일 트리 만들기 9종 — 폴더가 진짜로 열리는 사이드바가 다룬다.

기법별 코드로 보는 트리 배치

코드 조각은 데모 소스에서 연속된 줄을 그대로 옮겼고 전체 파일은 압축 파일 안에 있다. 박스 크기와 간격은 NODE_W 56·GAP_X 8·LEVEL_DY 72·TOP_Y 44 로 아홉 장면이 같다.

01가지 폭을 재서 부모를 자식 한가운데 놓는 기본 배치

measure 가 잎의 폭 56 에서 시작해 자식 폭의 합에 간격 8 을 더하며 부모 폭으로 올리고, place 가 부모를 첫째·마지막 자식의 가운데에 앉힌다. 기본 트리는 잎 일곱 개 56×7 에 간격 8×6 을 더한 440 폭이라서 개발 184·디자인 120·운영 120 이 나란히 서고, 부모는 이 폭이 정해진 뒤에 첫째·마지막 자식의 가운데로 놓인다. 측정은 누적면적 10.9%·평균 강도 121.0·22/23 프레임이다.

가지 폭을 재서 부모를 자식 한가운데 놓는 기본 배치
새 창에서 열기

01 · 가지 폭을 재서 부모를 자식 한가운데 놓는 기본 배치 — 측정 누적면적 10.878% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

GAP_XNODE_WMID_START_MSROOT_START_MSSETTLE_MS
      function measure(n) {
          const v = vis(n);
          if (v.length === 0) {
              n.w = n.bw;
              return n.w;
          }
          let sum = GAP_X * (v.length - 1);
          for (let i = 0; i < v.length; i += 1) {
              sum += measure(v[i]);
          }
          n.w = Math.max(n.bw, sum);
          return n.w;
      }
      function place(n, left) {
          const v = vis(n);
          n.left = left;
          if (v.length === 0) {
              n.tx = left + n.w / 2;
              return;
          }
          let span = GAP_X * (v.length - 1);
          for (let i = 0; i < v.length; i += 1) {
              span += v[i].w;
          }
          let cur = left + (n.w - span) / 2;
          for (let i = 0; i < v.length; i += 1) {
              place(v[i], cur);
              cur += v[i].w + GAP_X;
          }
          n.tx = (v[0].tx + v[v.length - 1].tx) / 2;
      }

02부모에서 자식으로 꺾여 그려지는 연결선

부모 아래에서 가로로 갈라져 자식으로 내려가는 꺾은선이 길이 비율만큼만 그어지고, 끝에는 흰 점과 진행 퍼센트가 붙는다. 캔버스의 setLineDash 에 전체 길이×비율을 넘기는 방식이라 SVG 의 stroke-dasharray 와 같은 생각이고, 선이 축에 평행한 선분뿐이라 getTotalLength 대신 polyLen 이 가로·세로 길이를 직접 더한다. 측정은 누적면적 13.6%·평균 강도 99.6·22/23 프레임이다.

부모에서 자식으로 꺾여 그려지는 연결선
새 창에서 열기

02 · 부모에서 자식으로 꺾여 그려지는 연결선 — 측정 누적면적 13.637% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

DRAW_MSKID_GAP_MSRETRACT_START_MSRETRACT_MS
      function strokeLine(q, ratio, col, lw) {
          if (ratio <= 0) {
              return;
          }
          const total = polyLen(q);
          ctx.beginPath();
          ctx.moveTo(q[0], q[1]);
          for (let i = 2; i < q.length; i += 2) {
              ctx.lineTo(q[i], q[i + 1]);
          }
          ctx.lineWidth = lw;
          ctx.strokeStyle = col;
          ctx.lineCap = 'round';
          ctx.lineJoin = 'round';
          ctx.setLineDash(ratio >= 1 ? [] : [total * ratio, total * 2]);
          ctx.stroke();
          ctx.setLineDash([]);
      }
      function elbow(p, c) {
          const ay = p.y + p.bh / 2;
          const dy = c.y - c.bh / 2;
          const my = (ay + dy) / 2;
          return [p.x, ay, p.x, my, c.x, my, c.x, dy];
      }

03가지가 접힐 때 남은 박스가 새 자리로 미끄러지는 재배치

가지 하나를 접으면 layout 이 보이는 가지의 폭을 다시 재서 새 목표 자리를 구하고, 박스는 걸음마다 남은 거리의 MOVE_RATE 비율만큼 다가간다. 접힌 노드에는 +3 같은 자식 수 배지가 붙고 열린 노드에는 - 배지가 붙으며, 방향키로 고른 노드를 Enter 나 Space 로 접고 편다. 측정은 누적면적 21.6%·평균 강도 82.8·23/23 프레임이다.

가지가 접힐 때 남은 박스가 새 자리로 미끄러지는 재배치
새 창에서 열기

03 · 가지가 접힐 때 남은 박스가 새 자리로 미끄러지는 재배치 — 측정 누적면적 21.601% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

MOVE_RATEFADE_RATELEVEL_DY
      function settleNodes(rate) {
          for (let i = 0; i < nodes.length; i += 1) {
              const n = nodes[i];
              n.x += (n.tx - n.x) * rate;
              n.y += (n.ty - n.y) * rate;
              n.alpha += (n.talpha - n.alpha) * FADE_RATE;
          }
      function fold(n, open) {
          if (n.kids.length === 0) {
              return;
          }
          n.open = open;
          cursor = n;
          showCursor = true;
          layout();
          setStatus(n.label + ': ' + (open ? '펼침' : '접힘'));
      }

04가리킨 노드에서 최상위까지 경로만 켜지는 강조

노드를 가리키면 그 노드가 0번, 부모가 1번으로 세어지고, k 번째 조상은 시간이 k×LIT_STEP_MS(220)를 넘어야 켜져서 불빛이 아래에서 위로 올라간다. 켜지지 않은 박스와 선은 투명도 0.3(DIM_ALPHA)까지 흐려지고 상태 글자에는 대표 > 개발 > 프론트 같은 경로가 적힌다. 측정은 누적면적 13.8%·평균 강도 74.8·22/23 프레임이다.

가리킨 노드에서 최상위까지 경로만 켜지는 강조
새 창에서 열기

04 · 가리킨 노드에서 최상위까지 경로만 켜지는 강조 — 측정 누적면적 13.762% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

LIT_STEP_MSLIT_RATEDIM_ALPHA
      function litTarget() {
          for (let i = 0; i < nodes.length; i += 1) {
              const k = chainIndex(nodes[i]);
              nodes[i].tval = (focus && k >= 0 && litMs >= k * LIT_STEP_MS) ? 1 : 0;
          }
      }
      stepFn = function (dt) {
          litMs += dt;
          litTarget();
          for (let i = 0; i < nodes.length; i += 1) {
              nodes[i].lit += (nodes[i].tval - nodes[i].lit) * LIT_RATE;
          }
          dimNow += ((focus ? DIM_ALPHA : 1) - dimNow) * LIT_RATE;
      };

05포인터 지점을 붙잡고 확대하는 이동과 줌

확대할 때 zoomAt 이 새 배율과 이동량을 함께 풀어서 포인터 아래의 점이 제자리에 남고, 손을 놓은 뒤에는 속도가 걸음마다 0.94 배로 줄며 미끄러진다. 배율은 1~3.2 로 묶이고 이동 범위는 가로 90·세로 70 의 여유까지이며, 우상단 작은 지도가 지금 보는 영역을 보여 준다. 측정은 누적면적 45.6%·평균 강도 106.0·23/23 프레임이다.

포인터 지점을 붙잡고 확대하는 이동·줌
새 창에서 열기

05 · 포인터 지점을 붙잡고 확대하는 이동·줌 — 측정 누적면적 45.608% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

ZOOM_RATEZOOM_MAXFRICTIONPAD_X
      function clampCam() {
          cam.ox = clamp(cam.ox, W - W * cam.s - PAD_X, PAD_X);
          cam.oy = clamp(cam.oy, H - H * cam.s - PAD_Y, PAD_Y);
      }
      // 확대 비율이 k 배가 될 때 (px, py) 아래의 세계 점이 그대로이도록 이동량을 푼다
      function zoomAt(px, py, k) {
          const ns = clamp(cam.s * k, ZOOM_MIN, ZOOM_MAX);
          const real = ns / cam.s;
          cam.ox = px - (px - cam.ox) * real;
          cam.oy = py - (py - cam.oy) * real;
          cam.s = ns;
          clampCam();
      }

06가로 배치와 부채꼴 배치 사이를 호로 오가는 극좌표 전환

모든 노드가 캔버스 위쪽 한가운데 원점에서 본 각도와 반지름을 갖고, 가로 배치는 Math.atan2 로, 부채꼴은 잎 일곱 개에 FAN_SPAN 2.62 라디안(약 150°)을 고르게 나눠 얻는다. 각도와 반지름을 따로 보간하므로 노드는 직선이 아니라 원점을 도는 호를 그리며 자리를 옮긴다. 측정은 누적면적 21.8%·평균 강도 98.5·23/23 프레임이다.

같은 트리를 부채꼴로 펼치는 극좌표 배치
새 창에서 열기

06 · 같은 트리를 부채꼴로 펼치는 극좌표 배치 — 측정 누적면적 21.756% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

FAN_SPANRINGFAN_RATE
      function assignAngle(n) {
          if (n.kids.length === 0) {
              fanAngle[n.id] = -FAN_SPAN / 2 + (leafNo + 0.5) * FAN_SPAN / leafCount;
              leafNo += 1;
          }
          else {
              for (let i = 0; i < n.kids.length; i += 1) {
                  assignAngle(n.kids[i]);
              }
              fanAngle[n.id] = (fanAngle[n.kids[0].id] + fanAngle[n.kids[n.kids.length - 1].id]) / 2;
          }
          fanRadius[n.id] = n.depth * RING;
      }
      // 가로 배치도 같은 원점에서 본 (각도, 반지름) 으로 바꿔 두면 두 배치 사이를 호로 잇는다
      for (let i = 0; i < nodes.length; i += 1) {
          const dx = nodes[i].tx - ORIGIN_X, dy = nodes[i].ty - ORIGIN_Y;
          flatRadius[i] = Math.sqrt(dx * dx + dy * dy);
          flatAngle[i] = Math.atan2(dx, dy);
      }
      function polarX(r, a) { return ORIGIN_X + r * Math.sin(a); }
      function polarY(r, a) { return ORIGIN_Y + r * Math.cos(a); }

07부모를 바꾸는 순간 겹치는 선을 재고 푸는 재배치

노드를 다른 부모 아래로 옮기면 reparent 가 연결만 먼저 바꾸고 박스는 옛 자리에 둬서, 서로 다른 부모의 같은 높이 가로선이 겹치는 길이를 measureOverlap 이 잰다. 650밀리초 뒤 resolve 가 폭을 다시 재면 겹침이 0 이 되는데, 시연에서는 영업을 개발로 옮길 때 64px, 프론트를 운영으로 옮길 때 192px, 데이터를 디자인으로 옮길 때 64px 가 0 으로 풀린다. 측정은 누적면적 14.6%·평균 강도 58.2·22/23 프레임이다.

부모가 바뀔 때 선과 박스가 함께 재배치되는 이동
새 창에서 열기

07 · 부모가 바뀔 때 선과 박스가 함께 재배치되는 이동 — 측정 누적면적 14.557% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

TANGLE_MSREFLOW_RATEGAP_X
      function measureOverlap(useTarget) {
          overlaps = [];
          overlapPx = 0;
          const spans = [];
          for (let i = 0; i < nodes.length; i += 1) {
              const p = nodes[i];
              const v = vis(p);
              if (v.length === 0) {
                  continue;
              }
              const px0 = useTarget ? p.tx : p.x;
              let lo = px0, hi = px0;
              for (let j = 0; j < v.length; j += 1) {
                  const vx0 = useTarget ? v[j].tx : v[j].x;
                  lo = Math.min(lo, vx0);
                  hi = Math.max(hi, vx0);
              }
              const py0 = useTarget ? p.ty : p.y;
              const vy0 = useTarget ? v[0].ty : v[0].y;
              spans.push([lo, hi, ((py0 + p.bh / 2) + (vy0 - v[0].bh / 2)) / 2]);
          }
          for (let a = 0; a < spans.length; a += 1) {
              for (let b = a + 1; b < spans.length; b += 1) {
                  if (Math.abs(spans[a][2] - spans[b][2]) > 1) {
                      continue;
                  }
                  const lo = Math.max(spans[a][0], spans[b][0]);
                  const hi = Math.min(spans[a][1], spans[b][1]);
                  if (hi - lo > 0.5) {
                      overlaps.push([lo, hi, spans[a][2]]);
                      overlapPx += hi - lo;
                  }
              }
          }
      }
      function resolve() {
          const before = overlapPx;
          layout();
          tangled = false;
          pendingLayout = false;
          // 폭을 다시 잰 목표 자리 기준으로 같은 구간을 다시 잰다
          measureOverlap(true);
          setStatus('선 겹침 ' + Math.round(before) + 'px > ' + Math.round(overlapPx) + 'px');
          overlaps = [];
      }

08질의를 치면 카메라가 맞은 노드로 날아가는 검색

입력 칸에 글자를 치면 라벨에 그 글자가 든 노드가 맞은 것이 되고, 맞은 노드가 있으면 카메라가 걸음마다 0.08 의 비율로 1.9 배 확대 지점을 향해 날아가며 나머지 노드는 투명도 0.25 로 흐려진다. 맞은 글자 밑에는 measureText 로 앞부분 폭을 재서 2px 줄을 긋고, 질의가 비면 처음 배율과 위치로 돌아온다. 측정은 누적면적 53.9%·평균 강도 58.9·22/23 프레임이다.

찾은 노드로 화면이 다가가는 검색 포커스
새 창에서 열기

08 · 찾은 노드로 화면이 다가가는 검색 포커스 — 측정 누적면적 53.938% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

CAM_RATEFOCUS_ZOOMDIM_ALPHA
      function applyQuery(q) {
          query = q;
          matchFirst = null;
          matchCount = 0;
          for (let i = 0; i < nodes.length; i += 1) {
              if (matches(nodes[i])) {
                  matchCount += 1;
                  if (!matchFirst) {
                      matchFirst = nodes[i];
                  }
              }
          }
          if (matchFirst) {
              camS = FOCUS_ZOOM;
              camOx = W / 2 - matchFirst.tx * FOCUS_ZOOM;
              camOy = H / 2 - matchFirst.ty * FOCUS_ZOOM;
              setStatus('찾음 ' + matchCount);
          }
          else {
              camS = 1;
              camOx = 0;
              camOy = 0;
              setStatus(query === '' ? '검색어 없음' : '일치 없음');
          }
      }

09잎의 인원이 위로 합산되어 박스가 커지는 장면

잎마다 인원을 갖고, rollUp 이 아래에서 위로 부모 합계를 매 걸음 다시 세며, 합계가 박스 폭·높이·선 굵기를 정한다. 인원이 바뀔 때마다 폭을 다시 재므로 박스가 커지면서 형제가 옆으로 밀려나고, 폭은 86 에서 더 안 커진다. 측정은 누적면적 11.9%·평균 강도 45.8·23/23 프레임이다.

인원 합계를 위로 모아 박스 크기에 반영하는 히어로 장면
새 창에서 열기

09 · 인원 합계를 위로 모아 박스 크기에 반영하는 히어로 장면 — 측정 누적면적 11.908% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

COUNT_RATEWIDTH_PERHEIGHT_PERLINE_PERWIDTH_MAX
      function rollUp() {
          for (let i = nodes.length - 1; i >= 0; i -= 1) {
              const n = nodes[i];
              if (n.kids.length === 0) {
                  n.total = n.val;
              }
              else {
                  let s = 0;
                  for (let j = 0; j < n.kids.length; j += 1) {
                      s += n.kids[j].total;
                  }
                  n.total = s;
              }
              n.bw = Math.min(WIDTH_MAX, WIDTH_BASE + n.total * WIDTH_PER);
              n.bh = HEIGHT_BASE + n.total * HEIGHT_PER;
          }
      }

값이 장면을 바꾸는 곳 — 가지 간격·옮겨 가는 비율·부채꼴 각도

손대면 화면이 달라지는 값은 셋이다. 형제 사이 간격(01 의 GAP_X), 박스가 새 자리로 다가가는 비율(03 의 MOVE_RATE), 부채꼴이 차지하는 각도(06 의 FAN_SPAN)다. 이 값들은 소스의 상수를 고치면 바로 계산에 들어간다.

간격을 바꾸면 트리 전체 폭이 따라 움직인다

GAP_X 8 에서 맨 위 가지 폭은 440 이다. 3 으로 줄이면 410 이 되어 잎이 더 붙고, 16 으로 넓히면 488 이 되어 480 캔버스를 넘는다. 간격을 한 칸 넓힐 때마다 형제가 많은 가지가 가장 크게 불어난다.

비율을 바꾸면 미끄러지는 시간이 달라진다

MOVE_RATE 0.09 는 한 걸음마다 남은 거리의 9% 를 좁히니, 새 자리의 90% 에 닿는 데 약 24걸음(390밀리초)이 든다. 0.2 로 올리면 약 10걸음(165밀리초)이라 같은 접기가 짧고 빠르게 끝난다.

아홉 장면의 측정은 아래 표 그대로다. 08 과 05 만 누적면적이 30% 를 넘고, 나머지는 박스가 캔버스의 작은 부분이라 10~20%대에 머문다. 01 은 한 프레임에 바뀌는 것이 막대 몇 개와 부모 하나뿐이어서 면적_최대가 3.5% 로 가장 작다.

항목 누적면적 평균 강도 움직인 프레임
01 가지 폭 기본 배치 10.9% 121.0 22/23
02 꺾은선 그리기 13.6% 99.6 22/23
03 접기 재배치 21.6% 82.8 23/23
04 조상 경로 강조 13.8% 74.8 22/23
05 포인터 확대 45.6% 106.0 23/23
06 극좌표 부채꼴 21.8% 98.5 23/23
07 부모 바꾸기 14.6% 58.2 22/23
08 검색 카메라 53.9% 58.9 22/23
09 인원 합산 11.9% 45.8 23/23

어디서 깨지는가 — 넘치는 폭·쏠린 트리·늘어지는 끝·겹치는 순간

폭이 캔버스를 넘는 경우

잎이 일곱 개인 기본 트리는 440 이라 480 안에 들어가지만, 잎이 여덟 개가 되면 56×8 에 간격 8×7 로 504 가 되어 캔버스 밖으로 나간다. GAP_X 를 16 으로 키워도 같은 일이 생기니 박스 폭과 간격은 캔버스 폭에서 거꾸로 정한다.

부모가 가지 폭의 한가운데가 아닌 경우

대표는 첫째 자식(개발, 중심 112)과 마지막 자식(운영, 중심 400)의 한가운데인 256 에 놓인다. 맨 위 가지 폭의 가운데는 240 이라서 대표가 16 오른쪽에 있는데, 개발 가지가 184 로 넓고 디자인·운영이 120 으로 좁기 때문이다. 부모는 가지 폭이 아니라 자식 박스의 위치를 기준으로 놓이니, 한쪽이 무거운 트리는 위가 한쪽으로 쏠려 보인다.

비율로 좁히는 이동의 끝이 길게 늘어지는 경우

03 은 걸음마다 남은 거리의 일정 비율만 줄이기 때문에, 90% 까지는 빠르게 가도 마지막 1px 까지는 걸음이 많이 든다. 접기를 연달아 하면 앞 이동이 끝나기 전에 다음 목표가 생겨 박스가 목표를 쫓아가며 흔들리듯 보일 수 있다.

두 상태가 한 화면에 겹치는 경우

04 에서 가리키는 노드를 바꾸면 이전 경로가 꺼지는 것과 새 경로가 올라가는 것이 겹쳐 잠깐 두 경로가 함께 켜져 보인다. 07 은 같은 높이의 가로선이 겹치는 길이만 잰다. 깊이가 다른 부모 사이는 가로선 높이가 달라 겹침으로 세지 않고, 세로선이 가로선을 가로지르는 교차는 이 꺾은선 구조에서 생기지 않는다.

질의가 여러 노드를 맞추는 경우

08 에서 드 를 치면 백엔드와 브랜드 두 노드가 맞지만 카메라는 첫 노드만 따라간다. 한 글자 질의는 의도하지 않은 노드까지 맞추고, 한글 조합 중에는 input 이벤트가 글자가 완성되기 전에도 와서 맞는 노드가 깜박일 수 있다. 상수를 직접 고쳐 가며 이 경계를 비교하려면 아래 압축 파일을 비밀번호 jksvbezb 으로 열면 된다.

변형 세 가지와 접근성

이름 값 느낌
가지 간격 바꾸기 01 GAP_X 8 → 3 형제 박스 사이 간격 GAP_X 를 8 에서 3 으로 줄이면 맨 위 가지 전체 폭이 440 에서 410 으로 줄고 잎 박스가 더 붙는다. 반대로 16 까지 넓히면 488 이 되어 480 캔버스를 넘는다.
옮겨 가는 비율 조정 03 MOVE_RATE 0.09 → 0.2 한 걸음(16ms)마다 남은 거리를 좁히는 비율 MOVE_RATE 를 0.09 에서 0.2 로 올리면 박스가 새 자리의 90% 에 닿기까지 약 24걸음(390ms)에서 약 10걸음(165ms)으로 줄어, 미끄러짐이 짧고 빠른 끝맺음으로 바뀐다.
부채꼴 각도 줄이기 06 FAN_SPAN 2.62 → 1.6 부채꼴이 차지하는 각도 FAN_SPAN 을 2.62 라디안(약 150°)에서 1.6 라디안(약 92°)으로 줄이면 맨 아래 잎 사이 호 길이가 약 58px 에서 약 36px 로 짧아져 박스 폭 56 보다 좁아진다. 각도를 줄이려면 RING 도 함께 키워야 한다.

prefers-reduced-motion 이 켜져 있으면 아홉 장면 모두 requestAnimationFrame 루프를 돌리지 않고 첫 장면을 즉시 그린다. 이 설정에서는 버튼 이름이 「다음 장면」 으로 바뀌어, 누를 때마다 시연 일정의 다음 사건을 실행하고, 보간 없이 최종 배치를 바로 그린다. 캔버스는 tabindex 0 이고 상태 글자는 aria-live="polite" 로 아홉 장면 모두에 있다. 01·02 는 조작이 없는 시간 함수 장면이라 캔버스 클릭·Enter·Space 가 재생과 멈춤이다. 방향키로 노드를 고르는 항목은 03·04·07·09 네 개이고, 05 의 방향키는 화면을 옮기며 +·- 는 확대·축소, 0·Enter·Space 는 처음 위치로 돌린다. 09 의 +·- 는 고른 잎의 인원을 바꾸고, 04·07 의 Escape 는 선택과 집은 노드를 풀며, 08 은 Enter 로 입력 칸에 포커스를 주고 Escape 로 질의를 지운다. 06 은 포인터 조작만 있고 키 입력으로 노드를 고르지는 못한다. 캔버스 안의 노드는 DOM 요소가 아니라서 role="tree" 는 쓰지 않았고, 대신 고른 노드의 이름을 상태 글자로 읽어 준다. 스크린리더 사용자가 트리 구조를 항목별로 탐색하는 것까지는 지원하지 않으니, 실서비스에서는 같은 데이터로 접근 가능한 목록을 곁에 둔다.

css 조직도 만들기는 flex 와 선만으로 충분한가?

그림이 고정이라면 충분하다. html 조직도 만들기를 가장 단순하게 하는 방법은 박스를 display: flex 로 줄 세우고 선을 의사요소로 긋는 것이고, 접기도 없고 항목이 바뀌지도 않는 소개 페이지의 조직도라면 이쪽이 더 가볍다. 다만 접으면 형제가 부드럽게 모이고, 부모를 바꾸면 선이 다시 정리되고, 포인터를 잡고 확대하는 조직도 html css 화면은 폭을 먼저 재서 자리를 계산해야 하므로 이 글의 방식이 필요해진다.

이런 계산은 사내 인명록이나 프로젝트 보고 라인을 보여 주는 화면에서 쓰인다. 구성원이 수십 명이면 접기(03)와 경로 강조(04)가, 부서 이동이 잦으면 부모 바꾸기(07)와 검색(08)이 먼저 필요하다. 선과 박스를 숫자로 그리는 다른 방식은 css 그래프 만들기 SVG로 9종 — 라이브러리 없이과 canvas 그래프 그리기 — 라이브러리 없이 그리는 애니메이션 차트 9종에서 볼 수 있고, 이 장면들이 호출하는 선·호·글자 그리기는 Canvas API 문서와 requestAnimationFrame 문서에서 읽을 수 있다.

css 조직도 만들기 전에 자주 묻는 질문

html 조직도 소스를 내 페이지에 붙이려면 무엇부터 바꾸나?

압축 파일에서 01 을 열어 ORG 배열의 부서 이름과 부모 번호를 바꾸면 된다. 잎이 일곱 개를 넘으면 박스 폭과 GAP_X 를 먼저 줄여 트리 전체 폭이 캔버스 안에 들어오는지 본다.

외부 라이브러리나 이미지가 필요한가?

필요 없다. 조직도의 박스·글자·선을 전부 캔버스 2d 로 그리고, 라이브러리·이미지·외부 주소 호출이 없어서 장면 하나의 파일만 옮겨도 그대로 돈다.

박스가 겹치거나 가지가 화면 밖으로 나가면 어디를 고치나?

겹치면 measure 가 합산한 폭이 맞는지부터 보고, 화면 밖으로 나가면 NODE_W 와 GAP_X 를 줄여 맨 위 가지 폭을 캔버스 폭 안으로 돌린다. 부모가 한쪽으로 쏠려 보이는 것은 오류가 아니라 자식 위치를 기준으로 놓는 계산의 결과다.

압축 비밀번호를 입력하세요

비밀번호는 이 글 본문 안에 있습니다. 읽다 보면 나옵니다.