04가리킨 노드에서 최상위까지 경로만 켜지는 강조
노드 하나가 가리켜지면 그 노드에서 맨 위까지 부모를 거슬러 올라가며 박스와 선을 차례로 밝히고, 나머지는 흐려진다.
쓰임조직도에서 보고 라인 보여 주기
LIT_STEP_MSLIT_RATEDIM_ALPHA

<main class="stage">
<div class="oc is-demo" id="ocRoot">
<canvas class="oc__canvas" id="ocCanvas" width="480" height="300" tabindex="0" aria-label="노드 하나가 가리켜지면 그 노드에서 맨 위까지 부모를 거슬러 올라가며 박스와 선을 차례로 밝히고, 나머지는 흐려진다."></canvas>
<p class="oc__status" id="ocStatus" aria-live="polite">시연 중</p>
<button class="oc__btn" id="ocBtn" type="button">멈춤</button>
<p class="oc__caption">노드 하나가 가리켜지면 그 노드에서 맨 위까지 부모를 거슬러 올라가며 박스와 선을 차례로 밝히고, 나머지는 흐려진다.</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.oc {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-ink;
}
.oc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.oc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.oc__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 62%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.oc__btn {
position: absolute;
right: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.oc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.oc__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 68%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.oc__caption, .oc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.oc__status { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.oc { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.oc {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.oc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.oc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.oc__status {
position: absolute;
left: 12px;
top: 12px;
max-width: 62%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.oc__btn {
position: absolute;
right: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.oc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.oc__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 68%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.oc__caption, .oc__btn {
font-size: 10px;
padding: 4px 8px;
}
.oc__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.oc {
cursor: default;
}
}(function () {
var root = document.getElementById('ocRoot');
var canvas = document.getElementById('ocCanvas');
var statusEl = document.getElementById('ocStatus');
var btnEl = document.getElementById('ocBtn');
var inputEl = null;
function main(root0, canvas, statusEl, btnEl, inputEl) {
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function () { return; };
}
const ctx = ctx0;
const rootEl = root0;
const W = 480, H = 300;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const MOVE_RATE = 0.09;
const FADE_RATE = 0.12;
const NODE_W = 56, NODE_H = 26, GAP_X = 8, LEVEL_DY = 72, TOP_Y = 44;
const BG = '#211d2b';
const INK = '#17141a';
const FONT = '-apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) {
rootEl.classList.remove('is-demo');
}
let paused = false;
let autoplay = !reduce;
let interactive = false;
let elapsed = 0;
let rafId = 0;
const cleaners = [];
function listen(target, type, fn) {
target.addEventListener(type, fn);
cleaners.push(function () { target.removeEventListener(type, fn); });
}
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function setStatus(msg) { statusEl.textContent = msg; }
canvas.width = W;
canvas.height = H;
// 파스텔 가지 색 — 대표 버터 · 개발 하늘 · 디자인 라일락 · 운영 민트
const BRANCH_COL = [[255, 228, 130], [122, 200, 255], [182, 152, 255], [122, 222, 186]];
function rgba(c, a, mix) {
return 'rgba(' + Math.round(lerp(c[0], 255, mix)) + ',' + Math.round(lerp(c[1], 255, mix)) + ',' + Math.round(lerp(c[2], 255, mix)) + ',' + a.toFixed(3) + ')';
}
// 조직 데이터 — 이름과 부모 번호 (부모가 항상 먼저 나온다)
const ORG = [
['대표', -1], ['개발', 0], ['디자인', 0], ['운영', 0],
['프론트', 1], ['백엔드', 1], ['데이터', 1], ['브랜드', 2], ['제품', 2], ['영업', 3], ['지원', 3]
];
const nodes = [];
for (let i = 0; i < ORG.length; i += 1) {
const pi = ORG[i][1];
const p = pi >= 0 ? nodes[pi] : null;
const nd = {
id: i, label: ORG[i][0], kids: [], parent: p, depth: p ? p.depth + 1 : 0, branch: 0, open: true,
w: NODE_W, left: 0, tx: 0, ty: 0, x: 0, y: 0, alpha: 1, talpha: 1,
bw: NODE_W, bh: NODE_H, lit: 0, val: 0, tval: 0, total: 0
};
nd.branch = p ? (p.depth === 0 ? i : p.branch) : 0;
if (p) {
p.kids.push(nd);
}
nodes.push(nd);
}
const root = nodes[0];
function vis(n) { return n.open ? n.kids : []; }
function isShown(n) {
let p = n.parent;
while (p) {
if (!p.open) {
return false;
}
p = p.parent;
}
return true;
}
function setDepth(n, d) {
n.depth = d;
for (let i = 0; i < n.kids.length; i += 1) {
setDepth(n.kids[i], d + 1);
}
}
// 가지 폭 재기 — 가장 아래 잎에서 위로 올라가며 폭을 더한다
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;
}
// 접힌 가지 안의 노드는 가장 가까운 보이는 조상 자리로 모인다
function gather(n, hidden, anchor) {
n.ty = TOP_Y + n.depth * LEVEL_DY;
if (hidden) {
n.tx = anchor.tx;
n.ty = anchor.ty;
n.talpha = 0;
}
else {
n.talpha = 1;
}
for (let i = 0; i < n.kids.length; i += 1) {
const kidHidden = hidden || !n.open;
gather(n.kids[i], kidHidden, hidden ? anchor : n);
}
}
function layout() {
measure(root);
place(root, (W - root.w) / 2);
gather(root, false, root);
}
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 snapNodes() {
for (let i = 0; i < nodes.length; i += 1) {
const n = nodes[i];
n.x = n.tx;
n.y = n.ty;
n.alpha = n.talpha;
}
}
// 카메라 — 화면 좌표 = 세계 좌표 * s + (ox, oy)
const cam = { s: 1, ox: 0, oy: 0 };
function worldX(sx) { return (sx - cam.ox) / cam.s; }
function worldY(sy) { return (sy - cam.oy) / cam.s; }
// 그리기 도구
function rr(x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
function clearStage() {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.globalAlpha = 1;
ctx.fillStyle = BG;
ctx.fillRect(0, 0, W, H);
}
function beginWorld() { ctx.setTransform(cam.s, 0, 0, cam.s, cam.ox, cam.oy); }
function endWorld() { ctx.setTransform(1, 0, 0, 1, 0, 0); }
function polyLen(q) {
let s = 0;
for (let i = 2; i < q.length; i += 2) {
s += Math.abs(q[i] - q[i - 2]) + Math.abs(q[i + 1] - q[i - 1]);
}
return s;
}
// 꺾은선 길이 대비 비율만큼만 긋는다 (점선 길이 = 전체 길이 * ratio)
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 pointAt(q, ratio) {
let left = polyLen(q) * clamp(ratio, 0, 1);
for (let i = 2; i < q.length; i += 2) {
const seg = Math.abs(q[i] - q[i - 2]) + Math.abs(q[i + 1] - q[i - 1]);
if (left <= seg || i === q.length - 2) {
const f = seg > 0 ? Math.min(left / seg, 1) : 1;
return [lerp(q[i - 2], q[i], f), lerp(q[i - 1], q[i + 1], f)];
}
left -= seg;
}
return [q[0], q[1]];
}
// 부모 아래에서 가로로 갈라져 자식으로 내려가는 꺾은선 (부모 아래 가운데, 가운데 높이, 자식 위 가운데)
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];
}
function drawBox(n, a, mix, dy = 1) {
if (a <= 0.01) {
return;
}
const col = BRANCH_COL[n.branch];
ctx.globalAlpha = clamp(a, 0, 1);
rr(n.x - n.bw / 2, n.y - n.bh / 2, n.bw, n.bh, 6);
ctx.fillStyle = rgba(col, 1, mix);
ctx.fill();
ctx.fillStyle = INK;
ctx.font = '600 10px ' + FONT;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(n.label, n.x, n.y + dy);
ctx.globalAlpha = 1;
}
function drawEdgesPlain(lineAlpha) {
for (let i = 1; i < nodes.length; i += 1) {
const n = nodes[i];
const p = n.parent;
if (!p) {
continue;
}
const a = Math.min(p.alpha, n.alpha) * lineAlpha;
if (a <= 0.01) {
continue;
}
strokeLine(elbow(p, n), 1, 'rgba(255,255,255,' + (0.55 * a).toFixed(3) + ')', 1.5);
}
}
function drawBoxesPlain() {
for (let d = 0; d < 4; d += 1) {
for (let i = 0; i < nodes.length; i += 1) {
if (nodes[i].depth === d) {
drawBox(nodes[i], nodes[i].alpha, d === 2 ? 0.34 : (d === 1 ? 0.1 : 0));
}
}
}
}
function ring(n, col, pad) {
ctx.globalAlpha = 1;
rr(n.x - n.bw / 2 - pad, n.y - n.bh / 2 - pad, n.bw + pad * 2, n.bh + pad * 2, 8);
ctx.lineWidth = 2;
ctx.strokeStyle = col;
ctx.stroke();
}
// 키보드 탐색 — 방향키가 부모·자식·같은 높이의 이웃으로 옮긴다
let cursor = root;
let showCursor = false;
function moveCursor(k) {
let t = null;
if (k === 'ArrowDown') {
const v = vis(cursor);
t = v.length > 0 ? v[0] : null;
}
else if (k === 'ArrowUp') {
t = cursor.parent;
}
else if (k === 'ArrowLeft' || k === 'ArrowRight') {
const row = [];
for (let i = 0; i < nodes.length; i += 1) {
if (nodes[i].depth === cursor.depth && isShown(nodes[i])) {
row.push(nodes[i]);
}
}
row.sort(function (a, b) { return a.tx - b.tx; });
const j = row.indexOf(cursor) + (k === 'ArrowRight' ? 1 : -1);
t = (j >= 0 && j < row.length) ? row[j] : null;
}
else if (k === 'Home') {
t = root;
}
else {
return false;
}
if (t) {
cursor = t;
}
return true;
}
function hitNode(sx, sy) {
const x = worldX(sx), y = worldY(sy);
let best = null;
for (let i = 0; i < nodes.length; i += 1) {
const n = nodes[i];
if (n.alpha > 0.3 && Math.abs(x - n.x) <= n.bw / 2 + 2 && Math.abs(y - n.y) <= n.bh / 2 + 2) {
best = n;
}
}
return best;
}
function toLocal(e) {
const r = canvas.getBoundingClientRect();
return [(e.clientX - r.left) * W / r.width, (e.clientY - r.top) * H / r.height];
}
// 장면이 채우는 자리 — 시연 일정, 시간 함수 여부, 입력 처리
let script = [];
let period = 4000;
let timeScene = false;
let reduceMs = 0;
let stepFn = function () { return; };
let drawFn = function () { return; };
let actFn = function () { return; };
let snapFn = function () { return; };
let ptrFn = function () { return; };
let keyFn = function () { return false; };
layout();
snapNodes();
// 04 가리킨 노드에서 최상위까지 경로만 켜지는 강조 — 한 칸씩 부모를 거슬러 올라간다
interactive = true;
period = 6300;
const LIT_STEP_MS = 220;
const LIT_RATE = 0.22;
const DIM_ALPHA = 0.3;
let focus = null;
let litMs = 0;
let dimNow = 1;
const BY_LABEL = {};
for (let i = 0; i < nodes.length; i += 1) {
BY_LABEL[nodes[i].label] = nodes[i];
}
function chainIndex(n) {
let k = 0;
let c = focus;
while (c) {
if (c === n) {
return k;
}
c = c.parent;
k += 1;
}
return -1;
}
function pathText(n) {
const parts = [];
let c = n;
while (c) {
parts.unshift(c.label);
c = c.parent;
}
return parts.join(' > ');
}
function point(n) {
focus = n;
litMs = 0;
if (n) {
cursor = n;
setStatus(pathText(n));
}
}
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;
}
}
script = [
{ at: 250, run: function () { point(BY_LABEL['프론트']); } },
{ at: 1250, run: function () { point(BY_LABEL['브랜드']); } },
{ at: 2250, run: function () { point(BY_LABEL['지원']); } },
{ at: 3250, run: function () { point(BY_LABEL['데이터']); } },
{ at: 4250, run: function () { point(BY_LABEL['제품']); } },
{ at: 5250, run: function () { point(BY_LABEL['영업']); } }
];
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;
};
snapFn = function () {
litMs = 100000;
litTarget();
for (let i = 0; i < nodes.length; i += 1) {
nodes[i].lit = nodes[i].tval;
}
dimNow = focus ? DIM_ALPHA : 1;
};
keyFn = function (k) {
if (k === 'Escape') {
point(null);
showCursor = false;
setStatus('선택 없음');
return true;
}
const used = moveCursor(k);
if (used) {
showCursor = true;
point(cursor);
}
return used;
};
actFn = function () { point(cursor); showCursor = true; };
ptrFn = function (kind, x, y) {
if (kind === 'wheel') {
return;
}
const n = hitNode(x, y);
if (n && n !== focus) {
takeOver();
point(n);
}
};
drawFn = function () {
clearStage();
for (let i = 1; i < nodes.length; i += 1) {
const n = nodes[i];
const p = n.parent;
if (!p) {
continue;
}
const on = Math.min(p.lit, n.lit);
const q = elbow(p, n);
strokeLine(q, 1, 'rgba(255,255,255,' + (0.5 * dimNow).toFixed(3) + ')', 1.5);
if (on > 0.02) {
strokeLine(q, on, rgba(BRANCH_COL[n.branch], 1, 0.15), 3.5);
}
}
for (let d = 0; d < 3; d += 1) {
for (let i = 0; i < nodes.length; i += 1) {
const n = nodes[i];
if (n.depth !== d) {
continue;
}
const a = lerp(dimNow, 1, n.lit);
drawBox(n, a, d === 2 ? 0.34 : (d === 1 ? 0.1 : 0));
if (n.lit > 0.05) {
ctx.globalAlpha = n.lit;
ring(n, 'rgba(255,255,255,0.95)', 2);
ctx.globalAlpha = 1;
}
}
}
if (showCursor && !focus) {
ring(cursor, 'rgba(255,255,255,0.6)', 4);
}
};
let lastTs = null;
let accMs = 0;
let prevCyc = 0;
let scriptIdx = 0;
function runScript() {
const cyc = elapsed % period;
for (let i = 0; i < script.length; i += 1) {
const at = script[i].at;
const due = prevCyc <= cyc ? (at > prevCyc && at <= cyc) : (at > prevCyc || at <= cyc);
if (due) {
script[i].run();
}
}
prevCyc = cyc;
}
function setMode() {
if (reduce) {
setStatus('한 장면씩 넘기는 중');
btnEl.textContent = '다음 장면';
return;
}
setStatus(!autoplay ? '직접 조작 중' : (paused ? '일시정지됨' : '시연 중'));
btnEl.textContent = (autoplay && !paused) ? '멈춤' : '재생';
}
function takeOver() {
if (reduce || !autoplay) {
return;
}
autoplay = false;
paused = false;
rootEl.classList.remove('is-demo');
setMode();
}
function nextScene() {
if (timeScene) {
elapsed += REDUCE_STEP_MS;
}
else if (script.length > 0) {
script[scriptIdx % script.length].run();
scriptIdx += 1;
}
snapFn();
drawFn();
}
function afterInput() {
if (reduce) {
snapFn();
drawFn();
}
}
function onButton() {
if (reduce) {
nextScene();
setStatus('한 장면씩 넘기는 중');
return;
}
if (autoplay && !paused) {
paused = true;
}
else {
paused = false;
autoplay = true;
prevCyc = elapsed % period;
rootEl.classList.add('is-demo');
}
setMode();
}
function tick(ts) {
if (lastTs === null) {
lastTs = ts;
}
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
elapsed += FIXED_DT;
accMs -= FIXED_DT;
if (autoplay) {
runScript();
}
stepFn(FIXED_DT);
}
drawFn();
}
rafId = requestAnimationFrame(tick);
}
listen(btnEl, 'click', function () { onButton(); });
listen(canvas, 'pointerdown', function (e) {
if (!interactive) {
onButton();
return;
}
takeOver();
const p = toLocal(e);
ptrFn('down', p[0], p[1], 0);
afterInput();
});
listen(canvas, 'pointermove', function (e) {
if (!interactive) {
return;
}
const p = toLocal(e);
ptrFn('move', p[0], p[1], e.buttons);
afterInput();
});
listen(canvas, 'pointerup', function (e) {
if (!interactive) {
return;
}
const p = toLocal(e);
ptrFn('up', p[0], p[1], 0);
afterInput();
});
listen(canvas, 'wheel', function (e) {
if (!interactive) {
return;
}
e.preventDefault();
takeOver();
const p = toLocal(e);
ptrFn('wheel', p[0], p[1], e.deltaY);
afterInput();
});
listen(canvas, 'keydown', function (e) {
const k = e.key;
if (!interactive) {
if (k === 'Enter' || k === ' ') {
e.preventDefault();
onButton();
}
return;
}
takeOver();
if (keyFn(k)) {
e.preventDefault();
}
else if (k === 'Enter' || k === ' ') {
e.preventDefault();
actFn();
}
afterInput();
});
if (reduce) {
if (timeScene) {
elapsed = reduceMs;
}
else if (script.length > 0) {
script[0].run();
scriptIdx = 1;
}
snapFn();
drawFn();
setMode();
}
else {
drawFn();
setMode();
rafId = requestAnimationFrame(tick);
}
function destroy() {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
}
return destroy;
}
main(root, canvas, statusEl, btnEl, inputEl);
})();import { useEffect, useRef } from "react";
import styles from "./OrgcAncestorPathHighlight.module.scss";
type OcNode = {
id: number; label: string; kids: OcNode[]; parent: OcNode | null; depth: number; branch: number; open: boolean;
w: number; left: number; tx: number; ty: number; x: number; y: number; alpha: number; talpha: number;
bw: number; bh: number; lit: number; val: number; tval: number; total: number;
};
type OcEvent = { at: number; run: () => void };
function main(root0: HTMLDivElement, canvas: HTMLCanvasElement, statusEl: HTMLElement, btnEl: HTMLButtonElement, inputEl: HTMLInputElement | null): () => void {
const ctx0 = canvas.getContext('2d');
if (!ctx0) { return function (): void { return; }; }
const ctx: CanvasRenderingContext2D = ctx0;
const rootEl: HTMLDivElement = root0;
const W = 480, H = 300;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const MOVE_RATE = 0.09;
const FADE_RATE = 0.12;
const NODE_W = 56, NODE_H = 26, GAP_X = 8, LEVEL_DY = 72, TOP_Y = 44;
const BG = '#211d2b';
const INK = '#17141a';
const FONT = '-apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) { rootEl.classList.remove('is-demo'); }
let paused = false;
let autoplay = !reduce;
let interactive = false;
let elapsed = 0;
let rafId = 0;
const cleaners: Array<() => void> = [];
function listen(target: EventTarget, type: string, fn: (e: Event) => void): void {
target.addEventListener(type, fn);
cleaners.push(function (): void { target.removeEventListener(type, fn); });
}
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function setStatus(msg: string): void { statusEl.textContent = msg; }
canvas.width = W;
canvas.height = H;
// 파스텔 가지 색 — 대표 버터 · 개발 하늘 · 디자인 라일락 · 운영 민트
const BRANCH_COL: number[][] = [[255, 228, 130], [122, 200, 255], [182, 152, 255], [122, 222, 186]];
function rgba(c: number[], a: number, mix: number): string {
return 'rgba(' + Math.round(lerp(c[0], 255, mix)) + ',' + Math.round(lerp(c[1], 255, mix)) + ',' + Math.round(lerp(c[2], 255, mix)) + ',' + a.toFixed(3) + ')';
}
// 조직 데이터 — 이름과 부모 번호 (부모가 항상 먼저 나온다)
const ORG: Array<[string, number]> = [
['대표', -1], ['개발', 0], ['디자인', 0], ['운영', 0],
['프론트', 1], ['백엔드', 1], ['데이터', 1], ['브랜드', 2], ['제품', 2], ['영업', 3], ['지원', 3]
];
const nodes: OcNode[] = [];
for (let i = 0; i < ORG.length; i += 1) {
const pi = ORG[i][1];
const p: OcNode | null = pi >= 0 ? nodes[pi] : null;
const nd: OcNode = {
id: i, label: ORG[i][0], kids: [], parent: p, depth: p ? p.depth + 1 : 0, branch: 0, open: true,
w: NODE_W, left: 0, tx: 0, ty: 0, x: 0, y: 0, alpha: 1, talpha: 1,
bw: NODE_W, bh: NODE_H, lit: 0, val: 0, tval: 0, total: 0
};
nd.branch = p ? (p.depth === 0 ? i : p.branch) : 0;
if (p) { p.kids.push(nd); }
nodes.push(nd);
}
const root: OcNode = nodes[0];
function vis(n: OcNode): OcNode[] { return n.open ? n.kids : []; }
function isShown(n: OcNode): boolean {
let p = n.parent;
while (p) { if (!p.open) { return false; } p = p.parent; }
return true;
}
function setDepth(n: OcNode, d: number): void {
n.depth = d;
for (let i = 0; i < n.kids.length; i += 1) { setDepth(n.kids[i], d + 1); }
}
// 가지 폭 재기 — 가장 아래 잎에서 위로 올라가며 폭을 더한다
function measure(n: OcNode): number {
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: OcNode, left: number): void {
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;
}
// 접힌 가지 안의 노드는 가장 가까운 보이는 조상 자리로 모인다
function gather(n: OcNode, hidden: boolean, anchor: OcNode): void {
n.ty = TOP_Y + n.depth * LEVEL_DY;
if (hidden) { n.tx = anchor.tx; n.ty = anchor.ty; n.talpha = 0; } else { n.talpha = 1; }
for (let i = 0; i < n.kids.length; i += 1) {
const kidHidden = hidden || !n.open;
gather(n.kids[i], kidHidden, hidden ? anchor : n);
}
}
function layout(): void {
measure(root);
place(root, (W - root.w) / 2);
gather(root, false, root);
}
function settleNodes(rate: number): void {
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 snapNodes(): void {
for (let i = 0; i < nodes.length; i += 1) {
const n = nodes[i];
n.x = n.tx; n.y = n.ty; n.alpha = n.talpha;
}
}
// 카메라 — 화면 좌표 = 세계 좌표 * s + (ox, oy)
const cam = { s: 1, ox: 0, oy: 0 };
function worldX(sx: number): number { return (sx - cam.ox) / cam.s; }
function worldY(sy: number): number { return (sy - cam.oy) / cam.s; }
// 그리기 도구
function rr(x: number, y: number, w: number, h: number, r: number): void {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
function clearStage(): void {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.globalAlpha = 1;
ctx.fillStyle = BG;
ctx.fillRect(0, 0, W, H);
}
function beginWorld(): void { ctx.setTransform(cam.s, 0, 0, cam.s, cam.ox, cam.oy); }
function endWorld(): void { ctx.setTransform(1, 0, 0, 1, 0, 0); }
function polyLen(q: number[]): number {
let s = 0;
for (let i = 2; i < q.length; i += 2) { s += Math.abs(q[i] - q[i - 2]) + Math.abs(q[i + 1] - q[i - 1]); }
return s;
}
// 꺾은선 길이 대비 비율만큼만 긋는다 (점선 길이 = 전체 길이 * ratio)
function strokeLine(q: number[], ratio: number, col: string, lw: number): void {
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 pointAt(q: number[], ratio: number): number[] {
let left = polyLen(q) * clamp(ratio, 0, 1);
for (let i = 2; i < q.length; i += 2) {
const seg = Math.abs(q[i] - q[i - 2]) + Math.abs(q[i + 1] - q[i - 1]);
if (left <= seg || i === q.length - 2) {
const f = seg > 0 ? Math.min(left / seg, 1) : 1;
return [lerp(q[i - 2], q[i], f), lerp(q[i - 1], q[i + 1], f)];
}
left -= seg;
}
return [q[0], q[1]];
}
// 부모 아래에서 가로로 갈라져 자식으로 내려가는 꺾은선 (부모 아래 가운데, 가운데 높이, 자식 위 가운데)
function elbow(p: OcNode, c: OcNode): number[] {
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];
}
function drawBox(n: OcNode, a: number, mix: number, dy: number = 1): void {
if (a <= 0.01) { return; }
const col = BRANCH_COL[n.branch];
ctx.globalAlpha = clamp(a, 0, 1);
rr(n.x - n.bw / 2, n.y - n.bh / 2, n.bw, n.bh, 6);
ctx.fillStyle = rgba(col, 1, mix);
ctx.fill();
ctx.fillStyle = INK;
ctx.font = '600 10px ' + FONT;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(n.label, n.x, n.y + dy);
ctx.globalAlpha = 1;
}
function drawEdgesPlain(lineAlpha: number): void {
for (let i = 1; i < nodes.length; i += 1) {
const n = nodes[i];
const p = n.parent;
if (!p) { continue; }
const a = Math.min(p.alpha, n.alpha) * lineAlpha;
if (a <= 0.01) { continue; }
strokeLine(elbow(p, n), 1, 'rgba(255,255,255,' + (0.55 * a).toFixed(3) + ')', 1.5);
}
}
function drawBoxesPlain(): void {
for (let d = 0; d < 4; d += 1) {
for (let i = 0; i < nodes.length; i += 1) {
if (nodes[i].depth === d) { drawBox(nodes[i], nodes[i].alpha, d === 2 ? 0.34 : (d === 1 ? 0.1 : 0)); }
}
}
}
function ring(n: OcNode, col: string, pad: number): void {
ctx.globalAlpha = 1;
rr(n.x - n.bw / 2 - pad, n.y - n.bh / 2 - pad, n.bw + pad * 2, n.bh + pad * 2, 8);
ctx.lineWidth = 2;
ctx.strokeStyle = col;
ctx.stroke();
}
// 키보드 탐색 — 방향키가 부모·자식·같은 높이의 이웃으로 옮긴다
let cursor: OcNode = root;
let showCursor = false;
function moveCursor(k: string): boolean {
let t: OcNode | null = null;
if (k === 'ArrowDown') { const v = vis(cursor); t = v.length > 0 ? v[0] : null; }
else if (k === 'ArrowUp') { t = cursor.parent; }
else if (k === 'ArrowLeft' || k === 'ArrowRight') {
const row: OcNode[] = [];
for (let i = 0; i < nodes.length; i += 1) { if (nodes[i].depth === cursor.depth && isShown(nodes[i])) { row.push(nodes[i]); } }
row.sort(function (a: OcNode, b: OcNode): number { return a.tx - b.tx; });
const j = row.indexOf(cursor) + (k === 'ArrowRight' ? 1 : -1);
t = (j >= 0 && j < row.length) ? row[j] : null;
}
else if (k === 'Home') { t = root; }
else { return false; }
if (t) { cursor = t; }
return true;
}
function hitNode(sx: number, sy: number): OcNode | null {
const x = worldX(sx), y = worldY(sy);
let best: OcNode | null = null;
for (let i = 0; i < nodes.length; i += 1) {
const n = nodes[i];
if (n.alpha > 0.3 && Math.abs(x - n.x) <= n.bw / 2 + 2 && Math.abs(y - n.y) <= n.bh / 2 + 2) { best = n; }
}
return best;
}
function toLocal(e: MouseEvent): number[] {
const r = canvas.getBoundingClientRect();
return [(e.clientX - r.left) * W / r.width, (e.clientY - r.top) * H / r.height];
}
// 장면이 채우는 자리 — 시연 일정, 시간 함수 여부, 입력 처리
let script: OcEvent[] = [];
let period = 4000;
let timeScene = false;
let reduceMs = 0;
let stepFn: (dt: number) => void = function (): void { return; };
let drawFn: () => void = function (): void { return; };
let actFn: () => void = function (): void { return; };
let snapFn: () => void = function (): void { return; };
let ptrFn: (kind: string, x: number, y: number, d: number) => void = function (): void { return; };
let keyFn: (k: string) => boolean = function (): boolean { return false; };
layout();
snapNodes();
// 04 가리킨 노드에서 최상위까지 경로만 켜지는 강조 — 한 칸씩 부모를 거슬러 올라간다
interactive = true;
period = 6300;
const LIT_STEP_MS = 220;
const LIT_RATE = 0.22;
const DIM_ALPHA = 0.3;
let focus: OcNode | null = null;
let litMs = 0;
let dimNow = 1;
const BY_LABEL: { [k: string]: OcNode } = {};
for (let i = 0; i < nodes.length; i += 1) { BY_LABEL[nodes[i].label] = nodes[i]; }
function chainIndex(n: OcNode): number {
let k = 0;
let c: OcNode | null = focus;
while (c) {
if (c === n) { return k; }
c = c.parent;
k += 1;
}
return -1;
}
function pathText(n: OcNode): string {
const parts: string[] = [];
let c: OcNode | null = n;
while (c) { parts.unshift(c.label); c = c.parent; }
return parts.join(' > ');
}
function point(n: OcNode | null): void {
focus = n;
litMs = 0;
if (n) { cursor = n; setStatus(pathText(n)); }
}
function litTarget(): void {
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;
}
}
script = [
{ at: 250, run: function (): void { point(BY_LABEL['프론트']); } },
{ at: 1250, run: function (): void { point(BY_LABEL['브랜드']); } },
{ at: 2250, run: function (): void { point(BY_LABEL['지원']); } },
{ at: 3250, run: function (): void { point(BY_LABEL['데이터']); } },
{ at: 4250, run: function (): void { point(BY_LABEL['제품']); } },
{ at: 5250, run: function (): void { point(BY_LABEL['영업']); } }
];
stepFn = function (dt: number): void {
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;
};
snapFn = function (): void {
litMs = 100000;
litTarget();
for (let i = 0; i < nodes.length; i += 1) { nodes[i].lit = nodes[i].tval; }
dimNow = focus ? DIM_ALPHA : 1;
};
keyFn = function (k: string): boolean {
if (k === 'Escape') { point(null); showCursor = false; setStatus('선택 없음'); return true; }
const used = moveCursor(k);
if (used) { showCursor = true; point(cursor); }
return used;
};
actFn = function (): void { point(cursor); showCursor = true; };
ptrFn = function (kind: string, x: number, y: number): void {
if (kind === 'wheel') { return; }
const n = hitNode(x, y);
if (n && n !== focus) { takeOver(); point(n); }
};
drawFn = function (): void {
clearStage();
for (let i = 1; i < nodes.length; i += 1) {
const n = nodes[i];
const p = n.parent;
if (!p) { continue; }
const on = Math.min(p.lit, n.lit);
const q = elbow(p, n);
strokeLine(q, 1, 'rgba(255,255,255,' + (0.5 * dimNow).toFixed(3) + ')', 1.5);
if (on > 0.02) { strokeLine(q, on, rgba(BRANCH_COL[n.branch], 1, 0.15), 3.5); }
}
for (let d = 0; d < 3; d += 1) {
for (let i = 0; i < nodes.length; i += 1) {
const n = nodes[i];
if (n.depth !== d) { continue; }
const a = lerp(dimNow, 1, n.lit);
drawBox(n, a, d === 2 ? 0.34 : (d === 1 ? 0.1 : 0));
if (n.lit > 0.05) { ctx.globalAlpha = n.lit; ring(n, 'rgba(255,255,255,0.95)', 2); ctx.globalAlpha = 1; }
}
}
if (showCursor && !focus) { ring(cursor, 'rgba(255,255,255,0.6)', 4); }
};
let lastTs: number | null = null;
let accMs = 0;
let prevCyc = 0;
let scriptIdx = 0;
function runScript(): void {
const cyc = elapsed % period;
for (let i = 0; i < script.length; i += 1) {
const at = script[i].at;
const due = prevCyc <= cyc ? (at > prevCyc && at <= cyc) : (at > prevCyc || at <= cyc);
if (due) { script[i].run(); }
}
prevCyc = cyc;
}
function setMode(): void {
if (reduce) {
setStatus('한 장면씩 넘기는 중');
btnEl.textContent = '다음 장면';
return;
}
setStatus(!autoplay ? '직접 조작 중' : (paused ? '일시정지됨' : '시연 중'));
btnEl.textContent = (autoplay && !paused) ? '멈춤' : '재생';
}
function takeOver(): void {
if (reduce || !autoplay) { return; }
autoplay = false;
paused = false;
rootEl.classList.remove('is-demo');
setMode();
}
function nextScene(): void {
if (timeScene) { elapsed += REDUCE_STEP_MS; }
else if (script.length > 0) { script[scriptIdx % script.length].run(); scriptIdx += 1; }
snapFn();
drawFn();
}
function afterInput(): void {
if (reduce) { snapFn(); drawFn(); }
}
function onButton(): void {
if (reduce) { nextScene(); setStatus('한 장면씩 넘기는 중'); return; }
if (autoplay && !paused) { paused = true; }
else { paused = false; autoplay = true; prevCyc = elapsed % period; rootEl.classList.add('is-demo'); }
setMode();
}
function tick(ts: number): void {
if (lastTs === null) { lastTs = ts; }
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
elapsed += FIXED_DT;
accMs -= FIXED_DT;
if (autoplay) { runScript(); }
stepFn(FIXED_DT);
}
drawFn();
}
rafId = requestAnimationFrame(tick);
}
listen(btnEl, 'click', function (): void { onButton(); });
listen(canvas, 'pointerdown', function (e: Event): void {
if (!interactive) { onButton(); return; }
takeOver();
const p = toLocal(e as PointerEvent);
ptrFn('down', p[0], p[1], 0);
afterInput();
});
listen(canvas, 'pointermove', function (e: Event): void {
if (!interactive) { return; }
const p = toLocal(e as PointerEvent);
ptrFn('move', p[0], p[1], (e as PointerEvent).buttons);
afterInput();
});
listen(canvas, 'pointerup', function (e: Event): void {
if (!interactive) { return; }
const p = toLocal(e as PointerEvent);
ptrFn('up', p[0], p[1], 0);
afterInput();
});
listen(canvas, 'wheel', function (e: Event): void {
if (!interactive) { return; }
e.preventDefault();
takeOver();
const p = toLocal(e as WheelEvent);
ptrFn('wheel', p[0], p[1], (e as WheelEvent).deltaY);
afterInput();
});
listen(canvas, 'keydown', function (e: Event): void {
const k = (e as KeyboardEvent).key;
if (!interactive) {
if (k === 'Enter' || k === ' ') { e.preventDefault(); onButton(); }
return;
}
takeOver();
if (keyFn(k)) { e.preventDefault(); }
else if (k === 'Enter' || k === ' ') { e.preventDefault(); actFn(); }
afterInput();
});
if (reduce) {
if (timeScene) { elapsed = reduceMs; } else if (script.length > 0) { script[0].run(); scriptIdx = 1; }
snapFn();
drawFn();
setMode();
} else {
drawFn();
setMode();
rafId = requestAnimationFrame(tick);
}
function destroy(): void {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) { cleaners[i](); }
}
return destroy;
}
export default function OrgcAncestorPathHighlight() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const statusRef = useRef<HTMLParagraphElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const statusEl = statusRef.current;
const btnEl = btnRef.current;
if (!root || !canvas || !statusEl || !btnEl) return;
return main(root, canvas, statusEl, btnEl, null);
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.oc} is-demo`} ref={rootRef}>
<canvas className={styles.oc__canvas} ref={canvasRef} width={480} height={300} tabIndex={0} aria-label="노드 하나가 가리켜지면 그 노드에서 맨 위까지 부모를 거슬러 올라가며 박스와 선을 차례로 밝히고, 나머지는 흐려진다." />
<p className={styles.oc__status} ref={statusRef} aria-live="polite">시연 중</p>
<button className={styles.oc__btn} ref={btnRef} type="button">멈춤</button>
<p className={styles.oc__caption}>노드 하나가 가리켜지면 그 노드에서 맨 위까지 부모를 거슬러 올라가며 박스와 선을 차례로 밝히고, 나머지는 흐려진다.</p>
</div>
</main>
);
}04 가리킨 노드에서 최상위까지 경로만 켜지는 강조 — 측정 누적면적 13.762% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · css 조직도 만들기 9가지 — 가지 폭을 계산해 부모를 자식 한가운데 놓는 트리 장면
이 데모가 속한 글
css 조직도 만들기 9가지 — 가지 폭을 계산해 부모를 자식 한가운데 놓는 트리 장면조직도는 박스와 선만 그리면 될 것 같지만, 가지 폭을 재지 않으면 형제 박스가 겹치고 가지가 접힐 때 남은 박스가 튀고 부모가 바뀌면 서로 다른 부모의 가로선이 겹친다 — 01번·03번·07번이 하나씩 푼다. 아래 이 글의 데모 전부 보기


