간트 차트 만들기 9종 — 의존 화살표·가장 긴 경로·연쇄 밀기를 계산으로
간트 차트 만들기는 할 일마다 시작일과 기간을 날짜 눈금 위 가로 막대로 놓고 앞뒤 순서를 화살표로 이어 일정 전체를 한 장에 보이게 하는 일이다. 디자인 시안이 사흘 늦어졌는데 막대 하나만 옮기면 일정표는 그날부터 거짓말을 한다.
막대를 그리는 것보다 어려운 일 — 날짜 하나가 밀릴 때 뒤 작업이 스스로 자리를 다시 잡는 일정표
01 gantt-bar-date-scale-spring-grow
02 gantt-dependency-elbow-route-flow
03 gantt-critical-path-slack-tail
04 gantt-cascade-push-depth-spring
05 gantt-anchored-zoom-tick-crossfade
06 gantt-baseline-actual-today-lag
07 gantt-resource-load-level-reflow
08 gantt-group-rollup-collapse-merge
09 gantt-hero-sprint-camera-milestone
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 날짜를 픽셀로 바꿔 시작일 순서로 뻗어 나오는 스프링 막대
- 02 앞 작업 끝에서 직각으로 꺾여 내려가는 의존 화살표
- 03 앞뒤로 두 번 훑어 여유 0인 길을 빛내는 가장 긴 경로
- 04 한 작업이 밀리면 화살표를 따라 차례로 밀려나는 뒤 작업
- 05 기준 날짜를 붙잡고 일·주·월로 늘었다 줄어드는 눈금
- 06 오늘 선이 지나가며 계획보다 늦은 막대를 드러내는 실적 비교
- 07 하루 정원을 넘는 날을 깎아 내는 인력 부하 평준화
- 08 하위 작업이 상위 막대 하나로 모여드는 묶음 접기
- 09 오늘 선을 카메라가 따라가며 마일스톤에 닿는 스프린트 첫 화면
아홉 장면은 같은 프로젝트 하나를 서로 다른 각도로 보여 준다. 요구 정리에서 시작해 디자인 시안·데이터 설계·화면 개발·서버 개발·기능 연동·문서 작성·통합 검수·오류 수정을 거쳐 출시로 끝나는 작업 열 개가 BASE_TASKS 한 곳에 있고, 05 는 그 일수를 5배로 늘려 105일짜리로, 08 은 출시를 뺀 아홉 개를 세 묶음으로, 09 는 시드로 만든 작업 240개로 바꿔 쓴다. 날짜는 기준일 0 부터 센 정수 일수로만 다루고, 화면에 찍히는 3월 11일 같은 숫자는 12개월 길이 표로 calendarOf 가 산수로 만든다. 데모 코드에는 Date 가 한 번도 나오지 않는다.
막대 차트를 스프링으로 키우는 편 의 막대는 값 하나만큼 높이가 자라지만, 여기 막대는 시작일과 기간이라는 두 값을 날짜 눈금에서 받고, 앞 작업이 바뀌면 그 두 값부터 다시 계산된다. 고급 차트 9종 의 워터폴 브리지가 앞 막대가 끝난 높이에서 다음 막대를 시작해 증감을 이어 붙인다면, 02 의 화살표는 「앞 작업이 끝나야 뒤 작업이 시작한다」 는 조건을 들고 있어서 그 조건이 지켜지는 길을 직접 고른다.
팀 일정표와 출시 계획 회의
팀 주간 회의에 띄우는 일정표라면 01 과 06 의 조합이 맞는다. 01 은 처음 열었을 때 시작일 순서대로 막대가 뻗어 「무엇이 먼저 시작하는지」 를 먼저 보여 주고, 06 은 오늘 선이 지나간 자리에서 계획보다 늦은 작업만 빗금으로 짙어진다. 출시일을 정하는 회의에서는 03 이 핵심이다. 여유가 0 인 작업 일곱 개가 한 줄로 빛나니 「이 중 하나라도 하루 늦으면 출시가 하루 늦는다」 는 말을 그림 하나로 대신할 수 있다.
인력 배분과 일정 변경 공지
사람 수가 정해진 팀이라면 07 이 먼저 쓰인다. 날마다 몇 명이 묶이는지 아래 그래프로 쌓고, 정원을 넘는 날이 생기면 여유가 큰 작업부터 하루씩 미뤄 출시일을 건드리지 않고 넘침을 없앤다. 외주 제작처럼 일정을 고객과 나누는 페이지에는 02 와 04 가 어울린다. 고객이 「왜 이 작업은 다음 주에 시작하나」 를 물으면 화살표 하나가 답이 되고, 시안이 늦어졌다는 공지에는 04 처럼 밀린 작업이 단계 순서로 움직이는 장면을 함께 붙일 수 있다.
간트 차트란 결국 무엇을 계산해서 보여 주는 표인가?
막대 하나는 숫자 셋을 품는다. 언제 시작하는지, 며칠 걸리는지, 그리고 늦어도 출시에 영향이 없는 날이 며칠인지(여유)다. 앞의 둘은 사람이 적지만 세 번째는 적을 수 없다. 여유는 이 작업 뒤에 무엇이 매달려 있는지를 끝까지 따라가야 나오는 값이라, 화살표 하나가 바뀌면 멀리 떨어진 막대의 여유까지 달라진다.
손으로 적은 좌표는 하루 만에 낡는다
간트 차트 예시를 그림 파일처럼 만들어 막대 좌표를 손으로 적어 두면, 디자인 시안 기간이 하루 바뀌는 순간 그 뒤 막대 여섯 개의 좌표를 사람이 다시 고쳐야 한다. 이 편의 아홉 장면은 좌표를 적지 않는다. 작업 이름·일수·앞 작업 번호·사람 수 네 가지만 적고, 시작일과 여유와 화면 x 는 그 네 가지에서 매번 계산한다.
그리기는 캔버스, 읽기는 숨긴 표
아홉 장면 모두 SVG 가 아니라 캔버스 2D 에 그린다. 09 처럼 막대 240개가 카메라를 따라 매 프레임 움직이는 장면도 같은 그리기 함수로 다루려는 선택으로 보인다. 대신 캔버스는 화면 읽기 프로그램이 내용을 읽을 수 없으니, 같은 일정을 작업 이름·시작일·끝나는 날·여유 일수 네 열의 숨긴 표로 한 벌 더 채운다.
간트 차트 만들기 9종 — 스프링 막대·꺾인 의존 화살표·가장 긴 경로·연쇄 밀기·기준 날짜 확대·계획 대비 실적·인력 부하 평준화·묶음 접기·스프린트 첫 화면
아홉 장면을 2초씩 찍어 잰 움직임
480×300 판을 2초 동안 24장 찍고 이웃한 두 장의 차이 23개로 잰 값이다. 240행이 카메라를 따라 흐르는 09 와 막대 열 개가 한꺼번에 뻗는 01 이 50% 를 넘고, 선 몇 가닥만 움직이는 02 는 2.3% 지만 23번 모두 화면이 바뀌었다.
| 장면 | 누적면적 | 움직인 프레임 | 강도 평균 |
|---|---|---|---|
| 01 스프링 막대 | 53.3% | 13/23 | 27.4 |
| 02 직각 의존 화살표 | 2.3% | 23/23 | 61.6 |
| 03 가장 긴 경로와 여유 꼬리 | 34.6% | 23/23 | 32.0 |
| 04 연쇄 밀기 | 40.3% | 11/23 | 36.1 |
| 05 기준 날짜 고정 확대 | 25.0% | 16/23 | 48.3 |
| 06 계획 대비 실적 | 14.0% | 23/23 | 93.2 |
| 07 인력 부하 평준화 | 10.8% | 11/23 | 39.8 |
| 08 묶음 접기 | 26.8% | 14/23 | 56.0 |
| 09 스프린트 첫 화면 | 52.9% | 23/23 | 59.7 |
01·04·05·07·08 은 움직인 프레임이 11~16 에 그친다. 이 다섯 장면은 시각표대로 한 번 자라거나 밀리거나 접힌 뒤 다음 변화(01 은 5.6초, 04 는 4.8초, 08 은 4.6초)까지 그대로 멈춰 있어서, 2초 촬영의 뒤쪽이 정지 화면으로 찍힌 것으로 보인다. 점선과 점이 쉬지 않고 흐르는 02, 훑는 선과 빛 띠가 이어지는 03, 오늘 선이 계속 움직이는 06·09 는 23번 모두 바뀌었다.
01날짜를 픽셀로 바꿔 시작일 순서로 뻗어 나오는 스프링 막대
막대 시작 x 는 dayX 가 102 + 일수 × 16 으로 정하므로 요구 정리는 102px 에서 48px 폭으로, 서버 개발은 198px 에서 112px 폭으로 서고, 시작일이 이른 순서(같은 날이면 위 행)로 70ms 씩 늦게 0.25초부터 0.88초까지 출발한다. 폭은 단단함 170·감쇠 14(감쇠비 0.537)인 스프링으로 0 에서 1 로 자라 제 길이를 13.0% 넘쳤다가 출발 650ms 뒤부터 ±1% 안에 머물고, 빨리 자랄수록 그림자가 최대 4px 까지 떠올라 막대가 들렸다가 내려앉는 듯 보인다. 프로젝트 관리 대시보드에 처음 들어온 사람은 숫자를 읽기 전에 막대가 뻗는 순서로 어떤 작업이 먼저 시작하는지부터 알게 된다.

<main class="stage">
<div class="cf is-demo" id="cf">
<canvas class="cf__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="날짜를 일수로 바꿔 하루 폭을 곱한 자리에서 시작일 순서대로 스프링처럼 뻗어 나오는 작업 막대 열 개."></canvas>
<table class="cf__sr" data-role="table"></table>
<button class="cf__pause" data-role="pause" type="button" aria-pressed="false">일시정지</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.cf {
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-cream;
}
.cf__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.cf__pause {
position: absolute;
right: $sp-3;
bottom: $sp-3;
padding: $sp-1 $sp-3;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.cf__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.cf__pause:hover {
transform: translateY(-2px);
}
.cf__pause:active {
transform: translateY(1px);
}
.cf__pause:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 일정에 무슨 일이 일어났는지 글자로. 시연 중에는 aria-live off, 사람이 처음 손대면 polite
.cf__status {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: calc(100% - 120px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
// 숨김 표 — 화면에서는 1px 로 접어 두고 화면 읽기 도구만 읽는다
.cf__sr {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
@media (max-width: 340px) {
.cf__pause,
.cf__status { font-size: 10px; padding: $sp-1 $sp-2; }
}
// 동작 줄이기 — 장면은 스크립트가 최종 배치 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.cf__pause { transition: none !important; }
.cf__pause:hover,
.cf__pause:active { transform: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cf {
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: #fff7e6;
}
.cf__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.cf__pause {
position: absolute;
right: 12px;
bottom: 12px;
padding: 4px 12px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.cf__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.cf__pause:hover {
transform: translateY(-2px);
}
.cf__pause:active {
transform: translateY(1px);
}
.cf__pause:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.cf__status {
position: absolute;
left: 12px;
bottom: 12px;
max-width: calc(100% - 120px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
.cf__sr {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
@media (max-width: 340px) {
.cf__pause,
.cf__status {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.cf__pause {
transition: none !important;
}
.cf__pause:hover,
.cf__pause:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("cf");
if (!root) return;
// 판 좌표 — 캔버스 안은 늘 480×300. 화면에 보이는 크기와 상관없이 계산은 이 좌표로 한다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
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); }
// 따라가기 비율 — 1 − e^(−rate·dt). 프레임 시간이 달라도 1초에 같은 만큼 다가간다
function follow(rate, dt) { return 1 - Math.exp(-rate * dt); }
// 시드 고정 난수 — 같은 시드면 같은 일정이 다시 나온다 (32비트 정수 섞기)
function makeRng(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function makeSpring(x) { return { x: x, v: 0, to: x }; }
const SPRING_SUBSTEP = 1 / 240;
function stepSpring(s, stiffness, damping, dt) {
let left = dt;
while (left > 1e-9) {
const h = Math.min(SPRING_SUBSTEP, left);
s.v += (stiffness * (s.to - s.x) - damping * s.v) * h;
s.x += s.v * h;
left -= h;
}
}
function snapSpring(s, x) { s.x = x; s.v = 0; s.to = x; }
// ── 날짜 = 정수 일수 ─────────────────────────────────────────
// 날짜 객체를 쓰지 않는다. 기준일을 0 으로 세는 정수 일수(day index)만 다루고, 화면의 월·일 숫자는 아래 월 길이 표로 산수해 만든다
const MONTH_DAYS = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
const YEAR_DAYS = 365;
// 기준일(일수 0) = 한 해의 70번째 날 → 표로 세면 3월 11일
const DAY_ZERO_OF_YEAR = 69;
function calendarOf(day) {
let r = ((day + DAY_ZERO_OF_YEAR) % YEAR_DAYS + YEAR_DAYS) % YEAR_DAYS;
let m = 0;
while (r >= MONTH_DAYS[m]) {
r -= MONTH_DAYS[m];
m += 1;
}
return [m + 1, r + 1];
}
const DAY_FMT = '{n}일차';
const WEEK_FMT = '{n}주차';
const MONTH_FMT = '{n}월';
const SLACK_FMT = '{n}일';
function dayText(day) { return DAY_FMT.replace('{n}', String(day + 1)); }
// 하루 차 계산의 함정 — 시각(시간 단위)끼리 빼서 24 로 나누면, 시계가 한 시간 당겨진 날이 끼었을 때 정수가 아니다(23시간짜리 하루).
// 그래서 바깥에서 들어온 시각은 반올림으로 정수 일수에 붙이고, 그다음부터는 정수끼리만 더하고 뺀다
const HOURS_PER_DAY = 24;
const CLOCK_SHIFT_HOURS = 1;
function hoursToDayIndex(hours) { return Math.round(hours / HOURS_PER_DAY); }
const BASE_TASKS = [
{ name: '요구 정리', dur: 3, deps: [], load: 2 },
{ name: '디자인 시안', dur: 4, deps: [0], load: 1 },
{ name: '데이터 설계', dur: 3, deps: [0], load: 1 },
{ name: '화면 개발', dur: 5, deps: [1], load: 2 },
{ name: '서버 개발', dur: 7, deps: [2], load: 1 },
{ name: '기능 연동', dur: 3, deps: [3, 4], load: 2 },
{ name: '문서 작성', dur: 3, deps: [1], load: 1 },
{ name: '통합 검수', dur: 3, deps: [5], load: 2 },
{ name: '오류 수정', dur: 2, deps: [7], load: 1 },
{ name: '출시', dur: 0, deps: [8, 6], load: 0 }
];
function copyTasks(ts) { return ts.map(function (t) { return { name: t.name, dur: t.dur, deps: t.deps.slice(), load: t.load }; }); }
function successorsOf(tasks) {
const out = tasks.map(function () { return []; });
tasks.forEach(function (t, i) { t.deps.forEach(function (p) { out[p].push(i); }); });
return out;
}
function sortByDeps(tasks) {
const succ = successorsOf(tasks);
const waiting = tasks.map(function (t) { return t.deps.length; });
const ready = [];
waiting.forEach(function (w, i) { if (w === 0) {
ready.push(i);
} });
const order = [];
while (ready.length) {
ready.sort(function (a, b) { return a - b; });
const i = ready.shift();
order.push(i);
succ[i].forEach(function (j) { waiting[j] -= 1; if (waiting[j] === 0) {
ready.push(j);
} });
}
const stuck = [];
waiting.forEach(function (w, i) { if (w > 0) {
stuck.push(i);
} });
return { order: order, stuck: stuck };
}
function sweepBoth(tasks, order, minStart) {
const n = tasks.length;
const es = new Array(n).fill(0);
const ef = new Array(n).fill(0);
for (const i of order) {
let s = minStart ? minStart[i] : 0;
for (const p of tasks[i].deps) {
s = Math.max(s, ef[p]);
}
es[i] = s;
ef[i] = s + tasks[i].dur;
}
let end = 0;
for (const i of order) {
end = Math.max(end, ef[i]);
}
const succ = successorsOf(tasks);
const ls = new Array(n).fill(0);
const lf = new Array(n).fill(0);
for (let k = order.length - 1; k >= 0; k -= 1) {
const i = order[k];
let f = end;
for (const j of succ[i]) {
f = Math.min(f, ls[j]);
}
lf[i] = f;
ls[i] = f - tasks[i].dur;
}
const slack = es.map(function (s, i) { return ls[i] - s; });
return { es: es, ef: ef, ls: ls, lf: lf, slack: slack, end: end };
}
function pushFrom(tasks, starts, from) {
const succ = successorsOf(tasks);
const out = starts.slice();
const depth = tasks.map(function () { return -1; });
depth[from] = 0;
const queue = [from];
let head = 0;
// 고리가 있어도 끝나도록 꺼내는 횟수에 상한을 둔다
const limit = tasks.length * tasks.length + 1;
while (head < queue.length && head < limit) {
const i = queue[head];
head += 1;
for (const j of succ[i]) {
let need = 0;
for (const p of tasks[j].deps) {
need = Math.max(need, out[p] + tasks[p].dur);
}
if (need > out[j]) {
out[j] = need;
depth[j] = Math.max(depth[j], depth[i] + 1);
queue.push(j);
}
}
}
return { starts: out, depth: depth };
}
// 날마다 걸린 일감 — 작업이 덮는 날(시작 ≤ 날 < 시작 + 일수)마다 사람 수를 더한다
function dailyLoad(tasks, starts, days) {
const load = new Array(days).fill(0);
tasks.forEach(function (t, i) {
for (let d = starts[i]; d < starts[i] + t.dur; d += 1) {
if (d >= 0 && d < days) {
load[d] += t.load;
}
}
});
return load;
}
// ── 직각 경로 ───────────────────────────────────────────────
// 앞 끝(ex, ey)에서 오른쪽으로 ROUTE_STUB 나간 뒤 아래로 꺾는다. 뒤 시작(sx)이 그 꺾인 자리 + ROUTE_STUB 보다 오른쪽이면 곧장 내려가 오른쪽으로 들어가고,
// 아니면 앞 행 바로 아래 통로(gapY)까지 내려가 왼쪽으로 돌아간 다음 뒤 시작보다 ROUTE_STUB 앞에서 내려가 오른쪽으로 들어간다
const ROUTE_STUB = 7;
function elbowRoute(ex, ey, sx, sy, gapY) {
const x1 = ex + ROUTE_STUB;
if (sx - ROUTE_STUB >= x1) {
return [ex, ey, x1, ey, x1, sy, sx, sy];
}
const x2 = sx - ROUTE_STUB;
return [ex, ey, x1, ey, x1, gapY, x2, gapY, x2, sy, sx, sy];
}
function routeLength(pts) {
let s = 0;
for (let i = 2; i < pts.length; i += 2) {
s += Math.hypot(pts[i] - pts[i - 2], pts[i + 1] - pts[i - 1]);
}
return s;
}
// 경로 위 거리 dist 자리의 점과 그 마디의 방향
function pointOnRoute(pts, dist) {
let left = Math.max(0, dist);
for (let i = 2; i < pts.length; i += 2) {
const dx = pts[i] - pts[i - 2];
const dy = pts[i + 1] - pts[i - 1];
const len = Math.hypot(dx, dy);
if (left <= len || i === pts.length - 2) {
const k = len > 0 ? Math.min(1, left / len) : 0;
return [pts[i - 2] + dx * k, pts[i - 1] + dy * k, len > 0 ? dx / len : 1, len > 0 ? dy / len : 0];
}
left -= len;
}
return [pts[0], pts[1], 1, 0];
}
// 경로를 앞에서부터 dist 만큼만 자른 점 목록 — 화살표가 그려져 나가는 장면에 쓴다
function cutRoute(pts, dist) {
const out = [pts[0], pts[1]];
let left = Math.max(0, dist);
for (let i = 2; i < pts.length; i += 2) {
const len = Math.hypot(pts[i] - pts[i - 2], pts[i + 1] - pts[i - 1]);
if (left >= len) {
out.push(pts[i], pts[i + 1]);
left -= len;
continue;
}
const k = len > 0 ? left / len : 0;
out.push(lerp(pts[i - 2], pts[i], k), lerp(pts[i - 1], pts[i + 1], k));
break;
}
return out;
}
// 장면 시계(초)와 상태 줄 글자 — 장면 계산이 고치고, 화면 쪽이 읽어 그린다
let sceneT = 0;
let simStatus = '';
// 파스텔 팔레트 — 크림 바탕 위 라벤더·민트·버터·산호 막대. 가장 긴 경로·오늘 선·글자는 진한 쪽으로 명도 차를 둔다
const PAPER = [251, 247, 238];
const PAPER_DIM = [243, 237, 224];
const GRID = [226, 218, 204];
const INK = [44, 38, 58];
const INK_SOFT = [112, 104, 126];
const CREAM = [255, 251, 242];
const LAVENDER = [190, 174, 236];
const MINT = [158, 214, 190];
const BUTTER = [244, 222, 146];
const CORAL = [244, 170, 150];
const SKY = [160, 204, 236];
const PINK = [242, 182, 206];
const PATH_DEEP = [86, 56, 168];
const TODAY_DEEP = [190, 72, 60];
const BAR_COLORS = [LAVENDER, MINT, BUTTER, CORAL, SKY, PINK];
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// 표 한 줄 — 작업·시작·끝·여유. 끝은 그 작업이 덮는 마지막 날(일수 0 짜리는 시작과 같은 날)
function tableRow(name, start, dur, slack) {
return [name, dayText(start), dayText(start + Math.max(0, dur - 1)), SLACK_FMT.replace('{n}', String(slack))];
}
const canvasEl = root.querySelector('[data-role="canvas"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const tableEl = root.querySelector('[data-role="table"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function clearStage() {
ctx.fillStyle = rgba(PAPER, 1);
ctx.fillRect(0, 0, W, H);
}
function label(s, x, y, c, a, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
function roundRectPath(x, y, w, h, r) {
const rr = Math.max(0, Math.min(r, w / 2, h / 2));
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.lineTo(x + w - rr, y);
ctx.arcTo(x + w, y, x + w, y + rr, rr);
ctx.lineTo(x + w, y + h - rr);
ctx.arcTo(x + w, y + h, x + w - rr, y + h, rr);
ctx.lineTo(x + rr, y + h);
ctx.arcTo(x, y + h, x, y + h - rr, rr);
ctx.lineTo(x, y + rr);
ctx.arcTo(x, y, x + rr, y, rr);
ctx.closePath();
}
// 막대 하나 — 둥근 사각형 채움 + 진한 테두리. 폭이 0 이하이면 그리지 않는다
function bar(x, y, w, h, fill, a, line) {
if (w <= 0.2 || a <= 0) {
return;
}
roundRectPath(x, y, w, h, 4);
ctx.fillStyle = rgba(fill, a);
ctx.fill();
if (line > 0) {
ctx.lineWidth = line;
ctx.strokeStyle = rgba(INK, 0.55 * a);
ctx.stroke();
}
}
// 마일스톤 마름모
function diamond(cx, cy, r, fill, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.moveTo(cx, cy - r);
ctx.lineTo(cx + r, cy);
ctx.lineTo(cx, cy + r);
ctx.lineTo(cx - r, cy);
ctx.closePath();
ctx.fillStyle = rgba(fill, a);
ctx.fill();
ctx.lineWidth = 1.5;
ctx.strokeStyle = rgba(INK, 0.8 * a);
ctx.stroke();
}
function polyline(pts, c, a, width) {
if (pts.length < 4) {
return;
}
ctx.beginPath();
ctx.moveTo(pts[0], pts[1]);
for (let i = 2; i < pts.length; i += 2) {
ctx.lineTo(pts[i], pts[i + 1]);
}
ctx.lineWidth = width;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function arrowHead(x, y, dx, dy, size, c, a) {
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x - dx * size - dy * size * 0.6, y - dy * size + dx * size * 0.6);
ctx.lineTo(x - dx * size + dy * size * 0.6, y - dy * size - dx * size * 0.6);
ctx.closePath();
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
// 빗금 — (x, y, w, h) 안에 45° 줄을 spacing 간격으로. 간격이 좁을수록 짙어 보인다
function hatch(x, y, w, h, spacing, c, a) {
if (w <= 0.5 || a <= 0) {
return;
}
ctx.save();
roundRectPath(x, y, w, h, 3);
ctx.clip();
ctx.beginPath();
for (let s = x - h; s < x + w; s += Math.max(2, spacing)) {
ctx.moveTo(s, y + h);
ctx.lineTo(s + h, y);
}
ctx.lineWidth = 1.6;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
ctx.restore();
}
// 시간 축 — 위 띠는 월 이름, 아래 띠는 일(월 안의 날 숫자)·주(주차) 층. 층마다 불투명도를 따로 받아 겹쳐 흐려지며 바뀌게 한다.
// 세로 격자도 같은 불투명도로 행 영역(top~bottom)에 긋는다. xOf 는 일수 → 화면 x
function timeAxis(xOf, dayW, d0, d1, left, right, top, bottom, ly) {
ctx.save();
ctx.beginPath();
ctx.rect(left, 0, right - left, H);
ctx.clip();
ctx.fillStyle = rgba(PAPER_DIM, 1);
ctx.fillRect(left, 0, right - left, top - 4);
for (let d = Math.floor(d0); d <= Math.ceil(d1); d += 1) {
const x = xOf(d);
const cal = calendarOf(d);
if (ly.day > 0.01) {
ctx.fillStyle = rgba(GRID, 0.7 * ly.day);
ctx.fillRect(x, top, 1, bottom - top);
if (dayW >= 13) {
label(String(cal[1]), x + dayW / 2, top - 11, INK_SOFT, ly.day, 9, 600, 'center');
}
}
// 주 층 — 글자가 겹치지 않을 만큼(36px) 주를 건너뛰며 적는다
const weekStep = 7 * dayW >= 36 ? 1 : (14 * dayW >= 36 ? 2 : 4);
if (ly.week > 0.01 && d % 7 === 0) {
ctx.fillStyle = rgba(GRID, ly.week);
ctx.fillRect(x, top, 1, bottom - top);
if ((d / 7) % weekStep === 0) {
label(WEEK_FMT.replace('{n}', String(d / 7 + 1)), x + 3, top - 11, INK_SOFT, ly.week, 9, 600, 'left');
}
}
if (cal[1] === 1) {
ctx.fillStyle = rgba(INK_SOFT, 0.18 + 0.4 * ly.month);
ctx.fillRect(x, 4, 1, bottom - 4);
label(MONTH_FMT.replace('{n}', String(cal[0])), x + 4, 11, INK, 0.85, 10, 700, 'left');
// 월 층 — 그달 칸 가운데에 그달 일수(월 길이 표에서)를 적는다
const len = MONTH_DAYS[cal[0] - 1];
if (ly.month > 0.01 && len * dayW >= 30) {
label(SLACK_FMT.replace('{n}', String(len)), x + len * dayW / 2, top - 11, INK_SOFT, ly.month, 9, 700, 'center');
}
}
else if (d === Math.floor(d0)) {
// 왼쪽 끝 월 이름 — 바로 오른쪽에 새 달이 시작하면 겹치므로 적지 않는다
const toNext = MONTH_DAYS[cal[0] - 1] - cal[1] + 1;
if (toNext * dayW >= 44) {
label(MONTH_FMT.replace('{n}', String(cal[0])), Math.max(x, left) + 4, 11, INK, 0.85, 10, 700, 'left');
}
}
}
ctx.restore();
}
// 왼쪽 작업 이름 칸
function rowLabel(name, y, a, bold) {
label(name, 10, y, INK, a, 11, bold ? 800 : 600, 'left');
}
function rowStripe(y, h, i, a) {
if (i % 2 === 1) {
ctx.fillStyle = rgba(PAPER_DIM, 0.75 * a);
ctx.fillRect(0, y, W, h);
}
}
// 움직이는 행 빛 — 그 행 막대의 스프링 속도(k = 0~1)만큼 행 전체가 옅은 라벤더로 밝아진다. 막대가 서면 사라진다
function rowGlow(y, h, k) {
if (k <= 0.01) {
return;
}
ctx.fillStyle = rgba(LAVENDER, 0.42 * clamp(k, 0, 1));
ctx.fillRect(0, y, W, h);
}
// 상태 줄 — 바뀔 때만 글자를 고친다
let shownStatus = '';
function pushStatus() {
if (!statusEl || simStatus === shownStatus) {
return;
}
shownStatus = simStatus;
statusEl.textContent = simStatus;
}
// 숨김 표 — 같은 일정을 작업·시작·끝·여유 열로 읽게. 화면에는 안 보이고 화면 읽기 도구가 읽는다
const TABLE_HEAD = ['작업 이름', '시작일', '끝나는 날', '여유 일수'];
function fillTable(caption, rows) {
if (!tableEl) {
return;
}
const tb = tableEl;
while (tb.firstChild) {
tb.removeChild(tb.firstChild);
}
const cap = document.createElement('caption');
cap.textContent = caption;
tb.appendChild(cap);
const thead = document.createElement('thead');
const hr = document.createElement('tr');
TABLE_HEAD.forEach(function (h) {
const th = document.createElement('th');
th.scope = 'col';
th.textContent = h;
hr.appendChild(th);
});
thead.appendChild(hr);
tb.appendChild(thead);
const body = document.createElement('tbody');
rows.forEach(function (r) {
const tr = document.createElement('tr');
r.forEach(function (c, k) {
const cell = document.createElement(k === 0 ? 'th' : 'td');
if (k === 0) {
cell.scope = 'row';
}
cell.textContent = c;
tr.appendChild(cell);
});
body.appendChild(tr);
});
tb.appendChild(body);
}
// 01 — 날짜를 픽셀로: x = 막대 칸 왼쪽 + 일수 × 하루 폭. 막대 길이 = 일수 × 하루 폭
const LABEL_W = 96;
const PLOT_X = LABEL_W + 6;
const DAY_WIDTH = 16;
const ROWS_TOP = 46;
const ROW_H = 21;
const BAR_H = 13;
// 폭 성장 스프링 — 0(없음) → 1(제 길이). 감쇠비 = BAR_DAMPING / (2√BAR_STIFFNESS)
const BAR_STIFFNESS = 170;
const BAR_DAMPING = 14;
// 시작일이 이른 순서로 한 막대씩 BAR_STAGGER_MS 늦게 출발
const BAR_STAGGER_MS = 70;
const GROW_START_S = 0.25;
const SHRINK_AT_S = 5.6;
const LOOP_S = 7;
const TABLE_CAPTION = '작업 일정표 — 시작일 순 성장';
const tasks01 = BASE_TASKS;
const plan01 = sweepBoth(tasks01, sortByDeps(tasks01).order, null);
function dayX(d) { return PLOT_X + d * DAY_WIDTH; }
// 시작일 순위 — 같은 날이면 위 행 먼저
const rank01 = (function () {
const idx = tasks01.map(function (_, i) { return i; });
idx.sort(function (a, b) { return plan01.es[a] - plan01.es[b] || a - b; });
const r = new Array(tasks01.length).fill(0);
idx.forEach(function (i, k) { r[i] = k; });
return r;
})();
const grow01 = tasks01.map(function () { return makeSpring(0); });
function growAt(i) { return GROW_START_S + rank01[i] * BAR_STAGGER_MS / 1000; }
function reset() { grow01.forEach(function (s) { snapSpring(s, 0); }); }
function update(dt) {
grow01.forEach(function (s, i) {
s.to = sceneT >= growAt(i) && sceneT < SHRINK_AT_S ? 1 : 0;
stepSpring(s, BAR_STIFFNESS, BAR_DAMPING, dt);
});
}
function restPose() { grow01.forEach(function (s) { snapSpring(s, 1); }); }
function tableRows() {
return tasks01.map(function (t, i) { return tableRow(t.name, plan01.es[i], t.dur, plan01.slack[i]); });
}
function draw() {
clearStage();
const bottom = ROWS_TOP + tasks01.length * ROW_H;
tasks01.forEach(function (_, i) {
rowStripe(ROWS_TOP + i * ROW_H, ROW_H, i, 1);
rowGlow(ROWS_TOP + i * ROW_H, ROW_H, Math.abs(grow01[i].v) / 6);
});
timeAxis(dayX, DAY_WIDTH, 0, 23, PLOT_X, W - 8, ROWS_TOP, bottom, { day: 1, week: 0, month: 0 });
// 날짜 → 픽셀 안내 — 막대가 출발하는 순간 그 시작일 칸(위 눈금)에서 막대 시작 x 로 세로선이 내려오고, 막대가 자라는 만큼 흐려진다
tasks01.forEach(function (_, i) {
if (sceneT < growAt(i) || sceneT >= SHRINK_AT_S) {
return;
}
const a = 1 - smooth(clamp(grow01[i].x, 0, 1));
if (a <= 0.01) {
return;
}
const x = dayX(plan01.es[i]);
ctx.fillStyle = rgba(PATH_DEEP, 0.28 * a);
ctx.fillRect(x, ROWS_TOP - 20, DAY_WIDTH, 18);
ctx.fillStyle = rgba(PATH_DEEP, 0.85 * a);
ctx.fillRect(x - 0.75, ROWS_TOP - 4, 1.5, i * ROW_H + ROW_H / 2 + 4);
});
tasks01.forEach(function (t, i) {
const y = ROWS_TOP + i * ROW_H;
const cy = y + ROW_H / 2;
rowLabel(t.name, cy, 1, false);
const g = grow01[i];
const k = Math.max(0, g.x);
const x0 = dayX(plan01.es[i]);
if (t.dur === 0) {
diamond(dayX(plan01.es[i]), cy, 7 * k, PATH_DEEP, Math.min(1, k * 1.5));
return;
}
const w = t.dur * DAY_WIDTH * k;
// 그림자 — 막대가 빨리 자랄수록(속도 클수록) 더 떠올라 그림자가 아래로 멀어지고 옅어진다
const lift = clamp(Math.abs(g.v) * 0.35, 0, 4);
bar(x0 + 1, y + (ROW_H - BAR_H) / 2 + 2 + lift, w, BAR_H, INK, 0.12 - lift * 0.015, 0);
bar(x0, y + (ROW_H - BAR_H) / 2, w, BAR_H, BAR_COLORS[i % BAR_COLORS.length], 1, 1.2);
// 길이 표시 — 제 길이에 거의 닿았을 때 나타난다
const a = smooth((k - 0.7) / 0.3);
if (a > 0) {
label(SLACK_FMT.replace('{n}', String(t.dur)), x0 + w + 4, cy, INK, a, 10, 700, 'left');
}
});
}
// 한 프레임에 넘기는 시간 상한(초) — 탭을 오래 숨겼다 돌아온 첫 프레임에도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 손대면 시연 표시를 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
// 리스너는 한 묶음으로 달고 정리할 때 한 번에 뗀다
const off = new AbortController();
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
if (statusEl) {
statusEl.setAttribute('aria-live', 'polite');
}
}
// 장면 시계를 dt 만큼 — 한 바퀴(LOOP_S)를 넘으면 처음 배치로 되돌려 다시 시연한다
function advance(dt) {
sceneT += dt;
if (sceneT >= LOOP_S) {
sceneT -= LOOP_S;
reset();
}
update(dt);
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
advance(dt);
}
draw();
pushStatus();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 시계 다시 맞추기 — 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 동작 줄이기 — 루프를 돌리지 않고 시연이 끝난 최종 배치 한 장만 그린다
function showRest() {
stopLoop();
sceneT = 0;
reset();
restPose();
draw();
pushStatus();
}
function play() {
sceneT = 0;
reset();
draw();
pushStatus();
if (!paused) {
startLoop();
}
}
pauseBtn.addEventListener('click', function () {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}, { signal: off.signal });
// 탭이 숨으면 루프를 멈추고, 돌아오면 시계를 다시 맞춰 시작한다 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 페이지를 연 채 바뀌면 — 켜지면 최종 배치로 멈추고, 꺼지면 시연을 처음부터
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
play();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
draw();
}, { signal: off.signal });
fitCanvas();
fillTable(TABLE_CAPTION, tableRows());
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// ScheduleSpringBars - 같은 항목 vanilla 판 index.html 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./ScheduleSpringBars.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function ScheduleSpringBars() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
type Pt = [number, number];
// 판 좌표 — 캔버스 안은 늘 480×300. 화면에 보이는 크기와 상관없이 계산은 이 좌표로 한다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
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); }
// 따라가기 비율 — 1 − e^(−rate·dt). 프레임 시간이 달라도 1초에 같은 만큼 다가간다
function follow(rate: number, dt: number): number { return 1 - Math.exp(-rate * dt); }
// 시드 고정 난수 — 같은 시드면 같은 일정이 다시 나온다 (32비트 정수 섞기)
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// ── 감쇠 스프링 ──────────────────────────────────────────────
// 가속 = 단단함 × (목표 − 위치) − 감쇠 × 속도. 속도를 먼저 고친 뒤 위치를 옮기는 적분을 1/240초 잔걸음으로 나눠 돌린다
type Spring = { x: number; v: number; to: number };
function makeSpring(x: number): Spring { return { x: x, v: 0, to: x }; }
const SPRING_SUBSTEP = 1 / 240;
function stepSpring(s: Spring, stiffness: number, damping: number, dt: number): void {
let left = dt;
while (left > 1e-9) {
const h = Math.min(SPRING_SUBSTEP, left);
s.v += (stiffness * (s.to - s.x) - damping * s.v) * h;
s.x += s.v * h;
left -= h;
}
}
function snapSpring(s: Spring, x: number): void { s.x = x; s.v = 0; s.to = x; }
// ── 날짜 = 정수 일수 ─────────────────────────────────────────
// 날짜 객체를 쓰지 않는다. 기준일을 0 으로 세는 정수 일수(day index)만 다루고, 화면의 월·일 숫자는 아래 월 길이 표로 산수해 만든다
const MONTH_DAYS = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
const YEAR_DAYS = 365;
// 기준일(일수 0) = 한 해의 70번째 날 → 표로 세면 3월 11일
const DAY_ZERO_OF_YEAR = 69;
function calendarOf(day: number): [number, number] {
let r = ((day + DAY_ZERO_OF_YEAR) % YEAR_DAYS + YEAR_DAYS) % YEAR_DAYS;
let m = 0;
while (r >= MONTH_DAYS[m]) { r -= MONTH_DAYS[m]; m += 1; }
return [m + 1, r + 1];
}
const DAY_FMT = '{n}일차';
const WEEK_FMT = '{n}주차';
const MONTH_FMT = '{n}월';
const SLACK_FMT = '{n}일';
function dayText(day: number): string { return DAY_FMT.replace('{n}', String(day + 1)); }
// 하루 차 계산의 함정 — 시각(시간 단위)끼리 빼서 24 로 나누면, 시계가 한 시간 당겨진 날이 끼었을 때 정수가 아니다(23시간짜리 하루).
// 그래서 바깥에서 들어온 시각은 반올림으로 정수 일수에 붙이고, 그다음부터는 정수끼리만 더하고 뺀다
const HOURS_PER_DAY = 24;
const CLOCK_SHIFT_HOURS = 1;
function hoursToDayIndex(hours: number): number { return Math.round(hours / HOURS_PER_DAY); }
// ── 작업과 의존 ──────────────────────────────────────────────
// dur 는 일수(0 이면 마일스톤), deps 는 끝나야 시작할 수 있는 앞 작업 번호, load 는 그 작업에 붙는 사람 수
type Task = { name: string; dur: number; deps: number[]; load: number };
const BASE_TASKS: Task[] = [
{ name: '요구 정리', dur: 3, deps: [], load: 2 },
{ name: '디자인 시안', dur: 4, deps: [0], load: 1 },
{ name: '데이터 설계', dur: 3, deps: [0], load: 1 },
{ name: '화면 개발', dur: 5, deps: [1], load: 2 },
{ name: '서버 개발', dur: 7, deps: [2], load: 1 },
{ name: '기능 연동', dur: 3, deps: [3, 4], load: 2 },
{ name: '문서 작성', dur: 3, deps: [1], load: 1 },
{ name: '통합 검수', dur: 3, deps: [5], load: 2 },
{ name: '오류 수정', dur: 2, deps: [7], load: 1 },
{ name: '출시', dur: 0, deps: [8, 6], load: 0 }
];
function copyTasks(ts: Task[]): Task[] { return ts.map(function (t: Task): Task { return { name: t.name, dur: t.dur, deps: t.deps.slice(), load: t.load }; }); }
function successorsOf(tasks: Task[]): number[][] {
const out: number[][] = tasks.map(function (): number[] { return []; });
tasks.forEach(function (t: Task, i: number): void { t.deps.forEach(function (p: number): void { out[p].push(i); }); });
return out;
}
// 선후 순서로 줄 세우기 — 앞 작업이 하나도 안 남은(들어오는 화살표 0) 작업부터 꺼내고, 꺼낼 때마다 뒤 작업의 남은 수를 하나 줄인다.
// 꺼낼 것이 사라졌는데 남은 작업이 있으면 그 작업들은 서로를 기다리는 고리(순환 의존)에 걸렸거나 그 뒤에 매달린 것이다 → stuck
type Order = { order: number[]; stuck: number[] };
function sortByDeps(tasks: Task[]): Order {
const succ = successorsOf(tasks);
const waiting = tasks.map(function (t: Task): number { return t.deps.length; });
const ready: number[] = [];
waiting.forEach(function (w: number, i: number): void { if (w === 0) { ready.push(i); } });
const order: number[] = [];
while (ready.length) {
ready.sort(function (a: number, b: number): number { return a - b; });
const i = ready.shift() as number;
order.push(i);
succ[i].forEach(function (j: number): void { waiting[j] -= 1; if (waiting[j] === 0) { ready.push(j); } });
}
const stuck: number[] = [];
waiting.forEach(function (w: number, i: number): void { if (w > 0) { stuck.push(i); } });
return { order: order, stuck: stuck };
}
// 앞뒤 두 번 훑기 — 앞에서: 가장 이른 시작 = 앞 작업 끝 중 가장 늦은 날(과 정해 둔 시작일 중 늦은 쪽).
// 뒤에서: 가장 늦은 끝 = 뒤 작업의 가장 늦은 시작 중 가장 이른 날(뒤 작업이 없으면 전체 끝). 여유 = 가장 늦은 시작 − 가장 이른 시작
type Layers = { day: number; week: number; month: number };
type Sweep = { es: number[]; ef: number[]; ls: number[]; lf: number[]; slack: number[]; end: number };
function sweepBoth(tasks: Task[], order: number[], minStart: number[] | null): Sweep {
const n = tasks.length;
const es: number[] = new Array(n).fill(0);
const ef: number[] = new Array(n).fill(0);
for (const i of order) {
let s = minStart ? minStart[i] : 0;
for (const p of tasks[i].deps) { s = Math.max(s, ef[p]); }
es[i] = s;
ef[i] = s + tasks[i].dur;
}
let end = 0;
for (const i of order) { end = Math.max(end, ef[i]); }
const succ = successorsOf(tasks);
const ls: number[] = new Array(n).fill(0);
const lf: number[] = new Array(n).fill(0);
for (let k = order.length - 1; k >= 0; k -= 1) {
const i = order[k];
let f = end;
for (const j of succ[i]) { f = Math.min(f, ls[j]); }
lf[i] = f;
ls[i] = f - tasks[i].dur;
}
const slack = es.map(function (s: number, i: number): number { return ls[i] - s; });
return { es: es, ef: ef, ls: ls, lf: lf, slack: slack, end: end };
}
// 밀기 전파 — 밀린 작업에서 가까운 뒤 작업부터 너비 우선으로 훑으며, 새 시작 = 앞 작업 끝 중 가장 늦은 날(지금보다 늦을 때만).
// depth 는 밀린 작업에서 몇 단계 뒤인지(-1 이면 여유가 흡수해 안 밀림). 시작이 바뀐 작업만 다시 큐에 넣는다
type Push = { starts: number[]; depth: number[] };
function pushFrom(tasks: Task[], starts: number[], from: number): Push {
const succ = successorsOf(tasks);
const out = starts.slice();
const depth: number[] = tasks.map(function (): number { return -1; });
depth[from] = 0;
const queue: number[] = [from];
let head = 0;
// 고리가 있어도 끝나도록 꺼내는 횟수에 상한을 둔다
const limit = tasks.length * tasks.length + 1;
while (head < queue.length && head < limit) {
const i = queue[head];
head += 1;
for (const j of succ[i]) {
let need = 0;
for (const p of tasks[j].deps) { need = Math.max(need, out[p] + tasks[p].dur); }
if (need > out[j]) {
out[j] = need;
depth[j] = Math.max(depth[j], depth[i] + 1);
queue.push(j);
}
}
}
return { starts: out, depth: depth };
}
// 날마다 걸린 일감 — 작업이 덮는 날(시작 ≤ 날 < 시작 + 일수)마다 사람 수를 더한다
function dailyLoad(tasks: Task[], starts: number[], days: number): number[] {
const load: number[] = new Array(days).fill(0);
tasks.forEach(function (t: Task, i: number): void {
for (let d = starts[i]; d < starts[i] + t.dur; d += 1) { if (d >= 0 && d < days) { load[d] += t.load; } }
});
return load;
}
// ── 직각 경로 ───────────────────────────────────────────────
// 앞 끝(ex, ey)에서 오른쪽으로 ROUTE_STUB 나간 뒤 아래로 꺾는다. 뒤 시작(sx)이 그 꺾인 자리 + ROUTE_STUB 보다 오른쪽이면 곧장 내려가 오른쪽으로 들어가고,
// 아니면 앞 행 바로 아래 통로(gapY)까지 내려가 왼쪽으로 돌아간 다음 뒤 시작보다 ROUTE_STUB 앞에서 내려가 오른쪽으로 들어간다
const ROUTE_STUB = 7;
function elbowRoute(ex: number, ey: number, sx: number, sy: number, gapY: number): number[] {
const x1 = ex + ROUTE_STUB;
if (sx - ROUTE_STUB >= x1) { return [ex, ey, x1, ey, x1, sy, sx, sy]; }
const x2 = sx - ROUTE_STUB;
return [ex, ey, x1, ey, x1, gapY, x2, gapY, x2, sy, sx, sy];
}
function routeLength(pts: number[]): number {
let s = 0;
for (let i = 2; i < pts.length; i += 2) { s += Math.hypot(pts[i] - pts[i - 2], pts[i + 1] - pts[i - 1]); }
return s;
}
// 경로 위 거리 dist 자리의 점과 그 마디의 방향
function pointOnRoute(pts: number[], dist: number): [number, number, number, number] {
let left = Math.max(0, dist);
for (let i = 2; i < pts.length; i += 2) {
const dx = pts[i] - pts[i - 2];
const dy = pts[i + 1] - pts[i - 1];
const len = Math.hypot(dx, dy);
if (left <= len || i === pts.length - 2) {
const k = len > 0 ? Math.min(1, left / len) : 0;
return [pts[i - 2] + dx * k, pts[i - 1] + dy * k, len > 0 ? dx / len : 1, len > 0 ? dy / len : 0];
}
left -= len;
}
return [pts[0], pts[1], 1, 0];
}
// 경로를 앞에서부터 dist 만큼만 자른 점 목록 — 화살표가 그려져 나가는 장면에 쓴다
function cutRoute(pts: number[], dist: number): number[] {
const out: number[] = [pts[0], pts[1]];
let left = Math.max(0, dist);
for (let i = 2; i < pts.length; i += 2) {
const len = Math.hypot(pts[i] - pts[i - 2], pts[i + 1] - pts[i - 1]);
if (left >= len) { out.push(pts[i], pts[i + 1]); left -= len; continue; }
const k = len > 0 ? left / len : 0;
out.push(lerp(pts[i - 2], pts[i], k), lerp(pts[i - 1], pts[i + 1], k));
break;
}
return out;
}
// 장면 시계(초)와 상태 줄 글자 — 장면 계산이 고치고, 화면 쪽이 읽어 그린다
let sceneT = 0;
let simStatus = '';
// 파스텔 팔레트 — 크림 바탕 위 라벤더·민트·버터·산호 막대. 가장 긴 경로·오늘 선·글자는 진한 쪽으로 명도 차를 둔다
const PAPER: RGB = [251, 247, 238];
const PAPER_DIM: RGB = [243, 237, 224];
const GRID: RGB = [226, 218, 204];
const INK: RGB = [44, 38, 58];
const INK_SOFT: RGB = [112, 104, 126];
const CREAM: RGB = [255, 251, 242];
const LAVENDER: RGB = [190, 174, 236];
const MINT: RGB = [158, 214, 190];
const BUTTER: RGB = [244, 222, 146];
const CORAL: RGB = [244, 170, 150];
const SKY: RGB = [160, 204, 236];
const PINK: RGB = [242, 182, 206];
const PATH_DEEP: RGB = [86, 56, 168];
const TODAY_DEEP: RGB = [190, 72, 60];
const BAR_COLORS: RGB[] = [LAVENDER, MINT, BUTTER, CORAL, SKY, PINK];
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// 표 한 줄 — 작업·시작·끝·여유. 끝은 그 작업이 덮는 마지막 날(일수 0 짜리는 시작과 같은 날)
function tableRow(name: string, start: number, dur: number, slack: number): string[] {
return [name, dayText(start), dayText(start + Math.max(0, dur - 1)), SLACK_FMT.replace('{n}', String(slack))];
}
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const tableEl = root.querySelector('[data-role="table"]') as HTMLTableElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function clearStage(): void {
ctx.fillStyle = rgba(PAPER, 1);
ctx.fillRect(0, 0, W, H);
}
function label(s: string, x: number, y: number, c: RGB, a: number, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
function roundRectPath(x: number, y: number, w: number, h: number, r: number): void {
const rr = Math.max(0, Math.min(r, w / 2, h / 2));
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.lineTo(x + w - rr, y);
ctx.arcTo(x + w, y, x + w, y + rr, rr);
ctx.lineTo(x + w, y + h - rr);
ctx.arcTo(x + w, y + h, x + w - rr, y + h, rr);
ctx.lineTo(x + rr, y + h);
ctx.arcTo(x, y + h, x, y + h - rr, rr);
ctx.lineTo(x, y + rr);
ctx.arcTo(x, y, x + rr, y, rr);
ctx.closePath();
}
// 막대 하나 — 둥근 사각형 채움 + 진한 테두리. 폭이 0 이하이면 그리지 않는다
function bar(x: number, y: number, w: number, h: number, fill: RGB, a: number, line: number): void {
if (w <= 0.2 || a <= 0) { return; }
roundRectPath(x, y, w, h, 4);
ctx.fillStyle = rgba(fill, a);
ctx.fill();
if (line > 0) {
ctx.lineWidth = line;
ctx.strokeStyle = rgba(INK, 0.55 * a);
ctx.stroke();
}
}
// 마일스톤 마름모
function diamond(cx: number, cy: number, r: number, fill: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.moveTo(cx, cy - r);
ctx.lineTo(cx + r, cy);
ctx.lineTo(cx, cy + r);
ctx.lineTo(cx - r, cy);
ctx.closePath();
ctx.fillStyle = rgba(fill, a);
ctx.fill();
ctx.lineWidth = 1.5;
ctx.strokeStyle = rgba(INK, 0.8 * a);
ctx.stroke();
}
function polyline(pts: number[], c: RGB, a: number, width: number): void {
if (pts.length < 4) { return; }
ctx.beginPath();
ctx.moveTo(pts[0], pts[1]);
for (let i = 2; i < pts.length; i += 2) { ctx.lineTo(pts[i], pts[i + 1]); }
ctx.lineWidth = width;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function arrowHead(x: number, y: number, dx: number, dy: number, size: number, c: RGB, a: number): void {
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x - dx * size - dy * size * 0.6, y - dy * size + dx * size * 0.6);
ctx.lineTo(x - dx * size + dy * size * 0.6, y - dy * size - dx * size * 0.6);
ctx.closePath();
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
// 빗금 — (x, y, w, h) 안에 45° 줄을 spacing 간격으로. 간격이 좁을수록 짙어 보인다
function hatch(x: number, y: number, w: number, h: number, spacing: number, c: RGB, a: number): void {
if (w <= 0.5 || a <= 0) { return; }
ctx.save();
roundRectPath(x, y, w, h, 3);
ctx.clip();
ctx.beginPath();
for (let s = x - h; s < x + w; s += Math.max(2, spacing)) {
ctx.moveTo(s, y + h);
ctx.lineTo(s + h, y);
}
ctx.lineWidth = 1.6;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
ctx.restore();
}
// 시간 축 — 위 띠는 월 이름, 아래 띠는 일(월 안의 날 숫자)·주(주차) 층. 층마다 불투명도를 따로 받아 겹쳐 흐려지며 바뀌게 한다.
// 세로 격자도 같은 불투명도로 행 영역(top~bottom)에 긋는다. xOf 는 일수 → 화면 x
function timeAxis(xOf: (d: number) => number, dayW: number, d0: number, d1: number, left: number, right: number, top: number, bottom: number, ly: Layers): void {
ctx.save();
ctx.beginPath();
ctx.rect(left, 0, right - left, H);
ctx.clip();
ctx.fillStyle = rgba(PAPER_DIM, 1);
ctx.fillRect(left, 0, right - left, top - 4);
for (let d = Math.floor(d0); d <= Math.ceil(d1); d += 1) {
const x = xOf(d);
const cal = calendarOf(d);
if (ly.day > 0.01) {
ctx.fillStyle = rgba(GRID, 0.7 * ly.day);
ctx.fillRect(x, top, 1, bottom - top);
if (dayW >= 13) { label(String(cal[1]), x + dayW / 2, top - 11, INK_SOFT, ly.day, 9, 600, 'center'); }
}
// 주 층 — 글자가 겹치지 않을 만큼(36px) 주를 건너뛰며 적는다
const weekStep = 7 * dayW >= 36 ? 1 : (14 * dayW >= 36 ? 2 : 4);
if (ly.week > 0.01 && d % 7 === 0) {
ctx.fillStyle = rgba(GRID, ly.week);
ctx.fillRect(x, top, 1, bottom - top);
if ((d / 7) % weekStep === 0) { label(WEEK_FMT.replace('{n}', String(d / 7 + 1)), x + 3, top - 11, INK_SOFT, ly.week, 9, 600, 'left'); }
}
if (cal[1] === 1) {
ctx.fillStyle = rgba(INK_SOFT, 0.18 + 0.4 * ly.month);
ctx.fillRect(x, 4, 1, bottom - 4);
label(MONTH_FMT.replace('{n}', String(cal[0])), x + 4, 11, INK, 0.85, 10, 700, 'left');
// 월 층 — 그달 칸 가운데에 그달 일수(월 길이 표에서)를 적는다
const len = MONTH_DAYS[cal[0] - 1];
if (ly.month > 0.01 && len * dayW >= 30) { label(SLACK_FMT.replace('{n}', String(len)), x + len * dayW / 2, top - 11, INK_SOFT, ly.month, 9, 700, 'center'); }
} else if (d === Math.floor(d0)) {
// 왼쪽 끝 월 이름 — 바로 오른쪽에 새 달이 시작하면 겹치므로 적지 않는다
const toNext = MONTH_DAYS[cal[0] - 1] - cal[1] + 1;
if (toNext * dayW >= 44) { label(MONTH_FMT.replace('{n}', String(cal[0])), Math.max(x, left) + 4, 11, INK, 0.85, 10, 700, 'left'); }
}
}
ctx.restore();
}
// 왼쪽 작업 이름 칸
function rowLabel(name: string, y: number, a: number, bold: boolean): void {
label(name, 10, y, INK, a, 11, bold ? 800 : 600, 'left');
}
function rowStripe(y: number, h: number, i: number, a: number): void {
if (i % 2 === 1) {
ctx.fillStyle = rgba(PAPER_DIM, 0.75 * a);
ctx.fillRect(0, y, W, h);
}
}
// 움직이는 행 빛 — 그 행 막대의 스프링 속도(k = 0~1)만큼 행 전체가 옅은 라벤더로 밝아진다. 막대가 서면 사라진다
function rowGlow(y: number, h: number, k: number): void {
if (k <= 0.01) { return; }
ctx.fillStyle = rgba(LAVENDER, 0.42 * clamp(k, 0, 1));
ctx.fillRect(0, y, W, h);
}
// 상태 줄 — 바뀔 때만 글자를 고친다
let shownStatus = '';
function pushStatus(): void {
if (!statusEl || simStatus === shownStatus) { return; }
shownStatus = simStatus;
statusEl.textContent = simStatus;
}
// 숨김 표 — 같은 일정을 작업·시작·끝·여유 열로 읽게. 화면에는 안 보이고 화면 읽기 도구가 읽는다
const TABLE_HEAD = ['작업 이름', '시작일', '끝나는 날', '여유 일수'];
function fillTable(caption: string, rows: string[][]): void {
if (!tableEl) { return; }
const tb = tableEl;
while (tb.firstChild) { tb.removeChild(tb.firstChild); }
const cap = document.createElement('caption');
cap.textContent = caption;
tb.appendChild(cap);
const thead = document.createElement('thead');
const hr = document.createElement('tr');
TABLE_HEAD.forEach(function (h: string): void {
const th = document.createElement('th');
th.scope = 'col';
th.textContent = h;
hr.appendChild(th);
});
thead.appendChild(hr);
tb.appendChild(thead);
const body = document.createElement('tbody');
rows.forEach(function (r: string[]): void {
const tr = document.createElement('tr');
r.forEach(function (c: string, k: number): void {
const cell = document.createElement(k === 0 ? 'th' : 'td');
if (k === 0) { (cell as HTMLTableCellElement).scope = 'row'; }
cell.textContent = c;
tr.appendChild(cell);
});
body.appendChild(tr);
});
tb.appendChild(body);
}
// 01 — 날짜를 픽셀로: x = 막대 칸 왼쪽 + 일수 × 하루 폭. 막대 길이 = 일수 × 하루 폭
const LABEL_W = 96;
const PLOT_X = LABEL_W + 6;
const DAY_WIDTH = 16;
const ROWS_TOP = 46;
const ROW_H = 21;
const BAR_H = 13;
// 폭 성장 스프링 — 0(없음) → 1(제 길이). 감쇠비 = BAR_DAMPING / (2√BAR_STIFFNESS)
const BAR_STIFFNESS = 170;
const BAR_DAMPING = 14;
// 시작일이 이른 순서로 한 막대씩 BAR_STAGGER_MS 늦게 출발
const BAR_STAGGER_MS = 70;
const GROW_START_S = 0.25;
const SHRINK_AT_S = 5.6;
const LOOP_S = 7;
const TABLE_CAPTION = '작업 일정표 — 시작일 순 성장';
const tasks01 = BASE_TASKS;
const plan01 = sweepBoth(tasks01, sortByDeps(tasks01).order, null);
function dayX(d: number): number { return PLOT_X + d * DAY_WIDTH; }
// 시작일 순위 — 같은 날이면 위 행 먼저
const rank01: number[] = (function (): number[] {
const idx = tasks01.map(function (_: Task, i: number): number { return i; });
idx.sort(function (a: number, b: number): number { return plan01.es[a] - plan01.es[b] || a - b; });
const r: number[] = new Array(tasks01.length).fill(0);
idx.forEach(function (i: number, k: number): void { r[i] = k; });
return r;
})();
const grow01: Spring[] = tasks01.map(function (): Spring { return makeSpring(0); });
function growAt(i: number): number { return GROW_START_S + rank01[i] * BAR_STAGGER_MS / 1000; }
function reset(): void { grow01.forEach(function (s: Spring): void { snapSpring(s, 0); }); }
function update(dt: number): void {
grow01.forEach(function (s: Spring, i: number): void {
s.to = sceneT >= growAt(i) && sceneT < SHRINK_AT_S ? 1 : 0;
stepSpring(s, BAR_STIFFNESS, BAR_DAMPING, dt);
});
}
function restPose(): void { grow01.forEach(function (s: Spring): void { snapSpring(s, 1); }); }
function tableRows(): string[][] {
return tasks01.map(function (t: Task, i: number): string[] { return tableRow(t.name, plan01.es[i], t.dur, plan01.slack[i]); });
}
function draw(): void {
clearStage();
const bottom = ROWS_TOP + tasks01.length * ROW_H;
tasks01.forEach(function (_: Task, i: number): void {
rowStripe(ROWS_TOP + i * ROW_H, ROW_H, i, 1);
rowGlow(ROWS_TOP + i * ROW_H, ROW_H, Math.abs(grow01[i].v) / 6);
});
timeAxis(dayX, DAY_WIDTH, 0, 23, PLOT_X, W - 8, ROWS_TOP, bottom, { day: 1, week: 0, month: 0 });
// 날짜 → 픽셀 안내 — 막대가 출발하는 순간 그 시작일 칸(위 눈금)에서 막대 시작 x 로 세로선이 내려오고, 막대가 자라는 만큼 흐려진다
tasks01.forEach(function (_: Task, i: number): void {
if (sceneT < growAt(i) || sceneT >= SHRINK_AT_S) { return; }
const a = 1 - smooth(clamp(grow01[i].x, 0, 1));
if (a <= 0.01) { return; }
const x = dayX(plan01.es[i]);
ctx.fillStyle = rgba(PATH_DEEP, 0.28 * a);
ctx.fillRect(x, ROWS_TOP - 20, DAY_WIDTH, 18);
ctx.fillStyle = rgba(PATH_DEEP, 0.85 * a);
ctx.fillRect(x - 0.75, ROWS_TOP - 4, 1.5, i * ROW_H + ROW_H / 2 + 4);
});
tasks01.forEach(function (t: Task, i: number): void {
const y = ROWS_TOP + i * ROW_H;
const cy = y + ROW_H / 2;
rowLabel(t.name, cy, 1, false);
const g = grow01[i];
const k = Math.max(0, g.x);
const x0 = dayX(plan01.es[i]);
if (t.dur === 0) {
diamond(dayX(plan01.es[i]), cy, 7 * k, PATH_DEEP, Math.min(1, k * 1.5));
return;
}
const w = t.dur * DAY_WIDTH * k;
// 그림자 — 막대가 빨리 자랄수록(속도 클수록) 더 떠올라 그림자가 아래로 멀어지고 옅어진다
const lift = clamp(Math.abs(g.v) * 0.35, 0, 4);
bar(x0 + 1, y + (ROW_H - BAR_H) / 2 + 2 + lift, w, BAR_H, INK, 0.12 - lift * 0.015, 0);
bar(x0, y + (ROW_H - BAR_H) / 2, w, BAR_H, BAR_COLORS[i % BAR_COLORS.length], 1, 1.2);
// 길이 표시 — 제 길이에 거의 닿았을 때 나타난다
const a = smooth((k - 0.7) / 0.3);
if (a > 0) { label(SLACK_FMT.replace('{n}', String(t.dur)), x0 + w + 4, cy, INK, a, 10, 700, 'left'); }
});
}
// 한 프레임에 넘기는 시간 상한(초) — 탭을 오래 숨겼다 돌아온 첫 프레임에도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 손대면 시연 표시를 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
// 리스너는 한 묶음으로 달고 정리할 때 한 번에 뗀다
const off = new AbortController();
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
if (statusEl) { statusEl.setAttribute('aria-live', 'polite'); }
}
// 장면 시계를 dt 만큼 — 한 바퀴(LOOP_S)를 넘으면 처음 배치로 되돌려 다시 시연한다
function advance(dt: number): void {
sceneT += dt;
if (sceneT >= LOOP_S) {
sceneT -= LOOP_S;
reset();
}
update(dt);
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) { advance(dt); }
draw();
pushStatus();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 시계 다시 맞추기 — 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 동작 줄이기 — 루프를 돌리지 않고 시연이 끝난 최종 배치 한 장만 그린다
function showRest(): void {
stopLoop();
sceneT = 0;
reset();
restPose();
draw();
pushStatus();
}
function play(): void {
sceneT = 0;
reset();
draw();
pushStatus();
if (!paused) { startLoop(); }
}
pauseBtn.addEventListener('click', function (): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}, { signal: off.signal });
// 탭이 숨으면 루프를 멈추고, 돌아오면 시계를 다시 맞춰 시작한다 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 페이지를 연 채 바뀌면 — 켜지면 최종 배치로 멈추고, 꺼지면 시연을 처음부터
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { play(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
draw();
}, { signal: off.signal });
fitCanvas();
fillTable(TABLE_CAPTION, tableRows());
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("cf") + " is-demo"} ref={rootRef}>
<canvas className={cx("cf__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label="날짜를 일수로 바꿔 하루 폭을 곱한 자리에서 시작일 순서대로 스프링처럼 뻗어 나오는 작업 막대 열 개."></canvas>
<table className={cx("cf__sr")} data-role="table"></table>
<button className={cx("cf__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
</div>
);
}01 · 날짜를 픽셀로 바꿔 시작일 순서로 뻗어 나오는 스프링 막대 — 측정 누적면적 53.288% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function dayX(d) { return PLOT_X + d * DAY_WIDTH; }
// 시작일 순위 — 같은 날이면 위 행 먼저
const rank01 = (function () {
const idx = tasks01.map(function (_, i) { return i; });
idx.sort(function (a, b) { return plan01.es[a] - plan01.es[b] || a - b; });
const r = new Array(tasks01.length).fill(0);
idx.forEach(function (i, k) { r[i] = k; });
return r;
})();
const grow01 = tasks01.map(function () { return makeSpring(0); });
function growAt(i) { return GROW_START_S + rank01[i] * BAR_STAGGER_MS / 1000; }
function reset() { grow01.forEach(function (s) { snapSpring(s, 0); }); }
function update(dt) {
grow01.forEach(function (s, i) {
s.to = sceneT >= growAt(i) && sceneT < SHRINK_AT_S ? 1 : 0;
stepSpring(s, BAR_STIFFNESS, BAR_DAMPING, dt);
});
}
function restPose() { grow01.forEach(function (s) { snapSpring(s, 1); }); }
02앞 작업 끝에서 직각으로 꺾여 내려가는 의존 화살표
elbowRoute 는 앞 작업 끝에서 7px 나간 자리보다 뒤 시작이 7px 이상 더 오른쪽이면 점 4개로 곧장 꺾여 들어가고, 아니면 앞 행 아래 통로까지 내려가 왼쪽으로 돌아 점 6개로 들어가는데, 의존 11개 가운데 6개가 이렇게 되돌아 들어가는 진한 보라 길이다. 화살표는 80ms 간격으로 출발해 360ms 동안 그려져 나가고, 다 그려진 길 위로 점선이 초당 26px, 점 하나가 초당 70px 로 흘러 길이 41~221px 인 경로마다 방향이 읽힌다. 외주 제작 일정을 고객과 나누는 페이지라면 「이 작업이 무엇을 기다리는지」 를 선 하나로 보여 줄 수 있다.

<main class="stage">
<div class="cf is-demo" id="cf">
<canvas class="cf__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="앞 작업 끝에서 직각으로 꺾여 뒤 작업 시작으로 들어가는 의존 화살표와 그 위를 흐르는 점선."></canvas>
<table class="cf__sr" data-role="table"></table>
<p class="cf__status" data-role="status" aria-live="off">의존 화살표를 그리는 중</p>
<button class="cf__pause" data-role="pause" type="button" aria-pressed="false">일시정지</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.cf {
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-cream;
}
.cf__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.cf__pause {
position: absolute;
right: $sp-3;
bottom: $sp-3;
padding: $sp-1 $sp-3;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.cf__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.cf__pause:hover {
transform: translateY(-2px);
}
.cf__pause:active {
transform: translateY(1px);
}
.cf__pause:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 일정에 무슨 일이 일어났는지 글자로. 시연 중에는 aria-live off, 사람이 처음 손대면 polite
.cf__status {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: calc(100% - 120px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
// 숨김 표 — 화면에서는 1px 로 접어 두고 화면 읽기 도구만 읽는다
.cf__sr {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
@media (max-width: 340px) {
.cf__pause,
.cf__status { font-size: 10px; padding: $sp-1 $sp-2; }
}
// 동작 줄이기 — 장면은 스크립트가 최종 배치 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.cf__pause { transition: none !important; }
.cf__pause:hover,
.cf__pause:active { transform: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cf {
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: #fff7e6;
}
.cf__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.cf__pause {
position: absolute;
right: 12px;
bottom: 12px;
padding: 4px 12px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.cf__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.cf__pause:hover {
transform: translateY(-2px);
}
.cf__pause:active {
transform: translateY(1px);
}
.cf__pause:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.cf__status {
position: absolute;
left: 12px;
bottom: 12px;
max-width: calc(100% - 120px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
.cf__sr {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
@media (max-width: 340px) {
.cf__pause,
.cf__status {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.cf__pause {
transition: none !important;
}
.cf__pause:hover,
.cf__pause:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("cf");
if (!root) return;
// 판 좌표 — 캔버스 안은 늘 480×300. 화면에 보이는 크기와 상관없이 계산은 이 좌표로 한다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
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); }
// 따라가기 비율 — 1 − e^(−rate·dt). 프레임 시간이 달라도 1초에 같은 만큼 다가간다
function follow(rate, dt) { return 1 - Math.exp(-rate * dt); }
// 시드 고정 난수 — 같은 시드면 같은 일정이 다시 나온다 (32비트 정수 섞기)
function makeRng(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function makeSpring(x) { return { x: x, v: 0, to: x }; }
const SPRING_SUBSTEP = 1 / 240;
function stepSpring(s, stiffness, damping, dt) {
let left = dt;
while (left > 1e-9) {
const h = Math.min(SPRING_SUBSTEP, left);
s.v += (stiffness * (s.to - s.x) - damping * s.v) * h;
s.x += s.v * h;
left -= h;
}
}
function snapSpring(s, x) { s.x = x; s.v = 0; s.to = x; }
// ── 날짜 = 정수 일수 ─────────────────────────────────────────
// 날짜 객체를 쓰지 않는다. 기준일을 0 으로 세는 정수 일수(day index)만 다루고, 화면의 월·일 숫자는 아래 월 길이 표로 산수해 만든다
const MONTH_DAYS = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
const YEAR_DAYS = 365;
// 기준일(일수 0) = 한 해의 70번째 날 → 표로 세면 3월 11일
const DAY_ZERO_OF_YEAR = 69;
function calendarOf(day) {
let r = ((day + DAY_ZERO_OF_YEAR) % YEAR_DAYS + YEAR_DAYS) % YEAR_DAYS;
let m = 0;
while (r >= MONTH_DAYS[m]) {
r -= MONTH_DAYS[m];
m += 1;
}
return [m + 1, r + 1];
}
const DAY_FMT = '{n}일차';
const WEEK_FMT = '{n}주차';
const MONTH_FMT = '{n}월';
const SLACK_FMT = '{n}일';
function dayText(day) { return DAY_FMT.replace('{n}', String(day + 1)); }
// 하루 차 계산의 함정 — 시각(시간 단위)끼리 빼서 24 로 나누면, 시계가 한 시간 당겨진 날이 끼었을 때 정수가 아니다(23시간짜리 하루).
// 그래서 바깥에서 들어온 시각은 반올림으로 정수 일수에 붙이고, 그다음부터는 정수끼리만 더하고 뺀다
const HOURS_PER_DAY = 24;
const CLOCK_SHIFT_HOURS = 1;
function hoursToDayIndex(hours) { return Math.round(hours / HOURS_PER_DAY); }
const BASE_TASKS = [
{ name: '요구 정리', dur: 3, deps: [], load: 2 },
{ name: '디자인 시안', dur: 4, deps: [0], load: 1 },
{ name: '데이터 설계', dur: 3, deps: [0], load: 1 },
{ name: '화면 개발', dur: 5, deps: [1], load: 2 },
{ name: '서버 개발', dur: 7, deps: [2], load: 1 },
{ name: '기능 연동', dur: 3, deps: [3, 4], load: 2 },
{ name: '문서 작성', dur: 3, deps: [1], load: 1 },
{ name: '통합 검수', dur: 3, deps: [5], load: 2 },
{ name: '오류 수정', dur: 2, deps: [7], load: 1 },
{ name: '출시', dur: 0, deps: [8, 6], load: 0 }
];
function copyTasks(ts) { return ts.map(function (t) { return { name: t.name, dur: t.dur, deps: t.deps.slice(), load: t.load }; }); }
function successorsOf(tasks) {
const out = tasks.map(function () { return []; });
tasks.forEach(function (t, i) { t.deps.forEach(function (p) { out[p].push(i); }); });
return out;
}
function sortByDeps(tasks) {
const succ = successorsOf(tasks);
const waiting = tasks.map(function (t) { return t.deps.length; });
const ready = [];
waiting.forEach(function (w, i) { if (w === 0) {
ready.push(i);
} });
const order = [];
while (ready.length) {
ready.sort(function (a, b) { return a - b; });
const i = ready.shift();
order.push(i);
succ[i].forEach(function (j) { waiting[j] -= 1; if (waiting[j] === 0) {
ready.push(j);
} });
}
const stuck = [];
waiting.forEach(function (w, i) { if (w > 0) {
stuck.push(i);
} });
return { order: order, stuck: stuck };
}
function sweepBoth(tasks, order, minStart) {
const n = tasks.length;
const es = new Array(n).fill(0);
const ef = new Array(n).fill(0);
for (const i of order) {
let s = minStart ? minStart[i] : 0;
for (const p of tasks[i].deps) {
s = Math.max(s, ef[p]);
}
es[i] = s;
ef[i] = s + tasks[i].dur;
}
let end = 0;
for (const i of order) {
end = Math.max(end, ef[i]);
}
const succ = successorsOf(tasks);
const ls = new Array(n).fill(0);
const lf = new Array(n).fill(0);
for (let k = order.length - 1; k >= 0; k -= 1) {
const i = order[k];
let f = end;
for (const j of succ[i]) {
f = Math.min(f, ls[j]);
}
lf[i] = f;
ls[i] = f - tasks[i].dur;
}
const slack = es.map(function (s, i) { return ls[i] - s; });
return { es: es, ef: ef, ls: ls, lf: lf, slack: slack, end: end };
}
function pushFrom(tasks, starts, from) {
const succ = successorsOf(tasks);
const out = starts.slice();
const depth = tasks.map(function () { return -1; });
depth[from] = 0;
const queue = [from];
let head = 0;
// 고리가 있어도 끝나도록 꺼내는 횟수에 상한을 둔다
const limit = tasks.length * tasks.length + 1;
while (head < queue.length && head < limit) {
const i = queue[head];
head += 1;
for (const j of succ[i]) {
let need = 0;
for (const p of tasks[j].deps) {
need = Math.max(need, out[p] + tasks[p].dur);
}
if (need > out[j]) {
out[j] = need;
depth[j] = Math.max(depth[j], depth[i] + 1);
queue.push(j);
}
}
}
return { starts: out, depth: depth };
}
// 날마다 걸린 일감 — 작업이 덮는 날(시작 ≤ 날 < 시작 + 일수)마다 사람 수를 더한다
function dailyLoad(tasks, starts, days) {
const load = new Array(days).fill(0);
tasks.forEach(function (t, i) {
for (let d = starts[i]; d < starts[i] + t.dur; d += 1) {
if (d >= 0 && d < days) {
load[d] += t.load;
}
}
});
return load;
}
// ── 직각 경로 ───────────────────────────────────────────────
// 앞 끝(ex, ey)에서 오른쪽으로 ROUTE_STUB 나간 뒤 아래로 꺾는다. 뒤 시작(sx)이 그 꺾인 자리 + ROUTE_STUB 보다 오른쪽이면 곧장 내려가 오른쪽으로 들어가고,
// 아니면 앞 행 바로 아래 통로(gapY)까지 내려가 왼쪽으로 돌아간 다음 뒤 시작보다 ROUTE_STUB 앞에서 내려가 오른쪽으로 들어간다
const ROUTE_STUB = 7;
function elbowRoute(ex, ey, sx, sy, gapY) {
const x1 = ex + ROUTE_STUB;
if (sx - ROUTE_STUB >= x1) {
return [ex, ey, x1, ey, x1, sy, sx, sy];
}
const x2 = sx - ROUTE_STUB;
return [ex, ey, x1, ey, x1, gapY, x2, gapY, x2, sy, sx, sy];
}
function routeLength(pts) {
let s = 0;
for (let i = 2; i < pts.length; i += 2) {
s += Math.hypot(pts[i] - pts[i - 2], pts[i + 1] - pts[i - 1]);
}
return s;
}
// 경로 위 거리 dist 자리의 점과 그 마디의 방향
function pointOnRoute(pts, dist) {
let left = Math.max(0, dist);
for (let i = 2; i < pts.length; i += 2) {
const dx = pts[i] - pts[i - 2];
const dy = pts[i + 1] - pts[i - 1];
const len = Math.hypot(dx, dy);
if (left <= len || i === pts.length - 2) {
const k = len > 0 ? Math.min(1, left / len) : 0;
return [pts[i - 2] + dx * k, pts[i - 1] + dy * k, len > 0 ? dx / len : 1, len > 0 ? dy / len : 0];
}
left -= len;
}
return [pts[0], pts[1], 1, 0];
}
// 경로를 앞에서부터 dist 만큼만 자른 점 목록 — 화살표가 그려져 나가는 장면에 쓴다
function cutRoute(pts, dist) {
const out = [pts[0], pts[1]];
let left = Math.max(0, dist);
for (let i = 2; i < pts.length; i += 2) {
const len = Math.hypot(pts[i] - pts[i - 2], pts[i + 1] - pts[i - 1]);
if (left >= len) {
out.push(pts[i], pts[i + 1]);
left -= len;
continue;
}
const k = len > 0 ? left / len : 0;
out.push(lerp(pts[i - 2], pts[i], k), lerp(pts[i - 1], pts[i + 1], k));
break;
}
return out;
}
// 장면 시계(초)와 상태 줄 글자 — 장면 계산이 고치고, 화면 쪽이 읽어 그린다
let sceneT = 0;
let simStatus = '';
// 파스텔 팔레트 — 크림 바탕 위 라벤더·민트·버터·산호 막대. 가장 긴 경로·오늘 선·글자는 진한 쪽으로 명도 차를 둔다
const PAPER = [251, 247, 238];
const PAPER_DIM = [243, 237, 224];
const GRID = [226, 218, 204];
const INK = [44, 38, 58];
const INK_SOFT = [112, 104, 126];
const CREAM = [255, 251, 242];
const LAVENDER = [190, 174, 236];
const MINT = [158, 214, 190];
const BUTTER = [244, 222, 146];
const CORAL = [244, 170, 150];
const SKY = [160, 204, 236];
const PINK = [242, 182, 206];
const PATH_DEEP = [86, 56, 168];
const TODAY_DEEP = [190, 72, 60];
const BAR_COLORS = [LAVENDER, MINT, BUTTER, CORAL, SKY, PINK];
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// 표 한 줄 — 작업·시작·끝·여유. 끝은 그 작업이 덮는 마지막 날(일수 0 짜리는 시작과 같은 날)
function tableRow(name, start, dur, slack) {
return [name, dayText(start), dayText(start + Math.max(0, dur - 1)), SLACK_FMT.replace('{n}', String(slack))];
}
const canvasEl = root.querySelector('[data-role="canvas"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const tableEl = root.querySelector('[data-role="table"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function clearStage() {
ctx.fillStyle = rgba(PAPER, 1);
ctx.fillRect(0, 0, W, H);
}
function label(s, x, y, c, a, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
function roundRectPath(x, y, w, h, r) {
const rr = Math.max(0, Math.min(r, w / 2, h / 2));
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.lineTo(x + w - rr, y);
ctx.arcTo(x + w, y, x + w, y + rr, rr);
ctx.lineTo(x + w, y + h - rr);
ctx.arcTo(x + w, y + h, x + w - rr, y + h, rr);
ctx.lineTo(x + rr, y + h);
ctx.arcTo(x, y + h, x, y + h - rr, rr);
ctx.lineTo(x, y + rr);
ctx.arcTo(x, y, x + rr, y, rr);
ctx.closePath();
}
// 막대 하나 — 둥근 사각형 채움 + 진한 테두리. 폭이 0 이하이면 그리지 않는다
function bar(x, y, w, h, fill, a, line) {
if (w <= 0.2 || a <= 0) {
return;
}
roundRectPath(x, y, w, h, 4);
ctx.fillStyle = rgba(fill, a);
ctx.fill();
if (line > 0) {
ctx.lineWidth = line;
ctx.strokeStyle = rgba(INK, 0.55 * a);
ctx.stroke();
}
}
// 마일스톤 마름모
function diamond(cx, cy, r, fill, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.moveTo(cx, cy - r);
ctx.lineTo(cx + r, cy);
ctx.lineTo(cx, cy + r);
ctx.lineTo(cx - r, cy);
ctx.closePath();
ctx.fillStyle = rgba(fill, a);
ctx.fill();
ctx.lineWidth = 1.5;
ctx.strokeStyle = rgba(INK, 0.8 * a);
ctx.stroke();
}
function polyline(pts, c, a, width) {
if (pts.length < 4) {
return;
}
ctx.beginPath();
ctx.moveTo(pts[0], pts[1]);
for (let i = 2; i < pts.length; i += 2) {
ctx.lineTo(pts[i], pts[i + 1]);
}
ctx.lineWidth = width;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function arrowHead(x, y, dx, dy, size, c, a) {
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x - dx * size - dy * size * 0.6, y - dy * size + dx * size * 0.6);
ctx.lineTo(x - dx * size + dy * size * 0.6, y - dy * size - dx * size * 0.6);
ctx.closePath();
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
// 빗금 — (x, y, w, h) 안에 45° 줄을 spacing 간격으로. 간격이 좁을수록 짙어 보인다
function hatch(x, y, w, h, spacing, c, a) {
if (w <= 0.5 || a <= 0) {
return;
}
ctx.save();
roundRectPath(x, y, w, h, 3);
ctx.clip();
ctx.beginPath();
for (let s = x - h; s < x + w; s += Math.max(2, spacing)) {
ctx.moveTo(s, y + h);
ctx.lineTo(s + h, y);
}
ctx.lineWidth = 1.6;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
ctx.restore();
}
// 시간 축 — 위 띠는 월 이름, 아래 띠는 일(월 안의 날 숫자)·주(주차) 층. 층마다 불투명도를 따로 받아 겹쳐 흐려지며 바뀌게 한다.
// 세로 격자도 같은 불투명도로 행 영역(top~bottom)에 긋는다. xOf 는 일수 → 화면 x
function timeAxis(xOf, dayW, d0, d1, left, right, top, bottom, ly) {
ctx.save();
ctx.beginPath();
ctx.rect(left, 0, right - left, H);
ctx.clip();
ctx.fillStyle = rgba(PAPER_DIM, 1);
ctx.fillRect(left, 0, right - left, top - 4);
for (let d = Math.floor(d0); d <= Math.ceil(d1); d += 1) {
const x = xOf(d);
const cal = calendarOf(d);
if (ly.day > 0.01) {
ctx.fillStyle = rgba(GRID, 0.7 * ly.day);
ctx.fillRect(x, top, 1, bottom - top);
if (dayW >= 13) {
label(String(cal[1]), x + dayW / 2, top - 11, INK_SOFT, ly.day, 9, 600, 'center');
}
}
// 주 층 — 글자가 겹치지 않을 만큼(36px) 주를 건너뛰며 적는다
const weekStep = 7 * dayW >= 36 ? 1 : (14 * dayW >= 36 ? 2 : 4);
if (ly.week > 0.01 && d % 7 === 0) {
ctx.fillStyle = rgba(GRID, ly.week);
ctx.fillRect(x, top, 1, bottom - top);
if ((d / 7) % weekStep === 0) {
label(WEEK_FMT.replace('{n}', String(d / 7 + 1)), x + 3, top - 11, INK_SOFT, ly.week, 9, 600, 'left');
}
}
if (cal[1] === 1) {
ctx.fillStyle = rgba(INK_SOFT, 0.18 + 0.4 * ly.month);
ctx.fillRect(x, 4, 1, bottom - 4);
label(MONTH_FMT.replace('{n}', String(cal[0])), x + 4, 11, INK, 0.85, 10, 700, 'left');
// 월 층 — 그달 칸 가운데에 그달 일수(월 길이 표에서)를 적는다
const len = MONTH_DAYS[cal[0] - 1];
if (ly.month > 0.01 && len * dayW >= 30) {
label(SLACK_FMT.replace('{n}', String(len)), x + len * dayW / 2, top - 11, INK_SOFT, ly.month, 9, 700, 'center');
}
}
else if (d === Math.floor(d0)) {
// 왼쪽 끝 월 이름 — 바로 오른쪽에 새 달이 시작하면 겹치므로 적지 않는다
const toNext = MONTH_DAYS[cal[0] - 1] - cal[1] + 1;
if (toNext * dayW >= 44) {
label(MONTH_FMT.replace('{n}', String(cal[0])), Math.max(x, left) + 4, 11, INK, 0.85, 10, 700, 'left');
}
}
}
ctx.restore();
}
// 왼쪽 작업 이름 칸
function rowLabel(name, y, a, bold) {
label(name, 10, y, INK, a, 11, bold ? 800 : 600, 'left');
}
function rowStripe(y, h, i, a) {
if (i % 2 === 1) {
ctx.fillStyle = rgba(PAPER_DIM, 0.75 * a);
ctx.fillRect(0, y, W, h);
}
}
// 움직이는 행 빛 — 그 행 막대의 스프링 속도(k = 0~1)만큼 행 전체가 옅은 라벤더로 밝아진다. 막대가 서면 사라진다
function rowGlow(y, h, k) {
if (k <= 0.01) {
return;
}
ctx.fillStyle = rgba(LAVENDER, 0.42 * clamp(k, 0, 1));
ctx.fillRect(0, y, W, h);
}
// 상태 줄 — 바뀔 때만 글자를 고친다
let shownStatus = '';
function pushStatus() {
if (!statusEl || simStatus === shownStatus) {
return;
}
shownStatus = simStatus;
statusEl.textContent = simStatus;
}
// 숨김 표 — 같은 일정을 작업·시작·끝·여유 열로 읽게. 화면에는 안 보이고 화면 읽기 도구가 읽는다
const TABLE_HEAD = ['작업 이름', '시작일', '끝나는 날', '여유 일수'];
function fillTable(caption, rows) {
if (!tableEl) {
return;
}
const tb = tableEl;
while (tb.firstChild) {
tb.removeChild(tb.firstChild);
}
const cap = document.createElement('caption');
cap.textContent = caption;
tb.appendChild(cap);
const thead = document.createElement('thead');
const hr = document.createElement('tr');
TABLE_HEAD.forEach(function (h) {
const th = document.createElement('th');
th.scope = 'col';
th.textContent = h;
hr.appendChild(th);
});
thead.appendChild(hr);
tb.appendChild(thead);
const body = document.createElement('tbody');
rows.forEach(function (r) {
const tr = document.createElement('tr');
r.forEach(function (c, k) {
const cell = document.createElement(k === 0 ? 'th' : 'td');
if (k === 0) {
cell.scope = 'row';
}
cell.textContent = c;
tr.appendChild(cell);
});
body.appendChild(tr);
});
tb.appendChild(body);
}
// 02 — 의존 화살표 직각 경로. 정해 둔 시작일(일부는 앞 작업 끝보다 하루 늦게)로 놓고, 화살표마다 elbowRoute 로 길을 고른다
const LABEL_W = 96;
const PLOT_X = LABEL_W + 6;
const DAY_WIDTH = 15;
const ROWS_TOP = 46;
const ROW_H = 21;
const BAR_H = 12;
// 정해 둔 시작일 — 앞 작업이 끝난 바로 그날 시작하는 작업은 되돌아 들어가는 길, 하루 이상 띄운 작업은 곧장 들어가는 길이 된다
const PLAN_STARTS = [0, 3, 4, 8, 7, 14, 9, 17, 20, 23];
// 화살표 하나가 그려져 나가는 시간과 다음 화살표까지의 간격
const ARROW_DRAW_MS = 360;
const ARROW_STAGGER_MS = 80;
const ARROW_START_S = 0.25;
// 점선 흐름 속도와 경로를 따라 도는 점의 속도(px/초) — 둘 다 경로 길이 위의 거리로 움직인다
const DASH_FLOW_SPEED = 26;
const DOT_SPEED = 70;
// 되돌아가는 의존(통합 검수 → 디자인 시안)이 잠깐 끼어드는 구간 — 줄 세우기가 남긴 작업을 순환으로 판정해 경고한다
const LOOP_EDGE = [7, 1];
const CYCLE_ON_S = 4.4;
const CYCLE_OFF_S = 6.6;
const LOOP_S = 7.6;
const TABLE_CAPTION = '작업 일정표 — 의존 관계와 정해 둔 시작일';
const STATUS_ROUTES = '의존 화살표 {n}개 — 통로로 되돌아 들어간 길 {m}개';
const STATUS_CYCLE = '순환 의존 — 줄 세우지 못한 작업 {n}개';
const tasks02 = BASE_TASKS;
const sweep02 = sweepBoth(tasks02, sortByDeps(tasks02).order, PLAN_STARTS);
function dayX(d) { return PLOT_X + d * DAY_WIDTH; }
function rowMid(i) { return ROWS_TOP + i * ROW_H + ROW_H / 2; }
// 의존마다 경로 하나 — 앞 작업 시작일이 이른 순서(같으면 위 행)로 그려 나간다
const edges02 = (function () {
const out = [];
tasks02.forEach(function (t, j) {
t.deps.forEach(function (p) {
const ex = dayX(PLAN_STARTS[p] + tasks02[p].dur);
const sx = dayX(PLAN_STARTS[j]) - (tasks02[j].dur === 0 ? 7 : 0);
const pts = elbowRoute(ex, rowMid(p), sx, rowMid(j), ROWS_TOP + (p + 1) * ROW_H);
out.push({ from: p, to: j, pts: pts, len: routeLength(pts), back: pts.length > 8 });
});
});
out.sort(function (a, b) { return PLAN_STARTS[a.from] - PLAN_STARTS[b.from] || a.from - b.from || a.to - b.to; });
return out;
})();
// 순환 판정 — 되돌아가는 의존 하나를 더한 사본을 줄 세워 남은 작업을 고른다
const cycleTasks02 = (function () {
const c = copyTasks(tasks02);
c[LOOP_EDGE[1]].deps.push(LOOP_EDGE[0]);
return c;
})();
const stuck02 = sortByDeps(cycleTasks02).stuck;
let cycleOn = 0;
function edgeDrawn(k) { return clamp((sceneT - ARROW_START_S - k * ARROW_STAGGER_MS / 1000) / (ARROW_DRAW_MS / 1000), 0, 1); }
function reset() { cycleOn = 0; }
function update(dt) {
const want = sceneT >= CYCLE_ON_S && sceneT < CYCLE_OFF_S ? 1 : 0;
cycleOn = lerp(cycleOn, want, follow(9, dt));
const back = edges02.filter(function (e) { return e.back; }).length;
simStatus = want ? STATUS_CYCLE.replace('{n}', String(stuck02.length)) : STATUS_ROUTES.replace('{n}', String(edges02.length)).replace('{m}', String(back));
}
function restPose() {
// 최종 배치 — 화살표가 다 그려진 자리. 동작 줄이기에서는 점선도 멈춘 채
sceneT = ARROW_START_S + edges02.length * ARROW_STAGGER_MS / 1000 + ARROW_DRAW_MS / 1000;
cycleOn = 0;
update(0);
}
function tableRows() {
return tasks02.map(function (t, i) { return tableRow(t.name, PLAN_STARTS[i], t.dur, sweep02.slack[i]); });
}
function draw() {
clearStage();
const bottom = ROWS_TOP + tasks02.length * ROW_H;
tasks02.forEach(function (_, i) { rowStripe(ROWS_TOP + i * ROW_H, ROW_H, i, 1); });
timeAxis(dayX, DAY_WIDTH, 0, 25, PLOT_X, W - 8, ROWS_TOP, bottom, { day: 1, week: 0, month: 0 });
tasks02.forEach(function (t, i) {
const cy = rowMid(i);
const isStuck = stuck02.indexOf(i) >= 0;
rowLabel(t.name, cy, 1, isStuck && cycleOn > 0.5);
const x0 = dayX(PLAN_STARTS[i]);
if (t.dur === 0) {
diamond(x0, cy, 7, PATH_DEEP, 1);
}
else {
bar(x0, cy - BAR_H / 2, t.dur * DAY_WIDTH, BAR_H, BAR_COLORS[i % BAR_COLORS.length], 1, 1.2);
}
// 순환에 걸린 작업 — 경고 빗금. 줄 세우기가 끝내 꺼내지 못한 작업이다
if (isStuck && cycleOn > 0.01) {
const w = Math.max(14, t.dur * DAY_WIDTH);
hatch(x0 - (t.dur === 0 ? 7 : 0), cy - BAR_H / 2 - 1, w, BAR_H + 2, 4, TODAY_DEEP, 0.75 * cycleOn);
}
});
// 화살표 — 앞에서부터 그려져 나가고, 다 그려진 길 위로 점선과 점이 흐른다
edges02.forEach(function (e, k) {
const f = edgeDrawn(k);
if (f <= 0) {
return;
}
const fade = 1 - 0.55 * cycleOn;
const part = cutRoute(e.pts, e.len * f);
polyline(part, e.back ? PATH_DEEP : INK_SOFT, (e.back ? 0.55 : 0.6) * fade, 1.6);
if (f >= 1) {
ctx.save();
ctx.setLineDash([5, 6]);
ctx.lineDashOffset = -sceneT * DASH_FLOW_SPEED;
polyline(e.pts, e.back ? PATH_DEEP : INK, 0.9 * fade, 1.8);
ctx.restore();
const p = pointOnRoute(e.pts, (sceneT * DOT_SPEED + k * 23) % e.len);
ctx.beginPath();
ctx.arc(p[0], p[1], 2.6, 0, TAU);
ctx.fillStyle = rgba(e.back ? PATH_DEEP : INK, fade);
ctx.fill();
}
const tip = pointOnRoute(part, routeLength(part));
arrowHead(tip[0], tip[1], tip[2], tip[3], 6, e.back ? PATH_DEEP : INK, (0.4 + 0.6 * f) * fade);
});
// 되돌아가는 의존 — 길을 고르지 않고 끊긴 점선과 ✕ 로만 표시한다
if (cycleOn > 0.01) {
const a = LOOP_EDGE[0];
const b = LOOP_EDGE[1];
const ex = dayX(PLAN_STARTS[a] + tasks02[a].dur);
const sx = dayX(PLAN_STARTS[b]);
const topY = ROWS_TOP + b * ROW_H + 2;
const pts = [ex, rowMid(a), ex + ROUTE_STUB * 2, rowMid(a), ex + ROUTE_STUB * 2, topY, sx - ROUTE_STUB, topY, sx - ROUTE_STUB, rowMid(b), sx, rowMid(b)];
ctx.save();
ctx.setLineDash([3, 4]);
polyline(pts, TODAY_DEEP, cycleOn, 2);
ctx.restore();
const m = pointOnRoute(pts, routeLength(pts) * 0.5);
ctx.beginPath();
ctx.arc(m[0], m[1], 8, 0, TAU);
ctx.fillStyle = rgba(CREAM, cycleOn);
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = rgba(TODAY_DEEP, cycleOn);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(m[0] - 3.5, m[1] - 3.5);
ctx.lineTo(m[0] + 3.5, m[1] + 3.5);
ctx.moveTo(m[0] + 3.5, m[1] - 3.5);
ctx.lineTo(m[0] - 3.5, m[1] + 3.5);
ctx.stroke();
}
}
// 한 프레임에 넘기는 시간 상한(초) — 탭을 오래 숨겼다 돌아온 첫 프레임에도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 손대면 시연 표시를 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
// 리스너는 한 묶음으로 달고 정리할 때 한 번에 뗀다
const off = new AbortController();
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
if (statusEl) {
statusEl.setAttribute('aria-live', 'polite');
}
}
// 장면 시계를 dt 만큼 — 한 바퀴(LOOP_S)를 넘으면 처음 배치로 되돌려 다시 시연한다
function advance(dt) {
sceneT += dt;
if (sceneT >= LOOP_S) {
sceneT -= LOOP_S;
reset();
}
update(dt);
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
advance(dt);
}
draw();
pushStatus();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 시계 다시 맞추기 — 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 동작 줄이기 — 루프를 돌리지 않고 시연이 끝난 최종 배치 한 장만 그린다
function showRest() {
stopLoop();
sceneT = 0;
reset();
restPose();
draw();
pushStatus();
}
function play() {
sceneT = 0;
reset();
draw();
pushStatus();
if (!paused) {
startLoop();
}
}
pauseBtn.addEventListener('click', function () {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}, { signal: off.signal });
// 탭이 숨으면 루프를 멈추고, 돌아오면 시계를 다시 맞춰 시작한다 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 페이지를 연 채 바뀌면 — 켜지면 최종 배치로 멈추고, 꺼지면 시연을 처음부터
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
play();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
draw();
}, { signal: off.signal });
fitCanvas();
fillTable(TABLE_CAPTION, tableRows());
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// DependencyElbowArrows - 같은 항목 vanilla 판 index.html 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./DependencyElbowArrows.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function DependencyElbowArrows() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
type Pt = [number, number];
// 판 좌표 — 캔버스 안은 늘 480×300. 화면에 보이는 크기와 상관없이 계산은 이 좌표로 한다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
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); }
// 따라가기 비율 — 1 − e^(−rate·dt). 프레임 시간이 달라도 1초에 같은 만큼 다가간다
function follow(rate: number, dt: number): number { return 1 - Math.exp(-rate * dt); }
// 시드 고정 난수 — 같은 시드면 같은 일정이 다시 나온다 (32비트 정수 섞기)
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// ── 감쇠 스프링 ──────────────────────────────────────────────
// 가속 = 단단함 × (목표 − 위치) − 감쇠 × 속도. 속도를 먼저 고친 뒤 위치를 옮기는 적분을 1/240초 잔걸음으로 나눠 돌린다
type Spring = { x: number; v: number; to: number };
function makeSpring(x: number): Spring { return { x: x, v: 0, to: x }; }
const SPRING_SUBSTEP = 1 / 240;
function stepSpring(s: Spring, stiffness: number, damping: number, dt: number): void {
let left = dt;
while (left > 1e-9) {
const h = Math.min(SPRING_SUBSTEP, left);
s.v += (stiffness * (s.to - s.x) - damping * s.v) * h;
s.x += s.v * h;
left -= h;
}
}
function snapSpring(s: Spring, x: number): void { s.x = x; s.v = 0; s.to = x; }
// ── 날짜 = 정수 일수 ─────────────────────────────────────────
// 날짜 객체를 쓰지 않는다. 기준일을 0 으로 세는 정수 일수(day index)만 다루고, 화면의 월·일 숫자는 아래 월 길이 표로 산수해 만든다
const MONTH_DAYS = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
const YEAR_DAYS = 365;
// 기준일(일수 0) = 한 해의 70번째 날 → 표로 세면 3월 11일
const DAY_ZERO_OF_YEAR = 69;
function calendarOf(day: number): [number, number] {
let r = ((day + DAY_ZERO_OF_YEAR) % YEAR_DAYS + YEAR_DAYS) % YEAR_DAYS;
let m = 0;
while (r >= MONTH_DAYS[m]) { r -= MONTH_DAYS[m]; m += 1; }
return [m + 1, r + 1];
}
const DAY_FMT = '{n}일차';
const WEEK_FMT = '{n}주차';
const MONTH_FMT = '{n}월';
const SLACK_FMT = '{n}일';
function dayText(day: number): string { return DAY_FMT.replace('{n}', String(day + 1)); }
// 하루 차 계산의 함정 — 시각(시간 단위)끼리 빼서 24 로 나누면, 시계가 한 시간 당겨진 날이 끼었을 때 정수가 아니다(23시간짜리 하루).
// 그래서 바깥에서 들어온 시각은 반올림으로 정수 일수에 붙이고, 그다음부터는 정수끼리만 더하고 뺀다
const HOURS_PER_DAY = 24;
const CLOCK_SHIFT_HOURS = 1;
function hoursToDayIndex(hours: number): number { return Math.round(hours / HOURS_PER_DAY); }
// ── 작업과 의존 ──────────────────────────────────────────────
// dur 는 일수(0 이면 마일스톤), deps 는 끝나야 시작할 수 있는 앞 작업 번호, load 는 그 작업에 붙는 사람 수
type Task = { name: string; dur: number; deps: number[]; load: number };
const BASE_TASKS: Task[] = [
{ name: '요구 정리', dur: 3, deps: [], load: 2 },
{ name: '디자인 시안', dur: 4, deps: [0], load: 1 },
{ name: '데이터 설계', dur: 3, deps: [0], load: 1 },
{ name: '화면 개발', dur: 5, deps: [1], load: 2 },
{ name: '서버 개발', dur: 7, deps: [2], load: 1 },
{ name: '기능 연동', dur: 3, deps: [3, 4], load: 2 },
{ name: '문서 작성', dur: 3, deps: [1], load: 1 },
{ name: '통합 검수', dur: 3, deps: [5], load: 2 },
{ name: '오류 수정', dur: 2, deps: [7], load: 1 },
{ name: '출시', dur: 0, deps: [8, 6], load: 0 }
];
function copyTasks(ts: Task[]): Task[] { return ts.map(function (t: Task): Task { return { name: t.name, dur: t.dur, deps: t.deps.slice(), load: t.load }; }); }
function successorsOf(tasks: Task[]): number[][] {
const out: number[][] = tasks.map(function (): number[] { return []; });
tasks.forEach(function (t: Task, i: number): void { t.deps.forEach(function (p: number): void { out[p].push(i); }); });
return out;
}
// 선후 순서로 줄 세우기 — 앞 작업이 하나도 안 남은(들어오는 화살표 0) 작업부터 꺼내고, 꺼낼 때마다 뒤 작업의 남은 수를 하나 줄인다.
// 꺼낼 것이 사라졌는데 남은 작업이 있으면 그 작업들은 서로를 기다리는 고리(순환 의존)에 걸렸거나 그 뒤에 매달린 것이다 → stuck
type Order = { order: number[]; stuck: number[] };
function sortByDeps(tasks: Task[]): Order {
const succ = successorsOf(tasks);
const waiting = tasks.map(function (t: Task): number { return t.deps.length; });
const ready: number[] = [];
waiting.forEach(function (w: number, i: number): void { if (w === 0) { ready.push(i); } });
const order: number[] = [];
while (ready.length) {
ready.sort(function (a: number, b: number): number { return a - b; });
const i = ready.shift() as number;
order.push(i);
succ[i].forEach(function (j: number): void { waiting[j] -= 1; if (waiting[j] === 0) { ready.push(j); } });
}
const stuck: number[] = [];
waiting.forEach(function (w: number, i: number): void { if (w > 0) { stuck.push(i); } });
return { order: order, stuck: stuck };
}
// 앞뒤 두 번 훑기 — 앞에서: 가장 이른 시작 = 앞 작업 끝 중 가장 늦은 날(과 정해 둔 시작일 중 늦은 쪽).
// 뒤에서: 가장 늦은 끝 = 뒤 작업의 가장 늦은 시작 중 가장 이른 날(뒤 작업이 없으면 전체 끝). 여유 = 가장 늦은 시작 − 가장 이른 시작
type Layers = { day: number; week: number; month: number };
type Sweep = { es: number[]; ef: number[]; ls: number[]; lf: number[]; slack: number[]; end: number };
function sweepBoth(tasks: Task[], order: number[], minStart: number[] | null): Sweep {
const n = tasks.length;
const es: number[] = new Array(n).fill(0);
const ef: number[] = new Array(n).fill(0);
for (const i of order) {
let s = minStart ? minStart[i] : 0;
for (const p of tasks[i].deps) { s = Math.max(s, ef[p]); }
es[i] = s;
ef[i] = s + tasks[i].dur;
}
let end = 0;
for (const i of order) { end = Math.max(end, ef[i]); }
const succ = successorsOf(tasks);
const ls: number[] = new Array(n).fill(0);
const lf: number[] = new Array(n).fill(0);
for (let k = order.length - 1; k >= 0; k -= 1) {
const i = order[k];
let f = end;
for (const j of succ[i]) { f = Math.min(f, ls[j]); }
lf[i] = f;
ls[i] = f - tasks[i].dur;
}
const slack = es.map(function (s: number, i: number): number { return ls[i] - s; });
return { es: es, ef: ef, ls: ls, lf: lf, slack: slack, end: end };
}
// 밀기 전파 — 밀린 작업에서 가까운 뒤 작업부터 너비 우선으로 훑으며, 새 시작 = 앞 작업 끝 중 가장 늦은 날(지금보다 늦을 때만).
// depth 는 밀린 작업에서 몇 단계 뒤인지(-1 이면 여유가 흡수해 안 밀림). 시작이 바뀐 작업만 다시 큐에 넣는다
type Push = { starts: number[]; depth: number[] };
function pushFrom(tasks: Task[], starts: number[], from: number): Push {
const succ = successorsOf(tasks);
const out = starts.slice();
const depth: number[] = tasks.map(function (): number { return -1; });
depth[from] = 0;
const queue: number[] = [from];
let head = 0;
// 고리가 있어도 끝나도록 꺼내는 횟수에 상한을 둔다
const limit = tasks.length * tasks.length + 1;
while (head < queue.length && head < limit) {
const i = queue[head];
head += 1;
for (const j of succ[i]) {
let need = 0;
for (const p of tasks[j].deps) { need = Math.max(need, out[p] + tasks[p].dur); }
if (need > out[j]) {
out[j] = need;
depth[j] = Math.max(depth[j], depth[i] + 1);
queue.push(j);
}
}
}
return { starts: out, depth: depth };
}
// 날마다 걸린 일감 — 작업이 덮는 날(시작 ≤ 날 < 시작 + 일수)마다 사람 수를 더한다
function dailyLoad(tasks: Task[], starts: number[], days: number): number[] {
const load: number[] = new Array(days).fill(0);
tasks.forEach(function (t: Task, i: number): void {
for (let d = starts[i]; d < starts[i] + t.dur; d += 1) { if (d >= 0 && d < days) { load[d] += t.load; } }
});
return load;
}
// ── 직각 경로 ───────────────────────────────────────────────
// 앞 끝(ex, ey)에서 오른쪽으로 ROUTE_STUB 나간 뒤 아래로 꺾는다. 뒤 시작(sx)이 그 꺾인 자리 + ROUTE_STUB 보다 오른쪽이면 곧장 내려가 오른쪽으로 들어가고,
// 아니면 앞 행 바로 아래 통로(gapY)까지 내려가 왼쪽으로 돌아간 다음 뒤 시작보다 ROUTE_STUB 앞에서 내려가 오른쪽으로 들어간다
const ROUTE_STUB = 7;
function elbowRoute(ex: number, ey: number, sx: number, sy: number, gapY: number): number[] {
const x1 = ex + ROUTE_STUB;
if (sx - ROUTE_STUB >= x1) { return [ex, ey, x1, ey, x1, sy, sx, sy]; }
const x2 = sx - ROUTE_STUB;
return [ex, ey, x1, ey, x1, gapY, x2, gapY, x2, sy, sx, sy];
}
function routeLength(pts: number[]): number {
let s = 0;
for (let i = 2; i < pts.length; i += 2) { s += Math.hypot(pts[i] - pts[i - 2], pts[i + 1] - pts[i - 1]); }
return s;
}
// 경로 위 거리 dist 자리의 점과 그 마디의 방향
function pointOnRoute(pts: number[], dist: number): [number, number, number, number] {
let left = Math.max(0, dist);
for (let i = 2; i < pts.length; i += 2) {
const dx = pts[i] - pts[i - 2];
const dy = pts[i + 1] - pts[i - 1];
const len = Math.hypot(dx, dy);
if (left <= len || i === pts.length - 2) {
const k = len > 0 ? Math.min(1, left / len) : 0;
return [pts[i - 2] + dx * k, pts[i - 1] + dy * k, len > 0 ? dx / len : 1, len > 0 ? dy / len : 0];
}
left -= len;
}
return [pts[0], pts[1], 1, 0];
}
// 경로를 앞에서부터 dist 만큼만 자른 점 목록 — 화살표가 그려져 나가는 장면에 쓴다
function cutRoute(pts: number[], dist: number): number[] {
const out: number[] = [pts[0], pts[1]];
let left = Math.max(0, dist);
for (let i = 2; i < pts.length; i += 2) {
const len = Math.hypot(pts[i] - pts[i - 2], pts[i + 1] - pts[i - 1]);
if (left >= len) { out.push(pts[i], pts[i + 1]); left -= len; continue; }
const k = len > 0 ? left / len : 0;
out.push(lerp(pts[i - 2], pts[i], k), lerp(pts[i - 1], pts[i + 1], k));
break;
}
return out;
}
// 장면 시계(초)와 상태 줄 글자 — 장면 계산이 고치고, 화면 쪽이 읽어 그린다
let sceneT = 0;
let simStatus = '';
// 파스텔 팔레트 — 크림 바탕 위 라벤더·민트·버터·산호 막대. 가장 긴 경로·오늘 선·글자는 진한 쪽으로 명도 차를 둔다
const PAPER: RGB = [251, 247, 238];
const PAPER_DIM: RGB = [243, 237, 224];
const GRID: RGB = [226, 218, 204];
const INK: RGB = [44, 38, 58];
const INK_SOFT: RGB = [112, 104, 126];
const CREAM: RGB = [255, 251, 242];
const LAVENDER: RGB = [190, 174, 236];
const MINT: RGB = [158, 214, 190];
const BUTTER: RGB = [244, 222, 146];
const CORAL: RGB = [244, 170, 150];
const SKY: RGB = [160, 204, 236];
const PINK: RGB = [242, 182, 206];
const PATH_DEEP: RGB = [86, 56, 168];
const TODAY_DEEP: RGB = [190, 72, 60];
const BAR_COLORS: RGB[] = [LAVENDER, MINT, BUTTER, CORAL, SKY, PINK];
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// 표 한 줄 — 작업·시작·끝·여유. 끝은 그 작업이 덮는 마지막 날(일수 0 짜리는 시작과 같은 날)
function tableRow(name: string, start: number, dur: number, slack: number): string[] {
return [name, dayText(start), dayText(start + Math.max(0, dur - 1)), SLACK_FMT.replace('{n}', String(slack))];
}
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const tableEl = root.querySelector('[data-role="table"]') as HTMLTableElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function clearStage(): void {
ctx.fillStyle = rgba(PAPER, 1);
ctx.fillRect(0, 0, W, H);
}
function label(s: string, x: number, y: number, c: RGB, a: number, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
function roundRectPath(x: number, y: number, w: number, h: number, r: number): void {
const rr = Math.max(0, Math.min(r, w / 2, h / 2));
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.lineTo(x + w - rr, y);
ctx.arcTo(x + w, y, x + w, y + rr, rr);
ctx.lineTo(x + w, y + h - rr);
ctx.arcTo(x + w, y + h, x + w - rr, y + h, rr);
ctx.lineTo(x + rr, y + h);
ctx.arcTo(x, y + h, x, y + h - rr, rr);
ctx.lineTo(x, y + rr);
ctx.arcTo(x, y, x + rr, y, rr);
ctx.closePath();
}
// 막대 하나 — 둥근 사각형 채움 + 진한 테두리. 폭이 0 이하이면 그리지 않는다
function bar(x: number, y: number, w: number, h: number, fill: RGB, a: number, line: number): void {
if (w <= 0.2 || a <= 0) { return; }
roundRectPath(x, y, w, h, 4);
ctx.fillStyle = rgba(fill, a);
ctx.fill();
if (line > 0) {
ctx.lineWidth = line;
ctx.strokeStyle = rgba(INK, 0.55 * a);
ctx.stroke();
}
}
// 마일스톤 마름모
function diamond(cx: number, cy: number, r: number, fill: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.moveTo(cx, cy - r);
ctx.lineTo(cx + r, cy);
ctx.lineTo(cx, cy + r);
ctx.lineTo(cx - r, cy);
ctx.closePath();
ctx.fillStyle = rgba(fill, a);
ctx.fill();
ctx.lineWidth = 1.5;
ctx.strokeStyle = rgba(INK, 0.8 * a);
ctx.stroke();
}
function polyline(pts: number[], c: RGB, a: number, width: number): void {
if (pts.length < 4) { return; }
ctx.beginPath();
ctx.moveTo(pts[0], pts[1]);
for (let i = 2; i < pts.length; i += 2) { ctx.lineTo(pts[i], pts[i + 1]); }
ctx.lineWidth = width;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function arrowHead(x: number, y: number, dx: number, dy: number, size: number, c: RGB, a: number): void {
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x - dx * size - dy * size * 0.6, y - dy * size + dx * size * 0.6);
ctx.lineTo(x - dx * size + dy * size * 0.6, y - dy * size - dx * size * 0.6);
ctx.closePath();
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
// 빗금 — (x, y, w, h) 안에 45° 줄을 spacing 간격으로. 간격이 좁을수록 짙어 보인다
function hatch(x: number, y: number, w: number, h: number, spacing: number, c: RGB, a: number): void {
if (w <= 0.5 || a <= 0) { return; }
ctx.save();
roundRectPath(x, y, w, h, 3);
ctx.clip();
ctx.beginPath();
for (let s = x - h; s < x + w; s += Math.max(2, spacing)) {
ctx.moveTo(s, y + h);
ctx.lineTo(s + h, y);
}
ctx.lineWidth = 1.6;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
ctx.restore();
}
// 시간 축 — 위 띠는 월 이름, 아래 띠는 일(월 안의 날 숫자)·주(주차) 층. 층마다 불투명도를 따로 받아 겹쳐 흐려지며 바뀌게 한다.
// 세로 격자도 같은 불투명도로 행 영역(top~bottom)에 긋는다. xOf 는 일수 → 화면 x
function timeAxis(xOf: (d: number) => number, dayW: number, d0: number, d1: number, left: number, right: number, top: number, bottom: number, ly: Layers): void {
ctx.save();
ctx.beginPath();
ctx.rect(left, 0, right - left, H);
ctx.clip();
ctx.fillStyle = rgba(PAPER_DIM, 1);
ctx.fillRect(left, 0, right - left, top - 4);
for (let d = Math.floor(d0); d <= Math.ceil(d1); d += 1) {
const x = xOf(d);
const cal = calendarOf(d);
if (ly.day > 0.01) {
ctx.fillStyle = rgba(GRID, 0.7 * ly.day);
ctx.fillRect(x, top, 1, bottom - top);
if (dayW >= 13) { label(String(cal[1]), x + dayW / 2, top - 11, INK_SOFT, ly.day, 9, 600, 'center'); }
}
// 주 층 — 글자가 겹치지 않을 만큼(36px) 주를 건너뛰며 적는다
const weekStep = 7 * dayW >= 36 ? 1 : (14 * dayW >= 36 ? 2 : 4);
if (ly.week > 0.01 && d % 7 === 0) {
ctx.fillStyle = rgba(GRID, ly.week);
ctx.fillRect(x, top, 1, bottom - top);
if ((d / 7) % weekStep === 0) { label(WEEK_FMT.replace('{n}', String(d / 7 + 1)), x + 3, top - 11, INK_SOFT, ly.week, 9, 600, 'left'); }
}
if (cal[1] === 1) {
ctx.fillStyle = rgba(INK_SOFT, 0.18 + 0.4 * ly.month);
ctx.fillRect(x, 4, 1, bottom - 4);
label(MONTH_FMT.replace('{n}', String(cal[0])), x + 4, 11, INK, 0.85, 10, 700, 'left');
// 월 층 — 그달 칸 가운데에 그달 일수(월 길이 표에서)를 적는다
const len = MONTH_DAYS[cal[0] - 1];
if (ly.month > 0.01 && len * dayW >= 30) { label(SLACK_FMT.replace('{n}', String(len)), x + len * dayW / 2, top - 11, INK_SOFT, ly.month, 9, 700, 'center'); }
} else if (d === Math.floor(d0)) {
// 왼쪽 끝 월 이름 — 바로 오른쪽에 새 달이 시작하면 겹치므로 적지 않는다
const toNext = MONTH_DAYS[cal[0] - 1] - cal[1] + 1;
if (toNext * dayW >= 44) { label(MONTH_FMT.replace('{n}', String(cal[0])), Math.max(x, left) + 4, 11, INK, 0.85, 10, 700, 'left'); }
}
}
ctx.restore();
}
// 왼쪽 작업 이름 칸
function rowLabel(name: string, y: number, a: number, bold: boolean): void {
label(name, 10, y, INK, a, 11, bold ? 800 : 600, 'left');
}
function rowStripe(y: number, h: number, i: number, a: number): void {
if (i % 2 === 1) {
ctx.fillStyle = rgba(PAPER_DIM, 0.75 * a);
ctx.fillRect(0, y, W, h);
}
}
// 움직이는 행 빛 — 그 행 막대의 스프링 속도(k = 0~1)만큼 행 전체가 옅은 라벤더로 밝아진다. 막대가 서면 사라진다
function rowGlow(y: number, h: number, k: number): void {
if (k <= 0.01) { return; }
ctx.fillStyle = rgba(LAVENDER, 0.42 * clamp(k, 0, 1));
ctx.fillRect(0, y, W, h);
}
// 상태 줄 — 바뀔 때만 글자를 고친다
let shownStatus = '';
function pushStatus(): void {
if (!statusEl || simStatus === shownStatus) { return; }
shownStatus = simStatus;
statusEl.textContent = simStatus;
}
// 숨김 표 — 같은 일정을 작업·시작·끝·여유 열로 읽게. 화면에는 안 보이고 화면 읽기 도구가 읽는다
const TABLE_HEAD = ['작업 이름', '시작일', '끝나는 날', '여유 일수'];
function fillTable(caption: string, rows: string[][]): void {
if (!tableEl) { return; }
const tb = tableEl;
while (tb.firstChild) { tb.removeChild(tb.firstChild); }
const cap = document.createElement('caption');
cap.textContent = caption;
tb.appendChild(cap);
const thead = document.createElement('thead');
const hr = document.createElement('tr');
TABLE_HEAD.forEach(function (h: string): void {
const th = document.createElement('th');
th.scope = 'col';
th.textContent = h;
hr.appendChild(th);
});
thead.appendChild(hr);
tb.appendChild(thead);
const body = document.createElement('tbody');
rows.forEach(function (r: string[]): void {
const tr = document.createElement('tr');
r.forEach(function (c: string, k: number): void {
const cell = document.createElement(k === 0 ? 'th' : 'td');
if (k === 0) { (cell as HTMLTableCellElement).scope = 'row'; }
cell.textContent = c;
tr.appendChild(cell);
});
body.appendChild(tr);
});
tb.appendChild(body);
}
// 02 — 의존 화살표 직각 경로. 정해 둔 시작일(일부는 앞 작업 끝보다 하루 늦게)로 놓고, 화살표마다 elbowRoute 로 길을 고른다
const LABEL_W = 96;
const PLOT_X = LABEL_W + 6;
const DAY_WIDTH = 15;
const ROWS_TOP = 46;
const ROW_H = 21;
const BAR_H = 12;
// 정해 둔 시작일 — 앞 작업이 끝난 바로 그날 시작하는 작업은 되돌아 들어가는 길, 하루 이상 띄운 작업은 곧장 들어가는 길이 된다
const PLAN_STARTS = [0, 3, 4, 8, 7, 14, 9, 17, 20, 23];
// 화살표 하나가 그려져 나가는 시간과 다음 화살표까지의 간격
const ARROW_DRAW_MS = 360;
const ARROW_STAGGER_MS = 80;
const ARROW_START_S = 0.25;
// 점선 흐름 속도와 경로를 따라 도는 점의 속도(px/초) — 둘 다 경로 길이 위의 거리로 움직인다
const DASH_FLOW_SPEED = 26;
const DOT_SPEED = 70;
// 되돌아가는 의존(통합 검수 → 디자인 시안)이 잠깐 끼어드는 구간 — 줄 세우기가 남긴 작업을 순환으로 판정해 경고한다
const LOOP_EDGE: [number, number] = [7, 1];
const CYCLE_ON_S = 4.4;
const CYCLE_OFF_S = 6.6;
const LOOP_S = 7.6;
const TABLE_CAPTION = '작업 일정표 — 의존 관계와 정해 둔 시작일';
const STATUS_ROUTES = '의존 화살표 {n}개 — 통로로 되돌아 들어간 길 {m}개';
const STATUS_CYCLE = '순환 의존 — 줄 세우지 못한 작업 {n}개';
const tasks02 = BASE_TASKS;
const sweep02 = sweepBoth(tasks02, sortByDeps(tasks02).order, PLAN_STARTS);
function dayX(d: number): number { return PLOT_X + d * DAY_WIDTH; }
function rowMid(i: number): number { return ROWS_TOP + i * ROW_H + ROW_H / 2; }
type Edge = { from: number; to: number; pts: number[]; len: number; back: boolean };
// 의존마다 경로 하나 — 앞 작업 시작일이 이른 순서(같으면 위 행)로 그려 나간다
const edges02: Edge[] = (function (): Edge[] {
const out: Edge[] = [];
tasks02.forEach(function (t: Task, j: number): void {
t.deps.forEach(function (p: number): void {
const ex = dayX(PLAN_STARTS[p] + tasks02[p].dur);
const sx = dayX(PLAN_STARTS[j]) - (tasks02[j].dur === 0 ? 7 : 0);
const pts = elbowRoute(ex, rowMid(p), sx, rowMid(j), ROWS_TOP + (p + 1) * ROW_H);
out.push({ from: p, to: j, pts: pts, len: routeLength(pts), back: pts.length > 8 });
});
});
out.sort(function (a: Edge, b: Edge): number { return PLAN_STARTS[a.from] - PLAN_STARTS[b.from] || a.from - b.from || a.to - b.to; });
return out;
})();
// 순환 판정 — 되돌아가는 의존 하나를 더한 사본을 줄 세워 남은 작업을 고른다
const cycleTasks02: Task[] = (function (): Task[] {
const c = copyTasks(tasks02);
c[LOOP_EDGE[1]].deps.push(LOOP_EDGE[0]);
return c;
})();
const stuck02 = sortByDeps(cycleTasks02).stuck;
let cycleOn = 0;
function edgeDrawn(k: number): number { return clamp((sceneT - ARROW_START_S - k * ARROW_STAGGER_MS / 1000) / (ARROW_DRAW_MS / 1000), 0, 1); }
function reset(): void { cycleOn = 0; }
function update(dt: number): void {
const want = sceneT >= CYCLE_ON_S && sceneT < CYCLE_OFF_S ? 1 : 0;
cycleOn = lerp(cycleOn, want, follow(9, dt));
const back = edges02.filter(function (e: Edge): boolean { return e.back; }).length;
simStatus = want ? STATUS_CYCLE.replace('{n}', String(stuck02.length)) : STATUS_ROUTES.replace('{n}', String(edges02.length)).replace('{m}', String(back));
}
function restPose(): void {
// 최종 배치 — 화살표가 다 그려진 자리. 동작 줄이기에서는 점선도 멈춘 채
sceneT = ARROW_START_S + edges02.length * ARROW_STAGGER_MS / 1000 + ARROW_DRAW_MS / 1000;
cycleOn = 0;
update(0);
}
function tableRows(): string[][] {
return tasks02.map(function (t: Task, i: number): string[] { return tableRow(t.name, PLAN_STARTS[i], t.dur, sweep02.slack[i]); });
}
function draw(): void {
clearStage();
const bottom = ROWS_TOP + tasks02.length * ROW_H;
tasks02.forEach(function (_: Task, i: number): void { rowStripe(ROWS_TOP + i * ROW_H, ROW_H, i, 1); });
timeAxis(dayX, DAY_WIDTH, 0, 25, PLOT_X, W - 8, ROWS_TOP, bottom, { day: 1, week: 0, month: 0 });
tasks02.forEach(function (t: Task, i: number): void {
const cy = rowMid(i);
const isStuck = stuck02.indexOf(i) >= 0;
rowLabel(t.name, cy, 1, isStuck && cycleOn > 0.5);
const x0 = dayX(PLAN_STARTS[i]);
if (t.dur === 0) {
diamond(x0, cy, 7, PATH_DEEP, 1);
} else {
bar(x0, cy - BAR_H / 2, t.dur * DAY_WIDTH, BAR_H, BAR_COLORS[i % BAR_COLORS.length], 1, 1.2);
}
// 순환에 걸린 작업 — 경고 빗금. 줄 세우기가 끝내 꺼내지 못한 작업이다
if (isStuck && cycleOn > 0.01) {
const w = Math.max(14, t.dur * DAY_WIDTH);
hatch(x0 - (t.dur === 0 ? 7 : 0), cy - BAR_H / 2 - 1, w, BAR_H + 2, 4, TODAY_DEEP, 0.75 * cycleOn);
}
});
// 화살표 — 앞에서부터 그려져 나가고, 다 그려진 길 위로 점선과 점이 흐른다
edges02.forEach(function (e: Edge, k: number): void {
const f = edgeDrawn(k);
if (f <= 0) { return; }
const fade = 1 - 0.55 * cycleOn;
const part = cutRoute(e.pts, e.len * f);
polyline(part, e.back ? PATH_DEEP : INK_SOFT, (e.back ? 0.55 : 0.6) * fade, 1.6);
if (f >= 1) {
ctx.save();
ctx.setLineDash([5, 6]);
ctx.lineDashOffset = -sceneT * DASH_FLOW_SPEED;
polyline(e.pts, e.back ? PATH_DEEP : INK, 0.9 * fade, 1.8);
ctx.restore();
const p = pointOnRoute(e.pts, (sceneT * DOT_SPEED + k * 23) % e.len);
ctx.beginPath();
ctx.arc(p[0], p[1], 2.6, 0, TAU);
ctx.fillStyle = rgba(e.back ? PATH_DEEP : INK, fade);
ctx.fill();
}
const tip = pointOnRoute(part, routeLength(part));
arrowHead(tip[0], tip[1], tip[2], tip[3], 6, e.back ? PATH_DEEP : INK, (0.4 + 0.6 * f) * fade);
});
// 되돌아가는 의존 — 길을 고르지 않고 끊긴 점선과 ✕ 로만 표시한다
if (cycleOn > 0.01) {
const a = LOOP_EDGE[0];
const b = LOOP_EDGE[1];
const ex = dayX(PLAN_STARTS[a] + tasks02[a].dur);
const sx = dayX(PLAN_STARTS[b]);
const topY = ROWS_TOP + b * ROW_H + 2;
const pts = [ex, rowMid(a), ex + ROUTE_STUB * 2, rowMid(a), ex + ROUTE_STUB * 2, topY, sx - ROUTE_STUB, topY, sx - ROUTE_STUB, rowMid(b), sx, rowMid(b)];
ctx.save();
ctx.setLineDash([3, 4]);
polyline(pts, TODAY_DEEP, cycleOn, 2);
ctx.restore();
const m = pointOnRoute(pts, routeLength(pts) * 0.5);
ctx.beginPath();
ctx.arc(m[0], m[1], 8, 0, TAU);
ctx.fillStyle = rgba(CREAM, cycleOn);
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = rgba(TODAY_DEEP, cycleOn);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(m[0] - 3.5, m[1] - 3.5);
ctx.lineTo(m[0] + 3.5, m[1] + 3.5);
ctx.moveTo(m[0] + 3.5, m[1] - 3.5);
ctx.lineTo(m[0] - 3.5, m[1] + 3.5);
ctx.stroke();
}
}
// 한 프레임에 넘기는 시간 상한(초) — 탭을 오래 숨겼다 돌아온 첫 프레임에도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 손대면 시연 표시를 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
// 리스너는 한 묶음으로 달고 정리할 때 한 번에 뗀다
const off = new AbortController();
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
if (statusEl) { statusEl.setAttribute('aria-live', 'polite'); }
}
// 장면 시계를 dt 만큼 — 한 바퀴(LOOP_S)를 넘으면 처음 배치로 되돌려 다시 시연한다
function advance(dt: number): void {
sceneT += dt;
if (sceneT >= LOOP_S) {
sceneT -= LOOP_S;
reset();
}
update(dt);
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) { advance(dt); }
draw();
pushStatus();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 시계 다시 맞추기 — 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 동작 줄이기 — 루프를 돌리지 않고 시연이 끝난 최종 배치 한 장만 그린다
function showRest(): void {
stopLoop();
sceneT = 0;
reset();
restPose();
draw();
pushStatus();
}
function play(): void {
sceneT = 0;
reset();
draw();
pushStatus();
if (!paused) { startLoop(); }
}
pauseBtn.addEventListener('click', function (): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}, { signal: off.signal });
// 탭이 숨으면 루프를 멈추고, 돌아오면 시계를 다시 맞춰 시작한다 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 페이지를 연 채 바뀌면 — 켜지면 최종 배치로 멈추고, 꺼지면 시연을 처음부터
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { play(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
draw();
}, { signal: off.signal });
fitCanvas();
fillTable(TABLE_CAPTION, tableRows());
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("cf") + " is-demo"} ref={rootRef}>
<canvas className={cx("cf__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label="앞 작업 끝에서 직각으로 꺾여 뒤 작업 시작으로 들어가는 의존 화살표와 그 위를 흐르는 점선."></canvas>
<table className={cx("cf__sr")} data-role="table"></table>
<p className={cx("cf__status")} data-role="status" aria-live="off">{'의존 화살표를 그리는 중'}</p>
<button className={cx("cf__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
</div>
);
}02 · 앞 작업 끝에서 직각으로 꺾여 내려가는 의존 화살표 — 측정 누적면적 2.342% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 앞 끝(ex, ey)에서 오른쪽으로 ROUTE_STUB 나간 뒤 아래로 꺾는다. 뒤 시작(sx)이 그 꺾인 자리 + ROUTE_STUB 보다 오른쪽이면 곧장 내려가 오른쪽으로 들어가고,
// 아니면 앞 행 바로 아래 통로(gapY)까지 내려가 왼쪽으로 돌아간 다음 뒤 시작보다 ROUTE_STUB 앞에서 내려가 오른쪽으로 들어간다
const ROUTE_STUB = 7;
function elbowRoute(ex, ey, sx, sy, gapY) {
const x1 = ex + ROUTE_STUB;
if (sx - ROUTE_STUB >= x1) {
return [ex, ey, x1, ey, x1, sy, sx, sy];
}
const x2 = sx - ROUTE_STUB;
return [ex, ey, x1, ey, x1, gapY, x2, gapY, x2, sy, sx, sy];
}
03앞뒤로 두 번 훑어 여유 0인 길을 빛내는 가장 긴 경로
줄 선 순서대로 85ms 마다 한 작업씩 가장 이른 시작을 구하며 세로선이 오른쪽으로 훑고, 1.25초부터는 거꾸로 돌아오며 가장 늦은 시작을 구해 두 값의 차이만큼 여유 꼬리를 스프링(120·13, 감쇠비 0.593)으로 뻗는다. 여유가 0 인 요구 정리·데이터 설계·서버 개발·기능 연동·통합 검수·오류 수정·출시 일곱 개가 진한 테두리와 직각 화살표로 이어지고 밝은 띠가 초당 9일 속도로 그 길을 흐르며, 여유가 가장 큰 문서 작성의 꼬리는 12.03일까지 넘쳤다가 11일에 선다. 출시 일정 회의에서 「어느 작업이 하루만 늦어도 출시가 늦는가」 를 물으면 빛나는 일곱 막대가 그대로 답이다.

<main class="stage">
<div class="cf is-demo" id="cf">
<canvas class="cf__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="앞뒤로 두 번 훑어 여유가 0인 작업을 이어 빛내고 나머지에는 여유만큼 옅은 꼬리를 단 일정표."></canvas>
<table class="cf__sr" data-role="table"></table>
<button class="cf__pause" data-role="pause" type="button" aria-pressed="false">일시정지</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.cf {
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-cream;
}
.cf__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.cf__pause {
position: absolute;
right: $sp-3;
bottom: $sp-3;
padding: $sp-1 $sp-3;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.cf__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.cf__pause:hover {
transform: translateY(-2px);
}
.cf__pause:active {
transform: translateY(1px);
}
.cf__pause:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 일정에 무슨 일이 일어났는지 글자로. 시연 중에는 aria-live off, 사람이 처음 손대면 polite
.cf__status {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: calc(100% - 120px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
// 숨김 표 — 화면에서는 1px 로 접어 두고 화면 읽기 도구만 읽는다
.cf__sr {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
@media (max-width: 340px) {
.cf__pause,
.cf__status { font-size: 10px; padding: $sp-1 $sp-2; }
}
// 동작 줄이기 — 장면은 스크립트가 최종 배치 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.cf__pause { transition: none !important; }
.cf__pause:hover,
.cf__pause:active { transform: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cf {
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: #fff7e6;
}
.cf__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.cf__pause {
position: absolute;
right: 12px;
bottom: 12px;
padding: 4px 12px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.cf__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.cf__pause:hover {
transform: translateY(-2px);
}
.cf__pause:active {
transform: translateY(1px);
}
.cf__pause:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.cf__status {
position: absolute;
left: 12px;
bottom: 12px;
max-width: calc(100% - 120px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
.cf__sr {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
@media (max-width: 340px) {
.cf__pause,
.cf__status {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.cf__pause {
transition: none !important;
}
.cf__pause:hover,
.cf__pause:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("cf");
if (!root) return;
// 판 좌표 — 캔버스 안은 늘 480×300. 화면에 보이는 크기와 상관없이 계산은 이 좌표로 한다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
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); }
// 따라가기 비율 — 1 − e^(−rate·dt). 프레임 시간이 달라도 1초에 같은 만큼 다가간다
function follow(rate, dt) { return 1 - Math.exp(-rate * dt); }
// 시드 고정 난수 — 같은 시드면 같은 일정이 다시 나온다 (32비트 정수 섞기)
function makeRng(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function makeSpring(x) { return { x: x, v: 0, to: x }; }
const SPRING_SUBSTEP = 1 / 240;
function stepSpring(s, stiffness, damping, dt) {
let left = dt;
while (left > 1e-9) {
const h = Math.min(SPRING_SUBSTEP, left);
s.v += (stiffness * (s.to - s.x) - damping * s.v) * h;
s.x += s.v * h;
left -= h;
}
}
function snapSpring(s, x) { s.x = x; s.v = 0; s.to = x; }
// ── 날짜 = 정수 일수 ─────────────────────────────────────────
// 날짜 객체를 쓰지 않는다. 기준일을 0 으로 세는 정수 일수(day index)만 다루고, 화면의 월·일 숫자는 아래 월 길이 표로 산수해 만든다
const MONTH_DAYS = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
const YEAR_DAYS = 365;
// 기준일(일수 0) = 한 해의 70번째 날 → 표로 세면 3월 11일
const DAY_ZERO_OF_YEAR = 69;
function calendarOf(day) {
let r = ((day + DAY_ZERO_OF_YEAR) % YEAR_DAYS + YEAR_DAYS) % YEAR_DAYS;
let m = 0;
while (r >= MONTH_DAYS[m]) {
r -= MONTH_DAYS[m];
m += 1;
}
return [m + 1, r + 1];
}
const DAY_FMT = '{n}일차';
const WEEK_FMT = '{n}주차';
const MONTH_FMT = '{n}월';
const SLACK_FMT = '{n}일';
function dayText(day) { return DAY_FMT.replace('{n}', String(day + 1)); }
// 하루 차 계산의 함정 — 시각(시간 단위)끼리 빼서 24 로 나누면, 시계가 한 시간 당겨진 날이 끼었을 때 정수가 아니다(23시간짜리 하루).
// 그래서 바깥에서 들어온 시각은 반올림으로 정수 일수에 붙이고, 그다음부터는 정수끼리만 더하고 뺀다
const HOURS_PER_DAY = 24;
const CLOCK_SHIFT_HOURS = 1;
function hoursToDayIndex(hours) { return Math.round(hours / HOURS_PER_DAY); }
const BASE_TASKS = [
{ name: '요구 정리', dur: 3, deps: [], load: 2 },
{ name: '디자인 시안', dur: 4, deps: [0], load: 1 },
{ name: '데이터 설계', dur: 3, deps: [0], load: 1 },
{ name: '화면 개발', dur: 5, deps: [1], load: 2 },
{ name: '서버 개발', dur: 7, deps: [2], load: 1 },
{ name: '기능 연동', dur: 3, deps: [3, 4], load: 2 },
{ name: '문서 작성', dur: 3, deps: [1], load: 1 },
{ name: '통합 검수', dur: 3, deps: [5], load: 2 },
{ name: '오류 수정', dur: 2, deps: [7], load: 1 },
{ name: '출시', dur: 0, deps: [8, 6], load: 0 }
];
function copyTasks(ts) { return ts.map(function (t) { return { name: t.name, dur: t.dur, deps: t.deps.slice(), load: t.load }; }); }
function successorsOf(tasks) {
const out = tasks.map(function () { return []; });
tasks.forEach(function (t, i) { t.deps.forEach(function (p) { out[p].push(i); }); });
return out;
}
function sortByDeps(tasks) {
const succ = successorsOf(tasks);
const waiting = tasks.map(function (t) { return t.deps.length; });
const ready = [];
waiting.forEach(function (w, i) { if (w === 0) {
ready.push(i);
} });
const order = [];
while (ready.length) {
ready.sort(function (a, b) { return a - b; });
const i = ready.shift();
order.push(i);
succ[i].forEach(function (j) { waiting[j] -= 1; if (waiting[j] === 0) {
ready.push(j);
} });
}
const stuck = [];
waiting.forEach(function (w, i) { if (w > 0) {
stuck.push(i);
} });
return { order: order, stuck: stuck };
}
function sweepBoth(tasks, order, minStart) {
const n = tasks.length;
const es = new Array(n).fill(0);
const ef = new Array(n).fill(0);
for (const i of order) {
let s = minStart ? minStart[i] : 0;
for (const p of tasks[i].deps) {
s = Math.max(s, ef[p]);
}
es[i] = s;
ef[i] = s + tasks[i].dur;
}
let end = 0;
for (const i of order) {
end = Math.max(end, ef[i]);
}
const succ = successorsOf(tasks);
const ls = new Array(n).fill(0);
const lf = new Array(n).fill(0);
for (let k = order.length - 1; k >= 0; k -= 1) {
const i = order[k];
let f = end;
for (const j of succ[i]) {
f = Math.min(f, ls[j]);
}
lf[i] = f;
ls[i] = f - tasks[i].dur;
}
const slack = es.map(function (s, i) { return ls[i] - s; });
return { es: es, ef: ef, ls: ls, lf: lf, slack: slack, end: end };
}
function pushFrom(tasks, starts, from) {
const succ = successorsOf(tasks);
const out = starts.slice();
const depth = tasks.map(function () { return -1; });
depth[from] = 0;
const queue = [from];
let head = 0;
// 고리가 있어도 끝나도록 꺼내는 횟수에 상한을 둔다
const limit = tasks.length * tasks.length + 1;
while (head < queue.length && head < limit) {
const i = queue[head];
head += 1;
for (const j of succ[i]) {
let need = 0;
for (const p of tasks[j].deps) {
need = Math.max(need, out[p] + tasks[p].dur);
}
if (need > out[j]) {
out[j] = need;
depth[j] = Math.max(depth[j], depth[i] + 1);
queue.push(j);
}
}
}
return { starts: out, depth: depth };
}
// 날마다 걸린 일감 — 작업이 덮는 날(시작 ≤ 날 < 시작 + 일수)마다 사람 수를 더한다
function dailyLoad(tasks, starts, days) {
const load = new Array(days).fill(0);
tasks.forEach(function (t, i) {
for (let d = starts[i]; d < starts[i] + t.dur; d += 1) {
if (d >= 0 && d < days) {
load[d] += t.load;
}
}
});
return load;
}
// ── 직각 경로 ───────────────────────────────────────────────
// 앞 끝(ex, ey)에서 오른쪽으로 ROUTE_STUB 나간 뒤 아래로 꺾는다. 뒤 시작(sx)이 그 꺾인 자리 + ROUTE_STUB 보다 오른쪽이면 곧장 내려가 오른쪽으로 들어가고,
// 아니면 앞 행 바로 아래 통로(gapY)까지 내려가 왼쪽으로 돌아간 다음 뒤 시작보다 ROUTE_STUB 앞에서 내려가 오른쪽으로 들어간다
const ROUTE_STUB = 7;
function elbowRoute(ex, ey, sx, sy, gapY) {
const x1 = ex + ROUTE_STUB;
if (sx - ROUTE_STUB >= x1) {
return [ex, ey, x1, ey, x1, sy, sx, sy];
}
const x2 = sx - ROUTE_STUB;
return [ex, ey, x1, ey, x1, gapY, x2, gapY, x2, sy, sx, sy];
}
function routeLength(pts) {
let s = 0;
for (let i = 2; i < pts.length; i += 2) {
s += Math.hypot(pts[i] - pts[i - 2], pts[i + 1] - pts[i - 1]);
}
return s;
}
// 경로 위 거리 dist 자리의 점과 그 마디의 방향
function pointOnRoute(pts, dist) {
let left = Math.max(0, dist);
for (let i = 2; i < pts.length; i += 2) {
const dx = pts[i] - pts[i - 2];
const dy = pts[i + 1] - pts[i - 1];
const len = Math.hypot(dx, dy);
if (left <= len || i === pts.length - 2) {
const k = len > 0 ? Math.min(1, left / len) : 0;
return [pts[i - 2] + dx * k, pts[i - 1] + dy * k, len > 0 ? dx / len : 1, len > 0 ? dy / len : 0];
}
left -= len;
}
return [pts[0], pts[1], 1, 0];
}
// 경로를 앞에서부터 dist 만큼만 자른 점 목록 — 화살표가 그려져 나가는 장면에 쓴다
function cutRoute(pts, dist) {
const out = [pts[0], pts[1]];
let left = Math.max(0, dist);
for (let i = 2; i < pts.length; i += 2) {
const len = Math.hypot(pts[i] - pts[i - 2], pts[i + 1] - pts[i - 1]);
if (left >= len) {
out.push(pts[i], pts[i + 1]);
left -= len;
continue;
}
const k = len > 0 ? left / len : 0;
out.push(lerp(pts[i - 2], pts[i], k), lerp(pts[i - 1], pts[i + 1], k));
break;
}
return out;
}
// 장면 시계(초)와 상태 줄 글자 — 장면 계산이 고치고, 화면 쪽이 읽어 그린다
let sceneT = 0;
let simStatus = '';
// 파스텔 팔레트 — 크림 바탕 위 라벤더·민트·버터·산호 막대. 가장 긴 경로·오늘 선·글자는 진한 쪽으로 명도 차를 둔다
const PAPER = [251, 247, 238];
const PAPER_DIM = [243, 237, 224];
const GRID = [226, 218, 204];
const INK = [44, 38, 58];
const INK_SOFT = [112, 104, 126];
const CREAM = [255, 251, 242];
const LAVENDER = [190, 174, 236];
const MINT = [158, 214, 190];
const BUTTER = [244, 222, 146];
const CORAL = [244, 170, 150];
const SKY = [160, 204, 236];
const PINK = [242, 182, 206];
const PATH_DEEP = [86, 56, 168];
const TODAY_DEEP = [190, 72, 60];
const BAR_COLORS = [LAVENDER, MINT, BUTTER, CORAL, SKY, PINK];
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// 표 한 줄 — 작업·시작·끝·여유. 끝은 그 작업이 덮는 마지막 날(일수 0 짜리는 시작과 같은 날)
function tableRow(name, start, dur, slack) {
return [name, dayText(start), dayText(start + Math.max(0, dur - 1)), SLACK_FMT.replace('{n}', String(slack))];
}
const canvasEl = root.querySelector('[data-role="canvas"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const tableEl = root.querySelector('[data-role="table"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function clearStage() {
ctx.fillStyle = rgba(PAPER, 1);
ctx.fillRect(0, 0, W, H);
}
function label(s, x, y, c, a, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
function roundRectPath(x, y, w, h, r) {
const rr = Math.max(0, Math.min(r, w / 2, h / 2));
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.lineTo(x + w - rr, y);
ctx.arcTo(x + w, y, x + w, y + rr, rr);
ctx.lineTo(x + w, y + h - rr);
ctx.arcTo(x + w, y + h, x + w - rr, y + h, rr);
ctx.lineTo(x + rr, y + h);
ctx.arcTo(x, y + h, x, y + h - rr, rr);
ctx.lineTo(x, y + rr);
ctx.arcTo(x, y, x + rr, y, rr);
ctx.closePath();
}
// 막대 하나 — 둥근 사각형 채움 + 진한 테두리. 폭이 0 이하이면 그리지 않는다
function bar(x, y, w, h, fill, a, line) {
if (w <= 0.2 || a <= 0) {
return;
}
roundRectPath(x, y, w, h, 4);
ctx.fillStyle = rgba(fill, a);
ctx.fill();
if (line > 0) {
ctx.lineWidth = line;
ctx.strokeStyle = rgba(INK, 0.55 * a);
ctx.stroke();
}
}
// 마일스톤 마름모
function diamond(cx, cy, r, fill, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.moveTo(cx, cy - r);
ctx.lineTo(cx + r, cy);
ctx.lineTo(cx, cy + r);
ctx.lineTo(cx - r, cy);
ctx.closePath();
ctx.fillStyle = rgba(fill, a);
ctx.fill();
ctx.lineWidth = 1.5;
ctx.strokeStyle = rgba(INK, 0.8 * a);
ctx.stroke();
}
function polyline(pts, c, a, width) {
if (pts.length < 4) {
return;
}
ctx.beginPath();
ctx.moveTo(pts[0], pts[1]);
for (let i = 2; i < pts.length; i += 2) {
ctx.lineTo(pts[i], pts[i + 1]);
}
ctx.lineWidth = width;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function arrowHead(x, y, dx, dy, size, c, a) {
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x - dx * size - dy * size * 0.6, y - dy * size + dx * size * 0.6);
ctx.lineTo(x - dx * size + dy * size * 0.6, y - dy * size - dx * size * 0.6);
ctx.closePath();
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
// 빗금 — (x, y, w, h) 안에 45° 줄을 spacing 간격으로. 간격이 좁을수록 짙어 보인다
function hatch(x, y, w, h, spacing, c, a) {
if (w <= 0.5 || a <= 0) {
return;
}
ctx.save();
roundRectPath(x, y, w, h, 3);
ctx.clip();
ctx.beginPath();
for (let s = x - h; s < x + w; s += Math.max(2, spacing)) {
ctx.moveTo(s, y + h);
ctx.lineTo(s + h, y);
}
ctx.lineWidth = 1.6;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
ctx.restore();
}
// 시간 축 — 위 띠는 월 이름, 아래 띠는 일(월 안의 날 숫자)·주(주차) 층. 층마다 불투명도를 따로 받아 겹쳐 흐려지며 바뀌게 한다.
// 세로 격자도 같은 불투명도로 행 영역(top~bottom)에 긋는다. xOf 는 일수 → 화면 x
function timeAxis(xOf, dayW, d0, d1, left, right, top, bottom, ly) {
ctx.save();
ctx.beginPath();
ctx.rect(left, 0, right - left, H);
ctx.clip();
ctx.fillStyle = rgba(PAPER_DIM, 1);
ctx.fillRect(left, 0, right - left, top - 4);
for (let d = Math.floor(d0); d <= Math.ceil(d1); d += 1) {
const x = xOf(d);
const cal = calendarOf(d);
if (ly.day > 0.01) {
ctx.fillStyle = rgba(GRID, 0.7 * ly.day);
ctx.fillRect(x, top, 1, bottom - top);
if (dayW >= 13) {
label(String(cal[1]), x + dayW / 2, top - 11, INK_SOFT, ly.day, 9, 600, 'center');
}
}
// 주 층 — 글자가 겹치지 않을 만큼(36px) 주를 건너뛰며 적는다
const weekStep = 7 * dayW >= 36 ? 1 : (14 * dayW >= 36 ? 2 : 4);
if (ly.week > 0.01 && d % 7 === 0) {
ctx.fillStyle = rgba(GRID, ly.week);
ctx.fillRect(x, top, 1, bottom - top);
if ((d / 7) % weekStep === 0) {
label(WEEK_FMT.replace('{n}', String(d / 7 + 1)), x + 3, top - 11, INK_SOFT, ly.week, 9, 600, 'left');
}
}
if (cal[1] === 1) {
ctx.fillStyle = rgba(INK_SOFT, 0.18 + 0.4 * ly.month);
ctx.fillRect(x, 4, 1, bottom - 4);
label(MONTH_FMT.replace('{n}', String(cal[0])), x + 4, 11, INK, 0.85, 10, 700, 'left');
// 월 층 — 그달 칸 가운데에 그달 일수(월 길이 표에서)를 적는다
const len = MONTH_DAYS[cal[0] - 1];
if (ly.month > 0.01 && len * dayW >= 30) {
label(SLACK_FMT.replace('{n}', String(len)), x + len * dayW / 2, top - 11, INK_SOFT, ly.month, 9, 700, 'center');
}
}
else if (d === Math.floor(d0)) {
// 왼쪽 끝 월 이름 — 바로 오른쪽에 새 달이 시작하면 겹치므로 적지 않는다
const toNext = MONTH_DAYS[cal[0] - 1] - cal[1] + 1;
if (toNext * dayW >= 44) {
label(MONTH_FMT.replace('{n}', String(cal[0])), Math.max(x, left) + 4, 11, INK, 0.85, 10, 700, 'left');
}
}
}
ctx.restore();
}
// 왼쪽 작업 이름 칸
function rowLabel(name, y, a, bold) {
label(name, 10, y, INK, a, 11, bold ? 800 : 600, 'left');
}
function rowStripe(y, h, i, a) {
if (i % 2 === 1) {
ctx.fillStyle = rgba(PAPER_DIM, 0.75 * a);
ctx.fillRect(0, y, W, h);
}
}
// 움직이는 행 빛 — 그 행 막대의 스프링 속도(k = 0~1)만큼 행 전체가 옅은 라벤더로 밝아진다. 막대가 서면 사라진다
function rowGlow(y, h, k) {
if (k <= 0.01) {
return;
}
ctx.fillStyle = rgba(LAVENDER, 0.42 * clamp(k, 0, 1));
ctx.fillRect(0, y, W, h);
}
// 상태 줄 — 바뀔 때만 글자를 고친다
let shownStatus = '';
function pushStatus() {
if (!statusEl || simStatus === shownStatus) {
return;
}
shownStatus = simStatus;
statusEl.textContent = simStatus;
}
// 숨김 표 — 같은 일정을 작업·시작·끝·여유 열로 읽게. 화면에는 안 보이고 화면 읽기 도구가 읽는다
const TABLE_HEAD = ['작업 이름', '시작일', '끝나는 날', '여유 일수'];
function fillTable(caption, rows) {
if (!tableEl) {
return;
}
const tb = tableEl;
while (tb.firstChild) {
tb.removeChild(tb.firstChild);
}
const cap = document.createElement('caption');
cap.textContent = caption;
tb.appendChild(cap);
const thead = document.createElement('thead');
const hr = document.createElement('tr');
TABLE_HEAD.forEach(function (h) {
const th = document.createElement('th');
th.scope = 'col';
th.textContent = h;
hr.appendChild(th);
});
thead.appendChild(hr);
tb.appendChild(thead);
const body = document.createElement('tbody');
rows.forEach(function (r) {
const tr = document.createElement('tr');
r.forEach(function (c, k) {
const cell = document.createElement(k === 0 ? 'th' : 'td');
if (k === 0) {
cell.scope = 'row';
}
cell.textContent = c;
tr.appendChild(cell);
});
body.appendChild(tr);
});
tb.appendChild(body);
}
// 03 — 가장 긴 경로. 선후 순서로 줄 세운 뒤 앞에서 한 번(가장 이른 시작), 뒤에서 한 번(가장 늦은 시작) 훑고, 차이(여유)가 0인 작업을 잇는다
const LABEL_W = 96;
const PLOT_X = LABEL_W + 6;
const DAY_WIDTH = 15;
const ROWS_TOP = 46;
const ROW_H = 21;
const BAR_H = 12;
// 한 작업을 훑는 데 걸리는 시간 — 앞 훑기 열 걸음, 뒤 훑기 열 걸음
const SWEEP_STEP_MS = 85;
const FORWARD_START_S = 0.25;
const SWEEP_PAUSE_S = 0.15;
// 여유 꼬리 스프링 — 0 에서 여유 일수까지 자란다
const TAIL_STIFFNESS = 120;
const TAIL_DAMPING = 13;
// 가장 긴 경로 빛 — 경로를 따라 흐르는 밝은 띠의 속도(일수/초)
const GLOW_SPEED_DAYS = 9;
const LOOP_S = 8;
const TABLE_CAPTION = '작업 일정표 — 여유와 가장 긴 경로';
const tasks03 = BASE_TASKS;
const order03 = sortByDeps(tasks03).order;
const sweep03 = sweepBoth(tasks03, order03, null);
// 여유 0 인 작업 — 화면 순서대로
const critical03 = order03.filter(function (i) { return sweep03.slack[i] === 0; });
function dayX(d) { return PLOT_X + d * DAY_WIDTH; }
function rowMid(i) { return ROWS_TOP + i * ROW_H + ROW_H / 2; }
const FORWARD_END_S = FORWARD_START_S + order03.length * SWEEP_STEP_MS / 1000;
const BACKWARD_START_S = FORWARD_END_S + SWEEP_PAUSE_S;
const BACKWARD_END_S = BACKWARD_START_S + order03.length * SWEEP_STEP_MS / 1000;
// k 번째로 줄 선 작업이 앞 훑기·뒤 훑기에서 계산되는 시각
function forwardAt(k) { return FORWARD_START_S + k * SWEEP_STEP_MS / 1000; }
function backwardAt(k) { return BACKWARD_START_S + (order03.length - 1 - k) * SWEEP_STEP_MS / 1000; }
const reveal03 = tasks03.map(function () { return makeSpring(0); });
const tail03 = tasks03.map(function () { return makeSpring(0); });
let glow03 = 0;
function reset() {
reveal03.forEach(function (s) { snapSpring(s, 0); });
tail03.forEach(function (s) { snapSpring(s, 0); });
glow03 = 0;
}
function update(dt) {
order03.forEach(function (i, k) {
reveal03[i].to = sceneT >= forwardAt(k) ? 1 : 0;
stepSpring(reveal03[i], 260, 22, dt);
tail03[i].to = sceneT >= backwardAt(k) ? sweep03.slack[i] : 0;
stepSpring(tail03[i], TAIL_STIFFNESS, TAIL_DAMPING, dt);
});
glow03 = lerp(glow03, sceneT >= BACKWARD_END_S ? 1 : 0, follow(5, dt));
}
function restPose() {
reveal03.forEach(function (s) { snapSpring(s, 1); });
tail03.forEach(function (s, i) { snapSpring(s, sweep03.slack[i]); });
glow03 = 1;
sceneT = BACKWARD_END_S + 1;
}
// 지금 훑고 있는 자리 — 앞 훑기는 가장 이른 시작, 뒤 훑기는 가장 늦은 시작 쪽 세로선(일수)
function sweepDay() {
if (sceneT >= FORWARD_START_S && sceneT < FORWARD_END_S) {
const k = Math.min(order03.length - 1, Math.floor((sceneT - FORWARD_START_S) / (SWEEP_STEP_MS / 1000)));
return [sweep03.ef[order03[k]], 1];
}
if (sceneT >= BACKWARD_START_S && sceneT < BACKWARD_END_S) {
const k = order03.length - 1 - Math.min(order03.length - 1, Math.floor((sceneT - BACKWARD_START_S) / (SWEEP_STEP_MS / 1000)));
return [sweep03.ls[order03[k]], -1];
}
return [0, 0];
}
function tableRows() {
return tasks03.map(function (t, i) { return tableRow(t.name, sweep03.es[i], t.dur, sweep03.slack[i]); });
}
const SLACK_LABEL = '여유 {n}일';
function draw() {
clearStage();
const bottom = ROWS_TOP + tasks03.length * ROW_H;
tasks03.forEach(function (_, i) { rowStripe(ROWS_TOP + i * ROW_H, ROW_H, i, 1); });
timeAxis(dayX, DAY_WIDTH, 0, 25, PLOT_X, W - 8, ROWS_TOP, bottom, { day: 1, week: 0, month: 0 });
// 훑는 선 — 앞 훑기는 오른쪽으로, 뒤 훑기는 왼쪽으로 지나간다
const sw = sweepDay();
if (sw[1] !== 0) {
const x = dayX(sw[0]);
ctx.fillStyle = rgba(PATH_DEEP, 0.1);
ctx.fillRect(sw[1] > 0 ? x - 30 : x, ROWS_TOP, 30, bottom - ROWS_TOP);
ctx.fillStyle = rgba(PATH_DEEP, 0.85);
ctx.fillRect(x - 1, ROWS_TOP - 4, 2, bottom - ROWS_TOP + 4);
arrowHead(x + sw[1] * 6, ROWS_TOP - 4, sw[1], 0, 6, PATH_DEEP, 0.9);
}
// 가장 긴 경로를 잇는 화살표 — 빛이 켜질 때 함께 진해진다
if (glow03 > 0.01) {
critical03.forEach(function (j) {
tasks03[j].deps.forEach(function (p) {
if (sweep03.slack[p] !== 0) {
return;
}
const pts = elbowRoute(dayX(sweep03.ef[p]), rowMid(p), dayX(sweep03.es[j]) - (tasks03[j].dur === 0 ? 7 : 0), rowMid(j), ROWS_TOP + (p + 1) * ROW_H);
polyline(pts, PATH_DEEP, 0.85 * glow03, 2.2);
const tip = pointOnRoute(pts, routeLength(pts));
arrowHead(tip[0], tip[1], tip[2], tip[3], 6, PATH_DEEP, glow03);
});
});
}
const glowDay = (sceneT * GLOW_SPEED_DAYS) % (sweep03.end + 6) - 3;
tasks03.forEach(function (t, i) {
const cy = rowMid(i);
const isCrit = sweep03.slack[i] === 0;
rowLabel(t.name, cy, 1, isCrit && glow03 > 0.5);
const r = clamp(reveal03[i].x, 0, 1.2);
const x0 = dayX(sweep03.es[i]);
const w = t.dur * DAY_WIDTH;
// 여유 꼬리 — 끝에서 여유 일수만큼 옅게 늘어난다
const tl = Math.max(0, tail03[i].x) * DAY_WIDTH;
if (tl > 0.5) {
const tx = x0 + w;
const c = BAR_COLORS[i % BAR_COLORS.length];
bar(tx, cy - BAR_H / 2 + 2, tl, BAR_H - 4, c, 0.35, 0);
ctx.save();
ctx.setLineDash([3, 3]);
roundRectPath(tx, cy - BAR_H / 2 + 2, tl, BAR_H - 4, 3);
ctx.lineWidth = 1;
ctx.strokeStyle = rgba(INK_SOFT, 0.7);
ctx.stroke();
ctx.restore();
const la = smooth((tail03[i].x / Math.max(1, sweep03.slack[i]) - 0.6) / 0.4);
if (la > 0) {
label(SLACK_LABEL.replace('{n}', String(sweep03.slack[i])), tx + tl + 4, cy, INK_SOFT, la, 9, 700, 'left');
}
}
if (t.dur === 0) {
diamond(x0, cy, 7 * r, isCrit ? PATH_DEEP : INK_SOFT, Math.min(1, r));
return;
}
const hh = BAR_H * (0.4 + 0.6 * r);
bar(x0, cy - hh / 2, w * Math.min(1, r), hh, BAR_COLORS[i % BAR_COLORS.length], Math.min(1, r), 1.2);
if (isCrit && glow03 > 0.01) {
// 빛 — 진한 테두리 + 경로를 따라 흐르는 밝은 띠(그 날짜를 덮는 막대만)
roundRectPath(x0 - 2, cy - hh / 2 - 2, w + 4, hh + 4, 6);
ctx.lineWidth = 2.4;
ctx.strokeStyle = rgba(PATH_DEEP, glow03);
ctx.stroke();
const gx = dayX(glowDay);
if (gx > x0 - 20 && gx < x0 + w + 20) {
ctx.save();
roundRectPath(x0, cy - hh / 2, w, hh, 4);
ctx.clip();
const g = ctx.createLinearGradient(gx - 22, 0, gx + 22, 0);
g.addColorStop(0, rgba(CREAM, 0));
g.addColorStop(0.5, rgba(CREAM, 0.85 * glow03));
g.addColorStop(1, rgba(CREAM, 0));
ctx.fillStyle = g;
ctx.fillRect(gx - 22, cy - hh / 2, 44, hh);
ctx.restore();
}
}
});
}
// 한 프레임에 넘기는 시간 상한(초) — 탭을 오래 숨겼다 돌아온 첫 프레임에도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 손대면 시연 표시를 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
// 리스너는 한 묶음으로 달고 정리할 때 한 번에 뗀다
const off = new AbortController();
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
if (statusEl) {
statusEl.setAttribute('aria-live', 'polite');
}
}
// 장면 시계를 dt 만큼 — 한 바퀴(LOOP_S)를 넘으면 처음 배치로 되돌려 다시 시연한다
function advance(dt) {
sceneT += dt;
if (sceneT >= LOOP_S) {
sceneT -= LOOP_S;
reset();
}
update(dt);
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
advance(dt);
}
draw();
pushStatus();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 시계 다시 맞추기 — 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 동작 줄이기 — 루프를 돌리지 않고 시연이 끝난 최종 배치 한 장만 그린다
function showRest() {
stopLoop();
sceneT = 0;
reset();
restPose();
draw();
pushStatus();
}
function play() {
sceneT = 0;
reset();
draw();
pushStatus();
if (!paused) {
startLoop();
}
}
pauseBtn.addEventListener('click', function () {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}, { signal: off.signal });
// 탭이 숨으면 루프를 멈추고, 돌아오면 시계를 다시 맞춰 시작한다 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 페이지를 연 채 바뀌면 — 켜지면 최종 배치로 멈추고, 꺼지면 시연을 처음부터
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
play();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
draw();
}, { signal: off.signal });
fitCanvas();
fillTable(TABLE_CAPTION, tableRows());
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// LongestPathSlackTail - 같은 항목 vanilla 판 index.html 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./LongestPathSlackTail.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function LongestPathSlackTail() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
type Pt = [number, number];
// 판 좌표 — 캔버스 안은 늘 480×300. 화면에 보이는 크기와 상관없이 계산은 이 좌표로 한다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
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); }
// 따라가기 비율 — 1 − e^(−rate·dt). 프레임 시간이 달라도 1초에 같은 만큼 다가간다
function follow(rate: number, dt: number): number { return 1 - Math.exp(-rate * dt); }
// 시드 고정 난수 — 같은 시드면 같은 일정이 다시 나온다 (32비트 정수 섞기)
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// ── 감쇠 스프링 ──────────────────────────────────────────────
// 가속 = 단단함 × (목표 − 위치) − 감쇠 × 속도. 속도를 먼저 고친 뒤 위치를 옮기는 적분을 1/240초 잔걸음으로 나눠 돌린다
type Spring = { x: number; v: number; to: number };
function makeSpring(x: number): Spring { return { x: x, v: 0, to: x }; }
const SPRING_SUBSTEP = 1 / 240;
function stepSpring(s: Spring, stiffness: number, damping: number, dt: number): void {
let left = dt;
while (left > 1e-9) {
const h = Math.min(SPRING_SUBSTEP, left);
s.v += (stiffness * (s.to - s.x) - damping * s.v) * h;
s.x += s.v * h;
left -= h;
}
}
function snapSpring(s: Spring, x: number): void { s.x = x; s.v = 0; s.to = x; }
// ── 날짜 = 정수 일수 ─────────────────────────────────────────
// 날짜 객체를 쓰지 않는다. 기준일을 0 으로 세는 정수 일수(day index)만 다루고, 화면의 월·일 숫자는 아래 월 길이 표로 산수해 만든다
const MONTH_DAYS = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
const YEAR_DAYS = 365;
// 기준일(일수 0) = 한 해의 70번째 날 → 표로 세면 3월 11일
const DAY_ZERO_OF_YEAR = 69;
function calendarOf(day: number): [number, number] {
let r = ((day + DAY_ZERO_OF_YEAR) % YEAR_DAYS + YEAR_DAYS) % YEAR_DAYS;
let m = 0;
while (r >= MONTH_DAYS[m]) { r -= MONTH_DAYS[m]; m += 1; }
return [m + 1, r + 1];
}
const DAY_FMT = '{n}일차';
const WEEK_FMT = '{n}주차';
const MONTH_FMT = '{n}월';
const SLACK_FMT = '{n}일';
function dayText(day: number): string { return DAY_FMT.replace('{n}', String(day + 1)); }
// 하루 차 계산의 함정 — 시각(시간 단위)끼리 빼서 24 로 나누면, 시계가 한 시간 당겨진 날이 끼었을 때 정수가 아니다(23시간짜리 하루).
// 그래서 바깥에서 들어온 시각은 반올림으로 정수 일수에 붙이고, 그다음부터는 정수끼리만 더하고 뺀다
const HOURS_PER_DAY = 24;
const CLOCK_SHIFT_HOURS = 1;
function hoursToDayIndex(hours: number): number { return Math.round(hours / HOURS_PER_DAY); }
// ── 작업과 의존 ──────────────────────────────────────────────
// dur 는 일수(0 이면 마일스톤), deps 는 끝나야 시작할 수 있는 앞 작업 번호, load 는 그 작업에 붙는 사람 수
type Task = { name: string; dur: number; deps: number[]; load: number };
const BASE_TASKS: Task[] = [
{ name: '요구 정리', dur: 3, deps: [], load: 2 },
{ name: '디자인 시안', dur: 4, deps: [0], load: 1 },
{ name: '데이터 설계', dur: 3, deps: [0], load: 1 },
{ name: '화면 개발', dur: 5, deps: [1], load: 2 },
{ name: '서버 개발', dur: 7, deps: [2], load: 1 },
{ name: '기능 연동', dur: 3, deps: [3, 4], load: 2 },
{ name: '문서 작성', dur: 3, deps: [1], load: 1 },
{ name: '통합 검수', dur: 3, deps: [5], load: 2 },
{ name: '오류 수정', dur: 2, deps: [7], load: 1 },
{ name: '출시', dur: 0, deps: [8, 6], load: 0 }
];
function copyTasks(ts: Task[]): Task[] { return ts.map(function (t: Task): Task { return { name: t.name, dur: t.dur, deps: t.deps.slice(), load: t.load }; }); }
function successorsOf(tasks: Task[]): number[][] {
const out: number[][] = tasks.map(function (): number[] { return []; });
tasks.forEach(function (t: Task, i: number): void { t.deps.forEach(function (p: number): void { out[p].push(i); }); });
return out;
}
// 선후 순서로 줄 세우기 — 앞 작업이 하나도 안 남은(들어오는 화살표 0) 작업부터 꺼내고, 꺼낼 때마다 뒤 작업의 남은 수를 하나 줄인다.
// 꺼낼 것이 사라졌는데 남은 작업이 있으면 그 작업들은 서로를 기다리는 고리(순환 의존)에 걸렸거나 그 뒤에 매달린 것이다 → stuck
type Order = { order: number[]; stuck: number[] };
function sortByDeps(tasks: Task[]): Order {
const succ = successorsOf(tasks);
const waiting = tasks.map(function (t: Task): number { return t.deps.length; });
const ready: number[] = [];
waiting.forEach(function (w: number, i: number): void { if (w === 0) { ready.push(i); } });
const order: number[] = [];
while (ready.length) {
ready.sort(function (a: number, b: number): number { return a - b; });
const i = ready.shift() as number;
order.push(i);
succ[i].forEach(function (j: number): void { waiting[j] -= 1; if (waiting[j] === 0) { ready.push(j); } });
}
const stuck: number[] = [];
waiting.forEach(function (w: number, i: number): void { if (w > 0) { stuck.push(i); } });
return { order: order, stuck: stuck };
}
// 앞뒤 두 번 훑기 — 앞에서: 가장 이른 시작 = 앞 작업 끝 중 가장 늦은 날(과 정해 둔 시작일 중 늦은 쪽).
// 뒤에서: 가장 늦은 끝 = 뒤 작업의 가장 늦은 시작 중 가장 이른 날(뒤 작업이 없으면 전체 끝). 여유 = 가장 늦은 시작 − 가장 이른 시작
type Layers = { day: number; week: number; month: number };
type Sweep = { es: number[]; ef: number[]; ls: number[]; lf: number[]; slack: number[]; end: number };
function sweepBoth(tasks: Task[], order: number[], minStart: number[] | null): Sweep {
const n = tasks.length;
const es: number[] = new Array(n).fill(0);
const ef: number[] = new Array(n).fill(0);
for (const i of order) {
let s = minStart ? minStart[i] : 0;
for (const p of tasks[i].deps) { s = Math.max(s, ef[p]); }
es[i] = s;
ef[i] = s + tasks[i].dur;
}
let end = 0;
for (const i of order) { end = Math.max(end, ef[i]); }
const succ = successorsOf(tasks);
const ls: number[] = new Array(n).fill(0);
const lf: number[] = new Array(n).fill(0);
for (let k = order.length - 1; k >= 0; k -= 1) {
const i = order[k];
let f = end;
for (const j of succ[i]) { f = Math.min(f, ls[j]); }
lf[i] = f;
ls[i] = f - tasks[i].dur;
}
const slack = es.map(function (s: number, i: number): number { return ls[i] - s; });
return { es: es, ef: ef, ls: ls, lf: lf, slack: slack, end: end };
}
// 밀기 전파 — 밀린 작업에서 가까운 뒤 작업부터 너비 우선으로 훑으며, 새 시작 = 앞 작업 끝 중 가장 늦은 날(지금보다 늦을 때만).
// depth 는 밀린 작업에서 몇 단계 뒤인지(-1 이면 여유가 흡수해 안 밀림). 시작이 바뀐 작업만 다시 큐에 넣는다
type Push = { starts: number[]; depth: number[] };
function pushFrom(tasks: Task[], starts: number[], from: number): Push {
const succ = successorsOf(tasks);
const out = starts.slice();
const depth: number[] = tasks.map(function (): number { return -1; });
depth[from] = 0;
const queue: number[] = [from];
let head = 0;
// 고리가 있어도 끝나도록 꺼내는 횟수에 상한을 둔다
const limit = tasks.length * tasks.length + 1;
while (head < queue.length && head < limit) {
const i = queue[head];
head += 1;
for (const j of succ[i]) {
let need = 0;
for (const p of tasks[j].deps) { need = Math.max(need, out[p] + tasks[p].dur); }
if (need > out[j]) {
out[j] = need;
depth[j] = Math.max(depth[j], depth[i] + 1);
queue.push(j);
}
}
}
return { starts: out, depth: depth };
}
// 날마다 걸린 일감 — 작업이 덮는 날(시작 ≤ 날 < 시작 + 일수)마다 사람 수를 더한다
function dailyLoad(tasks: Task[], starts: number[], days: number): number[] {
const load: number[] = new Array(days).fill(0);
tasks.forEach(function (t: Task, i: number): void {
for (let d = starts[i]; d < starts[i] + t.dur; d += 1) { if (d >= 0 && d < days) { load[d] += t.load; } }
});
return load;
}
// ── 직각 경로 ───────────────────────────────────────────────
// 앞 끝(ex, ey)에서 오른쪽으로 ROUTE_STUB 나간 뒤 아래로 꺾는다. 뒤 시작(sx)이 그 꺾인 자리 + ROUTE_STUB 보다 오른쪽이면 곧장 내려가 오른쪽으로 들어가고,
// 아니면 앞 행 바로 아래 통로(gapY)까지 내려가 왼쪽으로 돌아간 다음 뒤 시작보다 ROUTE_STUB 앞에서 내려가 오른쪽으로 들어간다
const ROUTE_STUB = 7;
function elbowRoute(ex: number, ey: number, sx: number, sy: number, gapY: number): number[] {
const x1 = ex + ROUTE_STUB;
if (sx - ROUTE_STUB >= x1) { return [ex, ey, x1, ey, x1, sy, sx, sy]; }
const x2 = sx - ROUTE_STUB;
return [ex, ey, x1, ey, x1, gapY, x2, gapY, x2, sy, sx, sy];
}
function routeLength(pts: number[]): number {
let s = 0;
for (let i = 2; i < pts.length; i += 2) { s += Math.hypot(pts[i] - pts[i - 2], pts[i + 1] - pts[i - 1]); }
return s;
}
// 경로 위 거리 dist 자리의 점과 그 마디의 방향
function pointOnRoute(pts: number[], dist: number): [number, number, number, number] {
let left = Math.max(0, dist);
for (let i = 2; i < pts.length; i += 2) {
const dx = pts[i] - pts[i - 2];
const dy = pts[i + 1] - pts[i - 1];
const len = Math.hypot(dx, dy);
if (left <= len || i === pts.length - 2) {
const k = len > 0 ? Math.min(1, left / len) : 0;
return [pts[i - 2] + dx * k, pts[i - 1] + dy * k, len > 0 ? dx / len : 1, len > 0 ? dy / len : 0];
}
left -= len;
}
return [pts[0], pts[1], 1, 0];
}
// 경로를 앞에서부터 dist 만큼만 자른 점 목록 — 화살표가 그려져 나가는 장면에 쓴다
function cutRoute(pts: number[], dist: number): number[] {
const out: number[] = [pts[0], pts[1]];
let left = Math.max(0, dist);
for (let i = 2; i < pts.length; i += 2) {
const len = Math.hypot(pts[i] - pts[i - 2], pts[i + 1] - pts[i - 1]);
if (left >= len) { out.push(pts[i], pts[i + 1]); left -= len; continue; }
const k = len > 0 ? left / len : 0;
out.push(lerp(pts[i - 2], pts[i], k), lerp(pts[i - 1], pts[i + 1], k));
break;
}
return out;
}
// 장면 시계(초)와 상태 줄 글자 — 장면 계산이 고치고, 화면 쪽이 읽어 그린다
let sceneT = 0;
let simStatus = '';
// 파스텔 팔레트 — 크림 바탕 위 라벤더·민트·버터·산호 막대. 가장 긴 경로·오늘 선·글자는 진한 쪽으로 명도 차를 둔다
const PAPER: RGB = [251, 247, 238];
const PAPER_DIM: RGB = [243, 237, 224];
const GRID: RGB = [226, 218, 204];
const INK: RGB = [44, 38, 58];
const INK_SOFT: RGB = [112, 104, 126];
const CREAM: RGB = [255, 251, 242];
const LAVENDER: RGB = [190, 174, 236];
const MINT: RGB = [158, 214, 190];
const BUTTER: RGB = [244, 222, 146];
const CORAL: RGB = [244, 170, 150];
const SKY: RGB = [160, 204, 236];
const PINK: RGB = [242, 182, 206];
const PATH_DEEP: RGB = [86, 56, 168];
const TODAY_DEEP: RGB = [190, 72, 60];
const BAR_COLORS: RGB[] = [LAVENDER, MINT, BUTTER, CORAL, SKY, PINK];
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// 표 한 줄 — 작업·시작·끝·여유. 끝은 그 작업이 덮는 마지막 날(일수 0 짜리는 시작과 같은 날)
function tableRow(name: string, start: number, dur: number, slack: number): string[] {
return [name, dayText(start), dayText(start + Math.max(0, dur - 1)), SLACK_FMT.replace('{n}', String(slack))];
}
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const tableEl = root.querySelector('[data-role="table"]') as HTMLTableElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function clearStage(): void {
ctx.fillStyle = rgba(PAPER, 1);
ctx.fillRect(0, 0, W, H);
}
function label(s: string, x: number, y: number, c: RGB, a: number, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
function roundRectPath(x: number, y: number, w: number, h: number, r: number): void {
const rr = Math.max(0, Math.min(r, w / 2, h / 2));
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.lineTo(x + w - rr, y);
ctx.arcTo(x + w, y, x + w, y + rr, rr);
ctx.lineTo(x + w, y + h - rr);
ctx.arcTo(x + w, y + h, x + w - rr, y + h, rr);
ctx.lineTo(x + rr, y + h);
ctx.arcTo(x, y + h, x, y + h - rr, rr);
ctx.lineTo(x, y + rr);
ctx.arcTo(x, y, x + rr, y, rr);
ctx.closePath();
}
// 막대 하나 — 둥근 사각형 채움 + 진한 테두리. 폭이 0 이하이면 그리지 않는다
function bar(x: number, y: number, w: number, h: number, fill: RGB, a: number, line: number): void {
if (w <= 0.2 || a <= 0) { return; }
roundRectPath(x, y, w, h, 4);
ctx.fillStyle = rgba(fill, a);
ctx.fill();
if (line > 0) {
ctx.lineWidth = line;
ctx.strokeStyle = rgba(INK, 0.55 * a);
ctx.stroke();
}
}
// 마일스톤 마름모
function diamond(cx: number, cy: number, r: number, fill: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.moveTo(cx, cy - r);
ctx.lineTo(cx + r, cy);
ctx.lineTo(cx, cy + r);
ctx.lineTo(cx - r, cy);
ctx.closePath();
ctx.fillStyle = rgba(fill, a);
ctx.fill();
ctx.lineWidth = 1.5;
ctx.strokeStyle = rgba(INK, 0.8 * a);
ctx.stroke();
}
function polyline(pts: number[], c: RGB, a: number, width: number): void {
if (pts.length < 4) { return; }
ctx.beginPath();
ctx.moveTo(pts[0], pts[1]);
for (let i = 2; i < pts.length; i += 2) { ctx.lineTo(pts[i], pts[i + 1]); }
ctx.lineWidth = width;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function arrowHead(x: number, y: number, dx: number, dy: number, size: number, c: RGB, a: number): void {
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x - dx * size - dy * size * 0.6, y - dy * size + dx * size * 0.6);
ctx.lineTo(x - dx * size + dy * size * 0.6, y - dy * size - dx * size * 0.6);
ctx.closePath();
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
// 빗금 — (x, y, w, h) 안에 45° 줄을 spacing 간격으로. 간격이 좁을수록 짙어 보인다
function hatch(x: number, y: number, w: number, h: number, spacing: number, c: RGB, a: number): void {
if (w <= 0.5 || a <= 0) { return; }
ctx.save();
roundRectPath(x, y, w, h, 3);
ctx.clip();
ctx.beginPath();
for (let s = x - h; s < x + w; s += Math.max(2, spacing)) {
ctx.moveTo(s, y + h);
ctx.lineTo(s + h, y);
}
ctx.lineWidth = 1.6;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
ctx.restore();
}
// 시간 축 — 위 띠는 월 이름, 아래 띠는 일(월 안의 날 숫자)·주(주차) 층. 층마다 불투명도를 따로 받아 겹쳐 흐려지며 바뀌게 한다.
// 세로 격자도 같은 불투명도로 행 영역(top~bottom)에 긋는다. xOf 는 일수 → 화면 x
function timeAxis(xOf: (d: number) => number, dayW: number, d0: number, d1: number, left: number, right: number, top: number, bottom: number, ly: Layers): void {
ctx.save();
ctx.beginPath();
ctx.rect(left, 0, right - left, H);
ctx.clip();
ctx.fillStyle = rgba(PAPER_DIM, 1);
ctx.fillRect(left, 0, right - left, top - 4);
for (let d = Math.floor(d0); d <= Math.ceil(d1); d += 1) {
const x = xOf(d);
const cal = calendarOf(d);
if (ly.day > 0.01) {
ctx.fillStyle = rgba(GRID, 0.7 * ly.day);
ctx.fillRect(x, top, 1, bottom - top);
if (dayW >= 13) { label(String(cal[1]), x + dayW / 2, top - 11, INK_SOFT, ly.day, 9, 600, 'center'); }
}
// 주 층 — 글자가 겹치지 않을 만큼(36px) 주를 건너뛰며 적는다
const weekStep = 7 * dayW >= 36 ? 1 : (14 * dayW >= 36 ? 2 : 4);
if (ly.week > 0.01 && d % 7 === 0) {
ctx.fillStyle = rgba(GRID, ly.week);
ctx.fillRect(x, top, 1, bottom - top);
if ((d / 7) % weekStep === 0) { label(WEEK_FMT.replace('{n}', String(d / 7 + 1)), x + 3, top - 11, INK_SOFT, ly.week, 9, 600, 'left'); }
}
if (cal[1] === 1) {
ctx.fillStyle = rgba(INK_SOFT, 0.18 + 0.4 * ly.month);
ctx.fillRect(x, 4, 1, bottom - 4);
label(MONTH_FMT.replace('{n}', String(cal[0])), x + 4, 11, INK, 0.85, 10, 700, 'left');
// 월 층 — 그달 칸 가운데에 그달 일수(월 길이 표에서)를 적는다
const len = MONTH_DAYS[cal[0] - 1];
if (ly.month > 0.01 && len * dayW >= 30) { label(SLACK_FMT.replace('{n}', String(len)), x + len * dayW / 2, top - 11, INK_SOFT, ly.month, 9, 700, 'center'); }
} else if (d === Math.floor(d0)) {
// 왼쪽 끝 월 이름 — 바로 오른쪽에 새 달이 시작하면 겹치므로 적지 않는다
const toNext = MONTH_DAYS[cal[0] - 1] - cal[1] + 1;
if (toNext * dayW >= 44) { label(MONTH_FMT.replace('{n}', String(cal[0])), Math.max(x, left) + 4, 11, INK, 0.85, 10, 700, 'left'); }
}
}
ctx.restore();
}
// 왼쪽 작업 이름 칸
function rowLabel(name: string, y: number, a: number, bold: boolean): void {
label(name, 10, y, INK, a, 11, bold ? 800 : 600, 'left');
}
function rowStripe(y: number, h: number, i: number, a: number): void {
if (i % 2 === 1) {
ctx.fillStyle = rgba(PAPER_DIM, 0.75 * a);
ctx.fillRect(0, y, W, h);
}
}
// 움직이는 행 빛 — 그 행 막대의 스프링 속도(k = 0~1)만큼 행 전체가 옅은 라벤더로 밝아진다. 막대가 서면 사라진다
function rowGlow(y: number, h: number, k: number): void {
if (k <= 0.01) { return; }
ctx.fillStyle = rgba(LAVENDER, 0.42 * clamp(k, 0, 1));
ctx.fillRect(0, y, W, h);
}
// 상태 줄 — 바뀔 때만 글자를 고친다
let shownStatus = '';
function pushStatus(): void {
if (!statusEl || simStatus === shownStatus) { return; }
shownStatus = simStatus;
statusEl.textContent = simStatus;
}
// 숨김 표 — 같은 일정을 작업·시작·끝·여유 열로 읽게. 화면에는 안 보이고 화면 읽기 도구가 읽는다
const TABLE_HEAD = ['작업 이름', '시작일', '끝나는 날', '여유 일수'];
function fillTable(caption: string, rows: string[][]): void {
if (!tableEl) { return; }
const tb = tableEl;
while (tb.firstChild) { tb.removeChild(tb.firstChild); }
const cap = document.createElement('caption');
cap.textContent = caption;
tb.appendChild(cap);
const thead = document.createElement('thead');
const hr = document.createElement('tr');
TABLE_HEAD.forEach(function (h: string): void {
const th = document.createElement('th');
th.scope = 'col';
th.textContent = h;
hr.appendChild(th);
});
thead.appendChild(hr);
tb.appendChild(thead);
const body = document.createElement('tbody');
rows.forEach(function (r: string[]): void {
const tr = document.createElement('tr');
r.forEach(function (c: string, k: number): void {
const cell = document.createElement(k === 0 ? 'th' : 'td');
if (k === 0) { (cell as HTMLTableCellElement).scope = 'row'; }
cell.textContent = c;
tr.appendChild(cell);
});
body.appendChild(tr);
});
tb.appendChild(body);
}
// 03 — 가장 긴 경로. 선후 순서로 줄 세운 뒤 앞에서 한 번(가장 이른 시작), 뒤에서 한 번(가장 늦은 시작) 훑고, 차이(여유)가 0인 작업을 잇는다
const LABEL_W = 96;
const PLOT_X = LABEL_W + 6;
const DAY_WIDTH = 15;
const ROWS_TOP = 46;
const ROW_H = 21;
const BAR_H = 12;
// 한 작업을 훑는 데 걸리는 시간 — 앞 훑기 열 걸음, 뒤 훑기 열 걸음
const SWEEP_STEP_MS = 85;
const FORWARD_START_S = 0.25;
const SWEEP_PAUSE_S = 0.15;
// 여유 꼬리 스프링 — 0 에서 여유 일수까지 자란다
const TAIL_STIFFNESS = 120;
const TAIL_DAMPING = 13;
// 가장 긴 경로 빛 — 경로를 따라 흐르는 밝은 띠의 속도(일수/초)
const GLOW_SPEED_DAYS = 9;
const LOOP_S = 8;
const TABLE_CAPTION = '작업 일정표 — 여유와 가장 긴 경로';
const tasks03 = BASE_TASKS;
const order03 = sortByDeps(tasks03).order;
const sweep03 = sweepBoth(tasks03, order03, null);
// 여유 0 인 작업 — 화면 순서대로
const critical03: number[] = order03.filter(function (i: number): boolean { return sweep03.slack[i] === 0; });
function dayX(d: number): number { return PLOT_X + d * DAY_WIDTH; }
function rowMid(i: number): number { return ROWS_TOP + i * ROW_H + ROW_H / 2; }
const FORWARD_END_S = FORWARD_START_S + order03.length * SWEEP_STEP_MS / 1000;
const BACKWARD_START_S = FORWARD_END_S + SWEEP_PAUSE_S;
const BACKWARD_END_S = BACKWARD_START_S + order03.length * SWEEP_STEP_MS / 1000;
// k 번째로 줄 선 작업이 앞 훑기·뒤 훑기에서 계산되는 시각
function forwardAt(k: number): number { return FORWARD_START_S + k * SWEEP_STEP_MS / 1000; }
function backwardAt(k: number): number { return BACKWARD_START_S + (order03.length - 1 - k) * SWEEP_STEP_MS / 1000; }
const reveal03: Spring[] = tasks03.map(function (): Spring { return makeSpring(0); });
const tail03: Spring[] = tasks03.map(function (): Spring { return makeSpring(0); });
let glow03 = 0;
function reset(): void {
reveal03.forEach(function (s: Spring): void { snapSpring(s, 0); });
tail03.forEach(function (s: Spring): void { snapSpring(s, 0); });
glow03 = 0;
}
function update(dt: number): void {
order03.forEach(function (i: number, k: number): void {
reveal03[i].to = sceneT >= forwardAt(k) ? 1 : 0;
stepSpring(reveal03[i], 260, 22, dt);
tail03[i].to = sceneT >= backwardAt(k) ? sweep03.slack[i] : 0;
stepSpring(tail03[i], TAIL_STIFFNESS, TAIL_DAMPING, dt);
});
glow03 = lerp(glow03, sceneT >= BACKWARD_END_S ? 1 : 0, follow(5, dt));
}
function restPose(): void {
reveal03.forEach(function (s: Spring): void { snapSpring(s, 1); });
tail03.forEach(function (s: Spring, i: number): void { snapSpring(s, sweep03.slack[i]); });
glow03 = 1;
sceneT = BACKWARD_END_S + 1;
}
// 지금 훑고 있는 자리 — 앞 훑기는 가장 이른 시작, 뒤 훑기는 가장 늦은 시작 쪽 세로선(일수)
function sweepDay(): [number, number] {
if (sceneT >= FORWARD_START_S && sceneT < FORWARD_END_S) {
const k = Math.min(order03.length - 1, Math.floor((sceneT - FORWARD_START_S) / (SWEEP_STEP_MS / 1000)));
return [sweep03.ef[order03[k]], 1];
}
if (sceneT >= BACKWARD_START_S && sceneT < BACKWARD_END_S) {
const k = order03.length - 1 - Math.min(order03.length - 1, Math.floor((sceneT - BACKWARD_START_S) / (SWEEP_STEP_MS / 1000)));
return [sweep03.ls[order03[k]], -1];
}
return [0, 0];
}
function tableRows(): string[][] {
return tasks03.map(function (t: Task, i: number): string[] { return tableRow(t.name, sweep03.es[i], t.dur, sweep03.slack[i]); });
}
const SLACK_LABEL = '여유 {n}일';
function draw(): void {
clearStage();
const bottom = ROWS_TOP + tasks03.length * ROW_H;
tasks03.forEach(function (_: Task, i: number): void { rowStripe(ROWS_TOP + i * ROW_H, ROW_H, i, 1); });
timeAxis(dayX, DAY_WIDTH, 0, 25, PLOT_X, W - 8, ROWS_TOP, bottom, { day: 1, week: 0, month: 0 });
// 훑는 선 — 앞 훑기는 오른쪽으로, 뒤 훑기는 왼쪽으로 지나간다
const sw = sweepDay();
if (sw[1] !== 0) {
const x = dayX(sw[0]);
ctx.fillStyle = rgba(PATH_DEEP, 0.1);
ctx.fillRect(sw[1] > 0 ? x - 30 : x, ROWS_TOP, 30, bottom - ROWS_TOP);
ctx.fillStyle = rgba(PATH_DEEP, 0.85);
ctx.fillRect(x - 1, ROWS_TOP - 4, 2, bottom - ROWS_TOP + 4);
arrowHead(x + sw[1] * 6, ROWS_TOP - 4, sw[1], 0, 6, PATH_DEEP, 0.9);
}
// 가장 긴 경로를 잇는 화살표 — 빛이 켜질 때 함께 진해진다
if (glow03 > 0.01) {
critical03.forEach(function (j: number): void {
tasks03[j].deps.forEach(function (p: number): void {
if (sweep03.slack[p] !== 0) { return; }
const pts = elbowRoute(dayX(sweep03.ef[p]), rowMid(p), dayX(sweep03.es[j]) - (tasks03[j].dur === 0 ? 7 : 0), rowMid(j), ROWS_TOP + (p + 1) * ROW_H);
polyline(pts, PATH_DEEP, 0.85 * glow03, 2.2);
const tip = pointOnRoute(pts, routeLength(pts));
arrowHead(tip[0], tip[1], tip[2], tip[3], 6, PATH_DEEP, glow03);
});
});
}
const glowDay = (sceneT * GLOW_SPEED_DAYS) % (sweep03.end + 6) - 3;
tasks03.forEach(function (t: Task, i: number): void {
const cy = rowMid(i);
const isCrit = sweep03.slack[i] === 0;
rowLabel(t.name, cy, 1, isCrit && glow03 > 0.5);
const r = clamp(reveal03[i].x, 0, 1.2);
const x0 = dayX(sweep03.es[i]);
const w = t.dur * DAY_WIDTH;
// 여유 꼬리 — 끝에서 여유 일수만큼 옅게 늘어난다
const tl = Math.max(0, tail03[i].x) * DAY_WIDTH;
if (tl > 0.5) {
const tx = x0 + w;
const c = BAR_COLORS[i % BAR_COLORS.length];
bar(tx, cy - BAR_H / 2 + 2, tl, BAR_H - 4, c, 0.35, 0);
ctx.save();
ctx.setLineDash([3, 3]);
roundRectPath(tx, cy - BAR_H / 2 + 2, tl, BAR_H - 4, 3);
ctx.lineWidth = 1;
ctx.strokeStyle = rgba(INK_SOFT, 0.7);
ctx.stroke();
ctx.restore();
const la = smooth((tail03[i].x / Math.max(1, sweep03.slack[i]) - 0.6) / 0.4);
if (la > 0) { label(SLACK_LABEL.replace('{n}', String(sweep03.slack[i])), tx + tl + 4, cy, INK_SOFT, la, 9, 700, 'left'); }
}
if (t.dur === 0) {
diamond(x0, cy, 7 * r, isCrit ? PATH_DEEP : INK_SOFT, Math.min(1, r));
return;
}
const hh = BAR_H * (0.4 + 0.6 * r);
bar(x0, cy - hh / 2, w * Math.min(1, r), hh, BAR_COLORS[i % BAR_COLORS.length], Math.min(1, r), 1.2);
if (isCrit && glow03 > 0.01) {
// 빛 — 진한 테두리 + 경로를 따라 흐르는 밝은 띠(그 날짜를 덮는 막대만)
roundRectPath(x0 - 2, cy - hh / 2 - 2, w + 4, hh + 4, 6);
ctx.lineWidth = 2.4;
ctx.strokeStyle = rgba(PATH_DEEP, glow03);
ctx.stroke();
const gx = dayX(glowDay);
if (gx > x0 - 20 && gx < x0 + w + 20) {
ctx.save();
roundRectPath(x0, cy - hh / 2, w, hh, 4);
ctx.clip();
const g = ctx.createLinearGradient(gx - 22, 0, gx + 22, 0);
g.addColorStop(0, rgba(CREAM, 0));
g.addColorStop(0.5, rgba(CREAM, 0.85 * glow03));
g.addColorStop(1, rgba(CREAM, 0));
ctx.fillStyle = g;
ctx.fillRect(gx - 22, cy - hh / 2, 44, hh);
ctx.restore();
}
}
});
}
// 한 프레임에 넘기는 시간 상한(초) — 탭을 오래 숨겼다 돌아온 첫 프레임에도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 손대면 시연 표시를 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
// 리스너는 한 묶음으로 달고 정리할 때 한 번에 뗀다
const off = new AbortController();
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
if (statusEl) { statusEl.setAttribute('aria-live', 'polite'); }
}
// 장면 시계를 dt 만큼 — 한 바퀴(LOOP_S)를 넘으면 처음 배치로 되돌려 다시 시연한다
function advance(dt: number): void {
sceneT += dt;
if (sceneT >= LOOP_S) {
sceneT -= LOOP_S;
reset();
}
update(dt);
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) { advance(dt); }
draw();
pushStatus();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 시계 다시 맞추기 — 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 동작 줄이기 — 루프를 돌리지 않고 시연이 끝난 최종 배치 한 장만 그린다
function showRest(): void {
stopLoop();
sceneT = 0;
reset();
restPose();
draw();
pushStatus();
}
function play(): void {
sceneT = 0;
reset();
draw();
pushStatus();
if (!paused) { startLoop(); }
}
pauseBtn.addEventListener('click', function (): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}, { signal: off.signal });
// 탭이 숨으면 루프를 멈추고, 돌아오면 시계를 다시 맞춰 시작한다 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 페이지를 연 채 바뀌면 — 켜지면 최종 배치로 멈추고, 꺼지면 시연을 처음부터
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { play(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
draw();
}, { signal: off.signal });
fitCanvas();
fillTable(TABLE_CAPTION, tableRows());
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("cf") + " is-demo"} ref={rootRef}>
<canvas className={cx("cf__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label="앞뒤로 두 번 훑어 여유가 0인 작업을 이어 빛내고 나머지에는 여유만큼 옅은 꼬리를 단 일정표."></canvas>
<table className={cx("cf__sr")} data-role="table"></table>
<button className={cx("cf__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
</div>
);
}03 · 앞뒤로 두 번 훑어 여유 0인 길을 빛내는 가장 긴 경로 — 측정 누적면적 34.583% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
for (const i of order) {
let s = minStart ? minStart[i] : 0;
for (const p of tasks[i].deps) {
s = Math.max(s, ef[p]);
}
es[i] = s;
ef[i] = s + tasks[i].dur;
}
let end = 0;
for (const i of order) {
end = Math.max(end, ef[i]);
}
const succ = successorsOf(tasks);
const ls = new Array(n).fill(0);
const lf = new Array(n).fill(0);
for (let k = order.length - 1; k >= 0; k -= 1) {
const i = order[k];
let f = end;
for (const j of succ[i]) {
f = Math.min(f, ls[j]);
}
lf[i] = f;
ls[i] = f - tasks[i].dur;
}
const slack = es.map(function (s, i) { return ls[i] - s; });
04한 작업이 밀리면 화살표를 따라 차례로 밀려나는 뒤 작업
0.3초에 디자인 시안이 사흘 늘어나면 pushFrom 이 뒤 작업을 가까운 것부터 넓게 훑어 새 시작을 앞 작업 끝 가운데 가장 늦은 날로 다시 정하고, 몇 단계 뒤인지(depth) × 60ms 만큼 늦게 단단함 210·감쇠 19 스프링으로 옮긴다. 화면 개발과 문서 작성은 사흘 그대로 밀리지만 기능 연동부터는 서버 개발 쪽 끝(13일)과 겨뤄 이틀만 밀려, 밀린 6개 끝의 출시 마름모는 22일차에서 24일차로 옮겨 가 장면 시작 1.037초에 자리를 굳힌다. 일정 변경 알림을 보내기 전 미리보기로 띄우면 받는 사람이 무엇이 어떤 순서로 며칠씩 밀렸는지를 한 장면으로 본다.

<main class="stage">
<div class="cf is-demo" id="cf">
<canvas class="cf__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="한 작업이 사흘 늦어지자 화살표를 따라 뒤 작업이 단계 순서대로 밀려나는 일정표."></canvas>
<table class="cf__sr" data-role="table"></table>
<p class="cf__status" data-role="status" aria-live="off">일정 그대로</p>
<button class="cf__pause" data-role="pause" type="button" aria-pressed="false">일시정지</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.cf {
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-cream;
}
.cf__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.cf__pause {
position: absolute;
right: $sp-3;
bottom: $sp-3;
padding: $sp-1 $sp-3;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.cf__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.cf__pause:hover {
transform: translateY(-2px);
}
.cf__pause:active {
transform: translateY(1px);
}
.cf__pause:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 일정에 무슨 일이 일어났는지 글자로. 시연 중에는 aria-live off, 사람이 처음 손대면 polite
.cf__status {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: calc(100% - 120px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
// 숨김 표 — 화면에서는 1px 로 접어 두고 화면 읽기 도구만 읽는다
.cf__sr {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
@media (max-width: 340px) {
.cf__pause,
.cf__status { font-size: 10px; padding: $sp-1 $sp-2; }
}
// 동작 줄이기 — 장면은 스크립트가 최종 배치 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.cf__pause { transition: none !important; }
.cf__pause:hover,
.cf__pause:active { transform: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cf {
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: #fff7e6;
}
.cf__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.cf__pause {
position: absolute;
right: 12px;
bottom: 12px;
padding: 4px 12px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.cf__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.cf__pause:hover {
transform: translateY(-2px);
}
.cf__pause:active {
transform: translateY(1px);
}
.cf__pause:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.cf__status {
position: absolute;
left: 12px;
bottom: 12px;
max-width: calc(100% - 120px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
.cf__sr {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
@media (max-width: 340px) {
.cf__pause,
.cf__status {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.cf__pause {
transition: none !important;
}
.cf__pause:hover,
.cf__pause:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("cf");
if (!root) return;
// 판 좌표 — 캔버스 안은 늘 480×300. 화면에 보이는 크기와 상관없이 계산은 이 좌표로 한다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
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); }
// 따라가기 비율 — 1 − e^(−rate·dt). 프레임 시간이 달라도 1초에 같은 만큼 다가간다
function follow(rate, dt) { return 1 - Math.exp(-rate * dt); }
// 시드 고정 난수 — 같은 시드면 같은 일정이 다시 나온다 (32비트 정수 섞기)
function makeRng(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function makeSpring(x) { return { x: x, v: 0, to: x }; }
const SPRING_SUBSTEP = 1 / 240;
function stepSpring(s, stiffness, damping, dt) {
let left = dt;
while (left > 1e-9) {
const h = Math.min(SPRING_SUBSTEP, left);
s.v += (stiffness * (s.to - s.x) - damping * s.v) * h;
s.x += s.v * h;
left -= h;
}
}
function snapSpring(s, x) { s.x = x; s.v = 0; s.to = x; }
// ── 날짜 = 정수 일수 ─────────────────────────────────────────
// 날짜 객체를 쓰지 않는다. 기준일을 0 으로 세는 정수 일수(day index)만 다루고, 화면의 월·일 숫자는 아래 월 길이 표로 산수해 만든다
const MONTH_DAYS = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
const YEAR_DAYS = 365;
// 기준일(일수 0) = 한 해의 70번째 날 → 표로 세면 3월 11일
const DAY_ZERO_OF_YEAR = 69;
function calendarOf(day) {
let r = ((day + DAY_ZERO_OF_YEAR) % YEAR_DAYS + YEAR_DAYS) % YEAR_DAYS;
let m = 0;
while (r >= MONTH_DAYS[m]) {
r -= MONTH_DAYS[m];
m += 1;
}
return [m + 1, r + 1];
}
const DAY_FMT = '{n}일차';
const WEEK_FMT = '{n}주차';
const MONTH_FMT = '{n}월';
const SLACK_FMT = '{n}일';
function dayText(day) { return DAY_FMT.replace('{n}', String(day + 1)); }
// 하루 차 계산의 함정 — 시각(시간 단위)끼리 빼서 24 로 나누면, 시계가 한 시간 당겨진 날이 끼었을 때 정수가 아니다(23시간짜리 하루).
// 그래서 바깥에서 들어온 시각은 반올림으로 정수 일수에 붙이고, 그다음부터는 정수끼리만 더하고 뺀다
const HOURS_PER_DAY = 24;
const CLOCK_SHIFT_HOURS = 1;
function hoursToDayIndex(hours) { return Math.round(hours / HOURS_PER_DAY); }
const BASE_TASKS = [
{ name: '요구 정리', dur: 3, deps: [], load: 2 },
{ name: '디자인 시안', dur: 4, deps: [0], load: 1 },
{ name: '데이터 설계', dur: 3, deps: [0], load: 1 },
{ name: '화면 개발', dur: 5, deps: [1], load: 2 },
{ name: '서버 개발', dur: 7, deps: [2], load: 1 },
{ name: '기능 연동', dur: 3, deps: [3, 4], load: 2 },
{ name: '문서 작성', dur: 3, deps: [1], load: 1 },
{ name: '통합 검수', dur: 3, deps: [5], load: 2 },
{ name: '오류 수정', dur: 2, deps: [7], load: 1 },
{ name: '출시', dur: 0, deps: [8, 6], load: 0 }
];
function copyTasks(ts) { return ts.map(function (t) { return { name: t.name, dur: t.dur, deps: t.deps.slice(), load: t.load }; }); }
function successorsOf(tasks) {
const out = tasks.map(function () { return []; });
tasks.forEach(function (t, i) { t.deps.forEach(function (p) { out[p].push(i); }); });
return out;
}
function sortByDeps(tasks) {
const succ = successorsOf(tasks);
const waiting = tasks.map(function (t) { return t.deps.length; });
const ready = [];
waiting.forEach(function (w, i) { if (w === 0) {
ready.push(i);
} });
const order = [];
while (ready.length) {
ready.sort(function (a, b) { return a - b; });
const i = ready.shift();
order.push(i);
succ[i].forEach(function (j) { waiting[j] -= 1; if (waiting[j] === 0) {
ready.push(j);
} });
}
const stuck = [];
waiting.forEach(function (w, i) { if (w > 0) {
stuck.push(i);
} });
return { order: order, stuck: stuck };
}
function sweepBoth(tasks, order, minStart) {
const n = tasks.length;
const es = new Array(n).fill(0);
const ef = new Array(n).fill(0);
for (const i of order) {
let s = minStart ? minStart[i] : 0;
for (const p of tasks[i].deps) {
s = Math.max(s, ef[p]);
}
es[i] = s;
ef[i] = s + tasks[i].dur;
}
let end = 0;
for (const i of order) {
end = Math.max(end, ef[i]);
}
const succ = successorsOf(tasks);
const ls = new Array(n).fill(0);
const lf = new Array(n).fill(0);
for (let k = order.length - 1; k >= 0; k -= 1) {
const i = order[k];
let f = end;
for (const j of succ[i]) {
f = Math.min(f, ls[j]);
}
lf[i] = f;
ls[i] = f - tasks[i].dur;
}
const slack = es.map(function (s, i) { return ls[i] - s; });
return { es: es, ef: ef, ls: ls, lf: lf, slack: slack, end: end };
}
function pushFrom(tasks, starts, from) {
const succ = successorsOf(tasks);
const out = starts.slice();
const depth = tasks.map(function () { return -1; });
depth[from] = 0;
const queue = [from];
let head = 0;
// 고리가 있어도 끝나도록 꺼내는 횟수에 상한을 둔다
const limit = tasks.length * tasks.length + 1;
while (head < queue.length && head < limit) {
const i = queue[head];
head += 1;
for (const j of succ[i]) {
let need = 0;
for (const p of tasks[j].deps) {
need = Math.max(need, out[p] + tasks[p].dur);
}
if (need > out[j]) {
out[j] = need;
depth[j] = Math.max(depth[j], depth[i] + 1);
queue.push(j);
}
}
}
return { starts: out, depth: depth };
}
// 날마다 걸린 일감 — 작업이 덮는 날(시작 ≤ 날 < 시작 + 일수)마다 사람 수를 더한다
function dailyLoad(tasks, starts, days) {
const load = new Array(days).fill(0);
tasks.forEach(function (t, i) {
for (let d = starts[i]; d < starts[i] + t.dur; d += 1) {
if (d >= 0 && d < days) {
load[d] += t.load;
}
}
});
return load;
}
// ── 직각 경로 ───────────────────────────────────────────────
// 앞 끝(ex, ey)에서 오른쪽으로 ROUTE_STUB 나간 뒤 아래로 꺾는다. 뒤 시작(sx)이 그 꺾인 자리 + ROUTE_STUB 보다 오른쪽이면 곧장 내려가 오른쪽으로 들어가고,
// 아니면 앞 행 바로 아래 통로(gapY)까지 내려가 왼쪽으로 돌아간 다음 뒤 시작보다 ROUTE_STUB 앞에서 내려가 오른쪽으로 들어간다
const ROUTE_STUB = 7;
function elbowRoute(ex, ey, sx, sy, gapY) {
const x1 = ex + ROUTE_STUB;
if (sx - ROUTE_STUB >= x1) {
return [ex, ey, x1, ey, x1, sy, sx, sy];
}
const x2 = sx - ROUTE_STUB;
return [ex, ey, x1, ey, x1, gapY, x2, gapY, x2, sy, sx, sy];
}
function routeLength(pts) {
let s = 0;
for (let i = 2; i < pts.length; i += 2) {
s += Math.hypot(pts[i] - pts[i - 2], pts[i + 1] - pts[i - 1]);
}
return s;
}
// 경로 위 거리 dist 자리의 점과 그 마디의 방향
function pointOnRoute(pts, dist) {
let left = Math.max(0, dist);
for (let i = 2; i < pts.length; i += 2) {
const dx = pts[i] - pts[i - 2];
const dy = pts[i + 1] - pts[i - 1];
const len = Math.hypot(dx, dy);
if (left <= len || i === pts.length - 2) {
const k = len > 0 ? Math.min(1, left / len) : 0;
return [pts[i - 2] + dx * k, pts[i - 1] + dy * k, len > 0 ? dx / len : 1, len > 0 ? dy / len : 0];
}
left -= len;
}
return [pts[0], pts[1], 1, 0];
}
// 경로를 앞에서부터 dist 만큼만 자른 점 목록 — 화살표가 그려져 나가는 장면에 쓴다
function cutRoute(pts, dist) {
const out = [pts[0], pts[1]];
let left = Math.max(0, dist);
for (let i = 2; i < pts.length; i += 2) {
const len = Math.hypot(pts[i] - pts[i - 2], pts[i + 1] - pts[i - 1]);
if (left >= len) {
out.push(pts[i], pts[i + 1]);
left -= len;
continue;
}
const k = len > 0 ? left / len : 0;
out.push(lerp(pts[i - 2], pts[i], k), lerp(pts[i - 1], pts[i + 1], k));
break;
}
return out;
}
// 장면 시계(초)와 상태 줄 글자 — 장면 계산이 고치고, 화면 쪽이 읽어 그린다
let sceneT = 0;
let simStatus = '';
// 파스텔 팔레트 — 크림 바탕 위 라벤더·민트·버터·산호 막대. 가장 긴 경로·오늘 선·글자는 진한 쪽으로 명도 차를 둔다
const PAPER = [251, 247, 238];
const PAPER_DIM = [243, 237, 224];
const GRID = [226, 218, 204];
const INK = [44, 38, 58];
const INK_SOFT = [112, 104, 126];
const CREAM = [255, 251, 242];
const LAVENDER = [190, 174, 236];
const MINT = [158, 214, 190];
const BUTTER = [244, 222, 146];
const CORAL = [244, 170, 150];
const SKY = [160, 204, 236];
const PINK = [242, 182, 206];
const PATH_DEEP = [86, 56, 168];
const TODAY_DEEP = [190, 72, 60];
const BAR_COLORS = [LAVENDER, MINT, BUTTER, CORAL, SKY, PINK];
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// 표 한 줄 — 작업·시작·끝·여유. 끝은 그 작업이 덮는 마지막 날(일수 0 짜리는 시작과 같은 날)
function tableRow(name, start, dur, slack) {
return [name, dayText(start), dayText(start + Math.max(0, dur - 1)), SLACK_FMT.replace('{n}', String(slack))];
}
const canvasEl = root.querySelector('[data-role="canvas"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const tableEl = root.querySelector('[data-role="table"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function clearStage() {
ctx.fillStyle = rgba(PAPER, 1);
ctx.fillRect(0, 0, W, H);
}
function label(s, x, y, c, a, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
function roundRectPath(x, y, w, h, r) {
const rr = Math.max(0, Math.min(r, w / 2, h / 2));
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.lineTo(x + w - rr, y);
ctx.arcTo(x + w, y, x + w, y + rr, rr);
ctx.lineTo(x + w, y + h - rr);
ctx.arcTo(x + w, y + h, x + w - rr, y + h, rr);
ctx.lineTo(x + rr, y + h);
ctx.arcTo(x, y + h, x, y + h - rr, rr);
ctx.lineTo(x, y + rr);
ctx.arcTo(x, y, x + rr, y, rr);
ctx.closePath();
}
// 막대 하나 — 둥근 사각형 채움 + 진한 테두리. 폭이 0 이하이면 그리지 않는다
function bar(x, y, w, h, fill, a, line) {
if (w <= 0.2 || a <= 0) {
return;
}
roundRectPath(x, y, w, h, 4);
ctx.fillStyle = rgba(fill, a);
ctx.fill();
if (line > 0) {
ctx.lineWidth = line;
ctx.strokeStyle = rgba(INK, 0.55 * a);
ctx.stroke();
}
}
// 마일스톤 마름모
function diamond(cx, cy, r, fill, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.moveTo(cx, cy - r);
ctx.lineTo(cx + r, cy);
ctx.lineTo(cx, cy + r);
ctx.lineTo(cx - r, cy);
ctx.closePath();
ctx.fillStyle = rgba(fill, a);
ctx.fill();
ctx.lineWidth = 1.5;
ctx.strokeStyle = rgba(INK, 0.8 * a);
ctx.stroke();
}
function polyline(pts, c, a, width) {
if (pts.length < 4) {
return;
}
ctx.beginPath();
ctx.moveTo(pts[0], pts[1]);
for (let i = 2; i < pts.length; i += 2) {
ctx.lineTo(pts[i], pts[i + 1]);
}
ctx.lineWidth = width;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function arrowHead(x, y, dx, dy, size, c, a) {
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x - dx * size - dy * size * 0.6, y - dy * size + dx * size * 0.6);
ctx.lineTo(x - dx * size + dy * size * 0.6, y - dy * size - dx * size * 0.6);
ctx.closePath();
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
// 빗금 — (x, y, w, h) 안에 45° 줄을 spacing 간격으로. 간격이 좁을수록 짙어 보인다
function hatch(x, y, w, h, spacing, c, a) {
if (w <= 0.5 || a <= 0) {
return;
}
ctx.save();
roundRectPath(x, y, w, h, 3);
ctx.clip();
ctx.beginPath();
for (let s = x - h; s < x + w; s += Math.max(2, spacing)) {
ctx.moveTo(s, y + h);
ctx.lineTo(s + h, y);
}
ctx.lineWidth = 1.6;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
ctx.restore();
}
// 시간 축 — 위 띠는 월 이름, 아래 띠는 일(월 안의 날 숫자)·주(주차) 층. 층마다 불투명도를 따로 받아 겹쳐 흐려지며 바뀌게 한다.
// 세로 격자도 같은 불투명도로 행 영역(top~bottom)에 긋는다. xOf 는 일수 → 화면 x
function timeAxis(xOf, dayW, d0, d1, left, right, top, bottom, ly) {
ctx.save();
ctx.beginPath();
ctx.rect(left, 0, right - left, H);
ctx.clip();
ctx.fillStyle = rgba(PAPER_DIM, 1);
ctx.fillRect(left, 0, right - left, top - 4);
for (let d = Math.floor(d0); d <= Math.ceil(d1); d += 1) {
const x = xOf(d);
const cal = calendarOf(d);
if (ly.day > 0.01) {
ctx.fillStyle = rgba(GRID, 0.7 * ly.day);
ctx.fillRect(x, top, 1, bottom - top);
if (dayW >= 13) {
label(String(cal[1]), x + dayW / 2, top - 11, INK_SOFT, ly.day, 9, 600, 'center');
}
}
// 주 층 — 글자가 겹치지 않을 만큼(36px) 주를 건너뛰며 적는다
const weekStep = 7 * dayW >= 36 ? 1 : (14 * dayW >= 36 ? 2 : 4);
if (ly.week > 0.01 && d % 7 === 0) {
ctx.fillStyle = rgba(GRID, ly.week);
ctx.fillRect(x, top, 1, bottom - top);
if ((d / 7) % weekStep === 0) {
label(WEEK_FMT.replace('{n}', String(d / 7 + 1)), x + 3, top - 11, INK_SOFT, ly.week, 9, 600, 'left');
}
}
if (cal[1] === 1) {
ctx.fillStyle = rgba(INK_SOFT, 0.18 + 0.4 * ly.month);
ctx.fillRect(x, 4, 1, bottom - 4);
label(MONTH_FMT.replace('{n}', String(cal[0])), x + 4, 11, INK, 0.85, 10, 700, 'left');
// 월 층 — 그달 칸 가운데에 그달 일수(월 길이 표에서)를 적는다
const len = MONTH_DAYS[cal[0] - 1];
if (ly.month > 0.01 && len * dayW >= 30) {
label(SLACK_FMT.replace('{n}', String(len)), x + len * dayW / 2, top - 11, INK_SOFT, ly.month, 9, 700, 'center');
}
}
else if (d === Math.floor(d0)) {
// 왼쪽 끝 월 이름 — 바로 오른쪽에 새 달이 시작하면 겹치므로 적지 않는다
const toNext = MONTH_DAYS[cal[0] - 1] - cal[1] + 1;
if (toNext * dayW >= 44) {
label(MONTH_FMT.replace('{n}', String(cal[0])), Math.max(x, left) + 4, 11, INK, 0.85, 10, 700, 'left');
}
}
}
ctx.restore();
}
// 왼쪽 작업 이름 칸
function rowLabel(name, y, a, bold) {
label(name, 10, y, INK, a, 11, bold ? 800 : 600, 'left');
}
function rowStripe(y, h, i, a) {
if (i % 2 === 1) {
ctx.fillStyle = rgba(PAPER_DIM, 0.75 * a);
ctx.fillRect(0, y, W, h);
}
}
// 움직이는 행 빛 — 그 행 막대의 스프링 속도(k = 0~1)만큼 행 전체가 옅은 라벤더로 밝아진다. 막대가 서면 사라진다
function rowGlow(y, h, k) {
if (k <= 0.01) {
return;
}
ctx.fillStyle = rgba(LAVENDER, 0.42 * clamp(k, 0, 1));
ctx.fillRect(0, y, W, h);
}
// 상태 줄 — 바뀔 때만 글자를 고친다
let shownStatus = '';
function pushStatus() {
if (!statusEl || simStatus === shownStatus) {
return;
}
shownStatus = simStatus;
statusEl.textContent = simStatus;
}
// 숨김 표 — 같은 일정을 작업·시작·끝·여유 열로 읽게. 화면에는 안 보이고 화면 읽기 도구가 읽는다
const TABLE_HEAD = ['작업 이름', '시작일', '끝나는 날', '여유 일수'];
function fillTable(caption, rows) {
if (!tableEl) {
return;
}
const tb = tableEl;
while (tb.firstChild) {
tb.removeChild(tb.firstChild);
}
const cap = document.createElement('caption');
cap.textContent = caption;
tb.appendChild(cap);
const thead = document.createElement('thead');
const hr = document.createElement('tr');
TABLE_HEAD.forEach(function (h) {
const th = document.createElement('th');
th.scope = 'col';
th.textContent = h;
hr.appendChild(th);
});
thead.appendChild(hr);
tb.appendChild(thead);
const body = document.createElement('tbody');
rows.forEach(function (r) {
const tr = document.createElement('tr');
r.forEach(function (c, k) {
const cell = document.createElement(k === 0 ? 'th' : 'td');
if (k === 0) {
cell.scope = 'row';
}
cell.textContent = c;
tr.appendChild(cell);
});
body.appendChild(tr);
});
tb.appendChild(body);
}
// 04 — 연쇄 밀기. 디자인 시안이 사흘 늦어지면 pushFrom 이 뒤 작업을 너비 우선으로 훑어 새 시작일을 정하고, 막대는 단계 깊이 × PUSH_STAGGER_MS 늦게 스프링으로 옮긴다
const LABEL_W = 96;
const PLOT_X = LABEL_W + 6;
const DAY_WIDTH = 15;
const ROWS_TOP = 46;
const ROW_H = 21;
const BAR_H = 12;
const SLIP_TASK = 1;
const SLIP_DAYS = 3;
const SLIP_AT_S = 0.3;
const RESTORE_AT_S = 4.8;
// 한 단계 뒤로 갈 때마다 늦게 출발하는 시간
const PUSH_STAGGER_MS = 60;
// 막대 이동 스프링(일수 단위)
const PUSH_STIFFNESS = 210;
const PUSH_DAMPING = 19;
const LOOP_S = 7.6;
const TABLE_CAPTION = '작업 일정표 — 디자인 시안이 사흘 늦어진 뒤';
const STATUS_IDLE = '일정 그대로';
const STATUS_PUSHED = '{task} {d}일 지연 → {n}개 밀림 · 출시 {a} → {b}';
const tasks04 = BASE_TASKS;
const order04 = sortByDeps(tasks04).order;
const before04 = sweepBoth(tasks04, order04, null);
// 늦어진 일정 — 작업 일수만 늘린 사본에서 밀린 작업부터 전파한다
const slipped04 = (function () { const c = copyTasks(tasks04); c[SLIP_TASK].dur += SLIP_DAYS; return c; })();
const push04 = pushFrom(slipped04, before04.es, SLIP_TASK);
const after04 = sweepBoth(slipped04, order04, null);
const moved04 = push04.depth.filter(function (d, i) { return d > 0 && push04.starts[i] !== before04.es[i]; }).length;
const lastTask04 = tasks04.length - 1;
// 막대마다 시작(일수)·길이(일수) 스프링
const startS04 = tasks04.map(function (_, i) { return makeSpring(before04.es[i]); });
const durS04 = tasks04.map(function (t) { return makeSpring(t.dur); });
function departAt(i, base) { return base + Math.max(0, push04.depth[i]) * PUSH_STAGGER_MS / 1000; }
function reset() {
tasks04.forEach(function (t, i) { snapSpring(startS04[i], before04.es[i]); snapSpring(durS04[i], t.dur); });
simStatus = STATUS_IDLE;
}
function update(dt) {
let slipped = false;
tasks04.forEach(function (t, i) {
const on = push04.depth[i] >= 0 && sceneT >= departAt(i, SLIP_AT_S) && sceneT < departAt(i, RESTORE_AT_S);
if (push04.depth[i] >= 0 && sceneT >= SLIP_AT_S && sceneT < RESTORE_AT_S) {
slipped = true;
}
startS04[i].to = on ? push04.starts[i] : before04.es[i];
durS04[i].to = on ? slipped04[i].dur : t.dur;
stepSpring(startS04[i], PUSH_STIFFNESS, PUSH_DAMPING, dt);
stepSpring(durS04[i], PUSH_STIFFNESS, PUSH_DAMPING, dt);
});
simStatus = slipped ? STATUS_PUSHED.replace('{task}', tasks04[SLIP_TASK].name).replace('{d}', String(SLIP_DAYS)).replace('{n}', String(moved04))
.replace('{a}', dayText(before04.es[lastTask04])).replace('{b}', dayText(push04.starts[lastTask04])) : STATUS_IDLE;
}
function restPose() {
sceneT = SLIP_AT_S + 2;
tasks04.forEach(function (_, i) {
snapSpring(startS04[i], push04.depth[i] >= 0 ? push04.starts[i] : before04.es[i]);
snapSpring(durS04[i], slipped04[i].dur);
});
update(0);
}
function tableRows() {
return slipped04.map(function (t, i) { return tableRow(t.name, push04.starts[i], t.dur, after04.slack[i]); });
}
function dayX(d) { return PLOT_X + d * DAY_WIDTH; }
function rowMid(i) { return ROWS_TOP + i * ROW_H + ROW_H / 2; }
function draw() {
clearStage();
const bottom = ROWS_TOP + tasks04.length * ROW_H;
tasks04.forEach(function (_, i) { rowStripe(ROWS_TOP + i * ROW_H, ROW_H, i, 1); rowGlow(ROWS_TOP + i * ROW_H, ROW_H, Math.max(Math.abs(startS04[i].v), Math.abs(durS04[i].v)) / 10); });
timeAxis(dayX, DAY_WIDTH, 0, 25, PLOT_X, W - 8, ROWS_TOP, bottom, { day: 1, week: 0, month: 0 });
// 화살표 — 지금 스프링 위치로 매 프레임 길을 다시 고른다
tasks04.forEach(function (t, j) {
t.deps.forEach(function (p) {
const ex = dayX(startS04[p].x + durS04[p].x);
const sx = dayX(startS04[j].x) - (t.dur === 0 ? 7 : 0);
const pts = elbowRoute(ex, rowMid(p), sx, rowMid(j), ROWS_TOP + (p + 1) * ROW_H);
const hot = push04.depth[j] > 0 && Math.abs(startS04[j].x - startS04[j].to) > 0.05;
polyline(pts, hot ? TODAY_DEEP : INK_SOFT, hot ? 0.9 : 0.55, hot ? 2 : 1.4);
const tip = pointOnRoute(pts, routeLength(pts));
arrowHead(tip[0], tip[1], tip[2], tip[3], 5, hot ? TODAY_DEEP : INK_SOFT, 0.8);
});
});
tasks04.forEach(function (t, i) {
const cy = rowMid(i);
rowLabel(t.name, cy, 1, push04.depth[i] >= 0 && startS04[i].to !== before04.es[i]);
const s = startS04[i].x;
const shift = s - before04.es[i];
// 원래 자리 — 밀려난 만큼 점선 윤곽으로 남긴다
if (shift > 0.08) {
ctx.save();
ctx.setLineDash([3, 3]);
if (t.dur === 0) {
diamond(dayX(before04.es[i]), cy, 6, PAPER, 0.9);
}
else {
roundRectPath(dayX(before04.es[i]), cy - BAR_H / 2, t.dur * DAY_WIDTH, BAR_H, 4);
ctx.lineWidth = 1.2;
ctx.strokeStyle = rgba(INK_SOFT, 0.8);
ctx.stroke();
}
ctx.restore();
}
const c = BAR_COLORS[i % BAR_COLORS.length];
if (t.dur === 0) {
diamond(dayX(s), cy, 7, PATH_DEEP, 1);
}
else {
// 이동 중에는 막대가 속도만큼 살짝 기울어진 그림자를 끈다
const v = startS04[i].v;
bar(dayX(s) - v * 0.8, cy - BAR_H / 2 + 2, t.dur * DAY_WIDTH + Math.abs(v) * 0.8, BAR_H, INK, clamp(Math.abs(v) * 0.03, 0, 0.18), 0);
bar(dayX(s), cy - BAR_H / 2, durS04[i].x * DAY_WIDTH, BAR_H, c, 1, 1.2);
}
// 늘어난 날 — 늦어진 작업의 덧붙은 일수에 빗금
if (i === SLIP_TASK && durS04[i].x > t.dur + 0.05) {
hatch(dayX(s + t.dur), cy - BAR_H / 2, (durS04[i].x - t.dur) * DAY_WIDTH, BAR_H, 4, TODAY_DEEP, 0.85);
}
if (shift > 0.3) {
label('+' + SLACK_FMT.replace('{n}', String(Math.round(shift))), dayX(s + Math.max(t.dur, durS04[i].x)) + (t.dur === 0 ? 10 : 4), cy, TODAY_DEEP, smooth(shift / 0.8), 10, 800, 'left');
}
});
}
// 한 프레임에 넘기는 시간 상한(초) — 탭을 오래 숨겼다 돌아온 첫 프레임에도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 손대면 시연 표시를 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
// 리스너는 한 묶음으로 달고 정리할 때 한 번에 뗀다
const off = new AbortController();
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
if (statusEl) {
statusEl.setAttribute('aria-live', 'polite');
}
}
// 장면 시계를 dt 만큼 — 한 바퀴(LOOP_S)를 넘으면 처음 배치로 되돌려 다시 시연한다
function advance(dt) {
sceneT += dt;
if (sceneT >= LOOP_S) {
sceneT -= LOOP_S;
reset();
}
update(dt);
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
advance(dt);
}
draw();
pushStatus();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 시계 다시 맞추기 — 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 동작 줄이기 — 루프를 돌리지 않고 시연이 끝난 최종 배치 한 장만 그린다
function showRest() {
stopLoop();
sceneT = 0;
reset();
restPose();
draw();
pushStatus();
}
function play() {
sceneT = 0;
reset();
draw();
pushStatus();
if (!paused) {
startLoop();
}
}
pauseBtn.addEventListener('click', function () {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}, { signal: off.signal });
// 탭이 숨으면 루프를 멈추고, 돌아오면 시계를 다시 맞춰 시작한다 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 페이지를 연 채 바뀌면 — 켜지면 최종 배치로 멈추고, 꺼지면 시연을 처음부터
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
play();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
draw();
}, { signal: off.signal });
fitCanvas();
fillTable(TABLE_CAPTION, tableRows());
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// CascadePushSpring - 같은 항목 vanilla 판 index.html 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./CascadePushSpring.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function CascadePushSpring() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
type Pt = [number, number];
// 판 좌표 — 캔버스 안은 늘 480×300. 화면에 보이는 크기와 상관없이 계산은 이 좌표로 한다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
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); }
// 따라가기 비율 — 1 − e^(−rate·dt). 프레임 시간이 달라도 1초에 같은 만큼 다가간다
function follow(rate: number, dt: number): number { return 1 - Math.exp(-rate * dt); }
// 시드 고정 난수 — 같은 시드면 같은 일정이 다시 나온다 (32비트 정수 섞기)
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// ── 감쇠 스프링 ──────────────────────────────────────────────
// 가속 = 단단함 × (목표 − 위치) − 감쇠 × 속도. 속도를 먼저 고친 뒤 위치를 옮기는 적분을 1/240초 잔걸음으로 나눠 돌린다
type Spring = { x: number; v: number; to: number };
function makeSpring(x: number): Spring { return { x: x, v: 0, to: x }; }
const SPRING_SUBSTEP = 1 / 240;
function stepSpring(s: Spring, stiffness: number, damping: number, dt: number): void {
let left = dt;
while (left > 1e-9) {
const h = Math.min(SPRING_SUBSTEP, left);
s.v += (stiffness * (s.to - s.x) - damping * s.v) * h;
s.x += s.v * h;
left -= h;
}
}
function snapSpring(s: Spring, x: number): void { s.x = x; s.v = 0; s.to = x; }
// ── 날짜 = 정수 일수 ─────────────────────────────────────────
// 날짜 객체를 쓰지 않는다. 기준일을 0 으로 세는 정수 일수(day index)만 다루고, 화면의 월·일 숫자는 아래 월 길이 표로 산수해 만든다
const MONTH_DAYS = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
const YEAR_DAYS = 365;
// 기준일(일수 0) = 한 해의 70번째 날 → 표로 세면 3월 11일
const DAY_ZERO_OF_YEAR = 69;
function calendarOf(day: number): [number, number] {
let r = ((day + DAY_ZERO_OF_YEAR) % YEAR_DAYS + YEAR_DAYS) % YEAR_DAYS;
let m = 0;
while (r >= MONTH_DAYS[m]) { r -= MONTH_DAYS[m]; m += 1; }
return [m + 1, r + 1];
}
const DAY_FMT = '{n}일차';
const WEEK_FMT = '{n}주차';
const MONTH_FMT = '{n}월';
const SLACK_FMT = '{n}일';
function dayText(day: number): string { return DAY_FMT.replace('{n}', String(day + 1)); }
// 하루 차 계산의 함정 — 시각(시간 단위)끼리 빼서 24 로 나누면, 시계가 한 시간 당겨진 날이 끼었을 때 정수가 아니다(23시간짜리 하루).
// 그래서 바깥에서 들어온 시각은 반올림으로 정수 일수에 붙이고, 그다음부터는 정수끼리만 더하고 뺀다
const HOURS_PER_DAY = 24;
const CLOCK_SHIFT_HOURS = 1;
function hoursToDayIndex(hours: number): number { return Math.round(hours / HOURS_PER_DAY); }
// ── 작업과 의존 ──────────────────────────────────────────────
// dur 는 일수(0 이면 마일스톤), deps 는 끝나야 시작할 수 있는 앞 작업 번호, load 는 그 작업에 붙는 사람 수
type Task = { name: string; dur: number; deps: number[]; load: number };
const BASE_TASKS: Task[] = [
{ name: '요구 정리', dur: 3, deps: [], load: 2 },
{ name: '디자인 시안', dur: 4, deps: [0], load: 1 },
{ name: '데이터 설계', dur: 3, deps: [0], load: 1 },
{ name: '화면 개발', dur: 5, deps: [1], load: 2 },
{ name: '서버 개발', dur: 7, deps: [2], load: 1 },
{ name: '기능 연동', dur: 3, deps: [3, 4], load: 2 },
{ name: '문서 작성', dur: 3, deps: [1], load: 1 },
{ name: '통합 검수', dur: 3, deps: [5], load: 2 },
{ name: '오류 수정', dur: 2, deps: [7], load: 1 },
{ name: '출시', dur: 0, deps: [8, 6], load: 0 }
];
function copyTasks(ts: Task[]): Task[] { return ts.map(function (t: Task): Task { return { name: t.name, dur: t.dur, deps: t.deps.slice(), load: t.load }; }); }
function successorsOf(tasks: Task[]): number[][] {
const out: number[][] = tasks.map(function (): number[] { return []; });
tasks.forEach(function (t: Task, i: number): void { t.deps.forEach(function (p: number): void { out[p].push(i); }); });
return out;
}
// 선후 순서로 줄 세우기 — 앞 작업이 하나도 안 남은(들어오는 화살표 0) 작업부터 꺼내고, 꺼낼 때마다 뒤 작업의 남은 수를 하나 줄인다.
// 꺼낼 것이 사라졌는데 남은 작업이 있으면 그 작업들은 서로를 기다리는 고리(순환 의존)에 걸렸거나 그 뒤에 매달린 것이다 → stuck
type Order = { order: number[]; stuck: number[] };
function sortByDeps(tasks: Task[]): Order {
const succ = successorsOf(tasks);
const waiting = tasks.map(function (t: Task): number { return t.deps.length; });
const ready: number[] = [];
waiting.forEach(function (w: number, i: number): void { if (w === 0) { ready.push(i); } });
const order: number[] = [];
while (ready.length) {
ready.sort(function (a: number, b: number): number { return a - b; });
const i = ready.shift() as number;
order.push(i);
succ[i].forEach(function (j: number): void { waiting[j] -= 1; if (waiting[j] === 0) { ready.push(j); } });
}
const stuck: number[] = [];
waiting.forEach(function (w: number, i: number): void { if (w > 0) { stuck.push(i); } });
return { order: order, stuck: stuck };
}
// 앞뒤 두 번 훑기 — 앞에서: 가장 이른 시작 = 앞 작업 끝 중 가장 늦은 날(과 정해 둔 시작일 중 늦은 쪽).
// 뒤에서: 가장 늦은 끝 = 뒤 작업의 가장 늦은 시작 중 가장 이른 날(뒤 작업이 없으면 전체 끝). 여유 = 가장 늦은 시작 − 가장 이른 시작
type Layers = { day: number; week: number; month: number };
type Sweep = { es: number[]; ef: number[]; ls: number[]; lf: number[]; slack: number[]; end: number };
function sweepBoth(tasks: Task[], order: number[], minStart: number[] | null): Sweep {
const n = tasks.length;
const es: number[] = new Array(n).fill(0);
const ef: number[] = new Array(n).fill(0);
for (const i of order) {
let s = minStart ? minStart[i] : 0;
for (const p of tasks[i].deps) { s = Math.max(s, ef[p]); }
es[i] = s;
ef[i] = s + tasks[i].dur;
}
let end = 0;
for (const i of order) { end = Math.max(end, ef[i]); }
const succ = successorsOf(tasks);
const ls: number[] = new Array(n).fill(0);
const lf: number[] = new Array(n).fill(0);
for (let k = order.length - 1; k >= 0; k -= 1) {
const i = order[k];
let f = end;
for (const j of succ[i]) { f = Math.min(f, ls[j]); }
lf[i] = f;
ls[i] = f - tasks[i].dur;
}
const slack = es.map(function (s: number, i: number): number { return ls[i] - s; });
return { es: es, ef: ef, ls: ls, lf: lf, slack: slack, end: end };
}
// 밀기 전파 — 밀린 작업에서 가까운 뒤 작업부터 너비 우선으로 훑으며, 새 시작 = 앞 작업 끝 중 가장 늦은 날(지금보다 늦을 때만).
// depth 는 밀린 작업에서 몇 단계 뒤인지(-1 이면 여유가 흡수해 안 밀림). 시작이 바뀐 작업만 다시 큐에 넣는다
type Push = { starts: number[]; depth: number[] };
function pushFrom(tasks: Task[], starts: number[], from: number): Push {
const succ = successorsOf(tasks);
const out = starts.slice();
const depth: number[] = tasks.map(function (): number { return -1; });
depth[from] = 0;
const queue: number[] = [from];
let head = 0;
// 고리가 있어도 끝나도록 꺼내는 횟수에 상한을 둔다
const limit = tasks.length * tasks.length + 1;
while (head < queue.length && head < limit) {
const i = queue[head];
head += 1;
for (const j of succ[i]) {
let need = 0;
for (const p of tasks[j].deps) { need = Math.max(need, out[p] + tasks[p].dur); }
if (need > out[j]) {
out[j] = need;
depth[j] = Math.max(depth[j], depth[i] + 1);
queue.push(j);
}
}
}
return { starts: out, depth: depth };
}
// 날마다 걸린 일감 — 작업이 덮는 날(시작 ≤ 날 < 시작 + 일수)마다 사람 수를 더한다
function dailyLoad(tasks: Task[], starts: number[], days: number): number[] {
const load: number[] = new Array(days).fill(0);
tasks.forEach(function (t: Task, i: number): void {
for (let d = starts[i]; d < starts[i] + t.dur; d += 1) { if (d >= 0 && d < days) { load[d] += t.load; } }
});
return load;
}
// ── 직각 경로 ───────────────────────────────────────────────
// 앞 끝(ex, ey)에서 오른쪽으로 ROUTE_STUB 나간 뒤 아래로 꺾는다. 뒤 시작(sx)이 그 꺾인 자리 + ROUTE_STUB 보다 오른쪽이면 곧장 내려가 오른쪽으로 들어가고,
// 아니면 앞 행 바로 아래 통로(gapY)까지 내려가 왼쪽으로 돌아간 다음 뒤 시작보다 ROUTE_STUB 앞에서 내려가 오른쪽으로 들어간다
const ROUTE_STUB = 7;
function elbowRoute(ex: number, ey: number, sx: number, sy: number, gapY: number): number[] {
const x1 = ex + ROUTE_STUB;
if (sx - ROUTE_STUB >= x1) { return [ex, ey, x1, ey, x1, sy, sx, sy]; }
const x2 = sx - ROUTE_STUB;
return [ex, ey, x1, ey, x1, gapY, x2, gapY, x2, sy, sx, sy];
}
function routeLength(pts: number[]): number {
let s = 0;
for (let i = 2; i < pts.length; i += 2) { s += Math.hypot(pts[i] - pts[i - 2], pts[i + 1] - pts[i - 1]); }
return s;
}
// 경로 위 거리 dist 자리의 점과 그 마디의 방향
function pointOnRoute(pts: number[], dist: number): [number, number, number, number] {
let left = Math.max(0, dist);
for (let i = 2; i < pts.length; i += 2) {
const dx = pts[i] - pts[i - 2];
const dy = pts[i + 1] - pts[i - 1];
const len = Math.hypot(dx, dy);
if (left <= len || i === pts.length - 2) {
const k = len > 0 ? Math.min(1, left / len) : 0;
return [pts[i - 2] + dx * k, pts[i - 1] + dy * k, len > 0 ? dx / len : 1, len > 0 ? dy / len : 0];
}
left -= len;
}
return [pts[0], pts[1], 1, 0];
}
// 경로를 앞에서부터 dist 만큼만 자른 점 목록 — 화살표가 그려져 나가는 장면에 쓴다
function cutRoute(pts: number[], dist: number): number[] {
const out: number[] = [pts[0], pts[1]];
let left = Math.max(0, dist);
for (let i = 2; i < pts.length; i += 2) {
const len = Math.hypot(pts[i] - pts[i - 2], pts[i + 1] - pts[i - 1]);
if (left >= len) { out.push(pts[i], pts[i + 1]); left -= len; continue; }
const k = len > 0 ? left / len : 0;
out.push(lerp(pts[i - 2], pts[i], k), lerp(pts[i - 1], pts[i + 1], k));
break;
}
return out;
}
// 장면 시계(초)와 상태 줄 글자 — 장면 계산이 고치고, 화면 쪽이 읽어 그린다
let sceneT = 0;
let simStatus = '';
// 파스텔 팔레트 — 크림 바탕 위 라벤더·민트·버터·산호 막대. 가장 긴 경로·오늘 선·글자는 진한 쪽으로 명도 차를 둔다
const PAPER: RGB = [251, 247, 238];
const PAPER_DIM: RGB = [243, 237, 224];
const GRID: RGB = [226, 218, 204];
const INK: RGB = [44, 38, 58];
const INK_SOFT: RGB = [112, 104, 126];
const CREAM: RGB = [255, 251, 242];
const LAVENDER: RGB = [190, 174, 236];
const MINT: RGB = [158, 214, 190];
const BUTTER: RGB = [244, 222, 146];
const CORAL: RGB = [244, 170, 150];
const SKY: RGB = [160, 204, 236];
const PINK: RGB = [242, 182, 206];
const PATH_DEEP: RGB = [86, 56, 168];
const TODAY_DEEP: RGB = [190, 72, 60];
const BAR_COLORS: RGB[] = [LAVENDER, MINT, BUTTER, CORAL, SKY, PINK];
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// 표 한 줄 — 작업·시작·끝·여유. 끝은 그 작업이 덮는 마지막 날(일수 0 짜리는 시작과 같은 날)
function tableRow(name: string, start: number, dur: number, slack: number): string[] {
return [name, dayText(start), dayText(start + Math.max(0, dur - 1)), SLACK_FMT.replace('{n}', String(slack))];
}
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const tableEl = root.querySelector('[data-role="table"]') as HTMLTableElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function clearStage(): void {
ctx.fillStyle = rgba(PAPER, 1);
ctx.fillRect(0, 0, W, H);
}
function label(s: string, x: number, y: number, c: RGB, a: number, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
function roundRectPath(x: number, y: number, w: number, h: number, r: number): void {
const rr = Math.max(0, Math.min(r, w / 2, h / 2));
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.lineTo(x + w - rr, y);
ctx.arcTo(x + w, y, x + w, y + rr, rr);
ctx.lineTo(x + w, y + h - rr);
ctx.arcTo(x + w, y + h, x + w - rr, y + h, rr);
ctx.lineTo(x + rr, y + h);
ctx.arcTo(x, y + h, x, y + h - rr, rr);
ctx.lineTo(x, y + rr);
ctx.arcTo(x, y, x + rr, y, rr);
ctx.closePath();
}
// 막대 하나 — 둥근 사각형 채움 + 진한 테두리. 폭이 0 이하이면 그리지 않는다
function bar(x: number, y: number, w: number, h: number, fill: RGB, a: number, line: number): void {
if (w <= 0.2 || a <= 0) { return; }
roundRectPath(x, y, w, h, 4);
ctx.fillStyle = rgba(fill, a);
ctx.fill();
if (line > 0) {
ctx.lineWidth = line;
ctx.strokeStyle = rgba(INK, 0.55 * a);
ctx.stroke();
}
}
// 마일스톤 마름모
function diamond(cx: number, cy: number, r: number, fill: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.moveTo(cx, cy - r);
ctx.lineTo(cx + r, cy);
ctx.lineTo(cx, cy + r);
ctx.lineTo(cx - r, cy);
ctx.closePath();
ctx.fillStyle = rgba(fill, a);
ctx.fill();
ctx.lineWidth = 1.5;
ctx.strokeStyle = rgba(INK, 0.8 * a);
ctx.stroke();
}
function polyline(pts: number[], c: RGB, a: number, width: number): void {
if (pts.length < 4) { return; }
ctx.beginPath();
ctx.moveTo(pts[0], pts[1]);
for (let i = 2; i < pts.length; i += 2) { ctx.lineTo(pts[i], pts[i + 1]); }
ctx.lineWidth = width;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function arrowHead(x: number, y: number, dx: number, dy: number, size: number, c: RGB, a: number): void {
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x - dx * size - dy * size * 0.6, y - dy * size + dx * size * 0.6);
ctx.lineTo(x - dx * size + dy * size * 0.6, y - dy * size - dx * size * 0.6);
ctx.closePath();
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
// 빗금 — (x, y, w, h) 안에 45° 줄을 spacing 간격으로. 간격이 좁을수록 짙어 보인다
function hatch(x: number, y: number, w: number, h: number, spacing: number, c: RGB, a: number): void {
if (w <= 0.5 || a <= 0) { return; }
ctx.save();
roundRectPath(x, y, w, h, 3);
ctx.clip();
ctx.beginPath();
for (let s = x - h; s < x + w; s += Math.max(2, spacing)) {
ctx.moveTo(s, y + h);
ctx.lineTo(s + h, y);
}
ctx.lineWidth = 1.6;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
ctx.restore();
}
// 시간 축 — 위 띠는 월 이름, 아래 띠는 일(월 안의 날 숫자)·주(주차) 층. 층마다 불투명도를 따로 받아 겹쳐 흐려지며 바뀌게 한다.
// 세로 격자도 같은 불투명도로 행 영역(top~bottom)에 긋는다. xOf 는 일수 → 화면 x
function timeAxis(xOf: (d: number) => number, dayW: number, d0: number, d1: number, left: number, right: number, top: number, bottom: number, ly: Layers): void {
ctx.save();
ctx.beginPath();
ctx.rect(left, 0, right - left, H);
ctx.clip();
ctx.fillStyle = rgba(PAPER_DIM, 1);
ctx.fillRect(left, 0, right - left, top - 4);
for (let d = Math.floor(d0); d <= Math.ceil(d1); d += 1) {
const x = xOf(d);
const cal = calendarOf(d);
if (ly.day > 0.01) {
ctx.fillStyle = rgba(GRID, 0.7 * ly.day);
ctx.fillRect(x, top, 1, bottom - top);
if (dayW >= 13) { label(String(cal[1]), x + dayW / 2, top - 11, INK_SOFT, ly.day, 9, 600, 'center'); }
}
// 주 층 — 글자가 겹치지 않을 만큼(36px) 주를 건너뛰며 적는다
const weekStep = 7 * dayW >= 36 ? 1 : (14 * dayW >= 36 ? 2 : 4);
if (ly.week > 0.01 && d % 7 === 0) {
ctx.fillStyle = rgba(GRID, ly.week);
ctx.fillRect(x, top, 1, bottom - top);
if ((d / 7) % weekStep === 0) { label(WEEK_FMT.replace('{n}', String(d / 7 + 1)), x + 3, top - 11, INK_SOFT, ly.week, 9, 600, 'left'); }
}
if (cal[1] === 1) {
ctx.fillStyle = rgba(INK_SOFT, 0.18 + 0.4 * ly.month);
ctx.fillRect(x, 4, 1, bottom - 4);
label(MONTH_FMT.replace('{n}', String(cal[0])), x + 4, 11, INK, 0.85, 10, 700, 'left');
// 월 층 — 그달 칸 가운데에 그달 일수(월 길이 표에서)를 적는다
const len = MONTH_DAYS[cal[0] - 1];
if (ly.month > 0.01 && len * dayW >= 30) { label(SLACK_FMT.replace('{n}', String(len)), x + len * dayW / 2, top - 11, INK_SOFT, ly.month, 9, 700, 'center'); }
} else if (d === Math.floor(d0)) {
// 왼쪽 끝 월 이름 — 바로 오른쪽에 새 달이 시작하면 겹치므로 적지 않는다
const toNext = MONTH_DAYS[cal[0] - 1] - cal[1] + 1;
if (toNext * dayW >= 44) { label(MONTH_FMT.replace('{n}', String(cal[0])), Math.max(x, left) + 4, 11, INK, 0.85, 10, 700, 'left'); }
}
}
ctx.restore();
}
// 왼쪽 작업 이름 칸
function rowLabel(name: string, y: number, a: number, bold: boolean): void {
label(name, 10, y, INK, a, 11, bold ? 800 : 600, 'left');
}
function rowStripe(y: number, h: number, i: number, a: number): void {
if (i % 2 === 1) {
ctx.fillStyle = rgba(PAPER_DIM, 0.75 * a);
ctx.fillRect(0, y, W, h);
}
}
// 움직이는 행 빛 — 그 행 막대의 스프링 속도(k = 0~1)만큼 행 전체가 옅은 라벤더로 밝아진다. 막대가 서면 사라진다
function rowGlow(y: number, h: number, k: number): void {
if (k <= 0.01) { return; }
ctx.fillStyle = rgba(LAVENDER, 0.42 * clamp(k, 0, 1));
ctx.fillRect(0, y, W, h);
}
// 상태 줄 — 바뀔 때만 글자를 고친다
let shownStatus = '';
function pushStatus(): void {
if (!statusEl || simStatus === shownStatus) { return; }
shownStatus = simStatus;
statusEl.textContent = simStatus;
}
// 숨김 표 — 같은 일정을 작업·시작·끝·여유 열로 읽게. 화면에는 안 보이고 화면 읽기 도구가 읽는다
const TABLE_HEAD = ['작업 이름', '시작일', '끝나는 날', '여유 일수'];
function fillTable(caption: string, rows: string[][]): void {
if (!tableEl) { return; }
const tb = tableEl;
while (tb.firstChild) { tb.removeChild(tb.firstChild); }
const cap = document.createElement('caption');
cap.textContent = caption;
tb.appendChild(cap);
const thead = document.createElement('thead');
const hr = document.createElement('tr');
TABLE_HEAD.forEach(function (h: string): void {
const th = document.createElement('th');
th.scope = 'col';
th.textContent = h;
hr.appendChild(th);
});
thead.appendChild(hr);
tb.appendChild(thead);
const body = document.createElement('tbody');
rows.forEach(function (r: string[]): void {
const tr = document.createElement('tr');
r.forEach(function (c: string, k: number): void {
const cell = document.createElement(k === 0 ? 'th' : 'td');
if (k === 0) { (cell as HTMLTableCellElement).scope = 'row'; }
cell.textContent = c;
tr.appendChild(cell);
});
body.appendChild(tr);
});
tb.appendChild(body);
}
// 04 — 연쇄 밀기. 디자인 시안이 사흘 늦어지면 pushFrom 이 뒤 작업을 너비 우선으로 훑어 새 시작일을 정하고, 막대는 단계 깊이 × PUSH_STAGGER_MS 늦게 스프링으로 옮긴다
const LABEL_W = 96;
const PLOT_X = LABEL_W + 6;
const DAY_WIDTH = 15;
const ROWS_TOP = 46;
const ROW_H = 21;
const BAR_H = 12;
const SLIP_TASK = 1;
const SLIP_DAYS = 3;
const SLIP_AT_S = 0.3;
const RESTORE_AT_S = 4.8;
// 한 단계 뒤로 갈 때마다 늦게 출발하는 시간
const PUSH_STAGGER_MS = 60;
// 막대 이동 스프링(일수 단위)
const PUSH_STIFFNESS = 210;
const PUSH_DAMPING = 19;
const LOOP_S = 7.6;
const TABLE_CAPTION = '작업 일정표 — 디자인 시안이 사흘 늦어진 뒤';
const STATUS_IDLE = '일정 그대로';
const STATUS_PUSHED = '{task} {d}일 지연 → {n}개 밀림 · 출시 {a} → {b}';
const tasks04 = BASE_TASKS;
const order04 = sortByDeps(tasks04).order;
const before04 = sweepBoth(tasks04, order04, null);
// 늦어진 일정 — 작업 일수만 늘린 사본에서 밀린 작업부터 전파한다
const slipped04: Task[] = (function (): Task[] { const c = copyTasks(tasks04); c[SLIP_TASK].dur += SLIP_DAYS; return c; })();
const push04 = pushFrom(slipped04, before04.es, SLIP_TASK);
const after04 = sweepBoth(slipped04, order04, null);
const moved04 = push04.depth.filter(function (d: number, i: number): boolean { return d > 0 && push04.starts[i] !== before04.es[i]; }).length;
const lastTask04 = tasks04.length - 1;
// 막대마다 시작(일수)·길이(일수) 스프링
const startS04: Spring[] = tasks04.map(function (_: Task, i: number): Spring { return makeSpring(before04.es[i]); });
const durS04: Spring[] = tasks04.map(function (t: Task): Spring { return makeSpring(t.dur); });
function departAt(i: number, base: number): number { return base + Math.max(0, push04.depth[i]) * PUSH_STAGGER_MS / 1000; }
function reset(): void {
tasks04.forEach(function (t: Task, i: number): void { snapSpring(startS04[i], before04.es[i]); snapSpring(durS04[i], t.dur); });
simStatus = STATUS_IDLE;
}
function update(dt: number): void {
let slipped = false;
tasks04.forEach(function (t: Task, i: number): void {
const on = push04.depth[i] >= 0 && sceneT >= departAt(i, SLIP_AT_S) && sceneT < departAt(i, RESTORE_AT_S);
if (push04.depth[i] >= 0 && sceneT >= SLIP_AT_S && sceneT < RESTORE_AT_S) { slipped = true; }
startS04[i].to = on ? push04.starts[i] : before04.es[i];
durS04[i].to = on ? slipped04[i].dur : t.dur;
stepSpring(startS04[i], PUSH_STIFFNESS, PUSH_DAMPING, dt);
stepSpring(durS04[i], PUSH_STIFFNESS, PUSH_DAMPING, dt);
});
simStatus = slipped ? STATUS_PUSHED.replace('{task}', tasks04[SLIP_TASK].name).replace('{d}', String(SLIP_DAYS)).replace('{n}', String(moved04))
.replace('{a}', dayText(before04.es[lastTask04])).replace('{b}', dayText(push04.starts[lastTask04])) : STATUS_IDLE;
}
function restPose(): void {
sceneT = SLIP_AT_S + 2;
tasks04.forEach(function (_: Task, i: number): void {
snapSpring(startS04[i], push04.depth[i] >= 0 ? push04.starts[i] : before04.es[i]);
snapSpring(durS04[i], slipped04[i].dur);
});
update(0);
}
function tableRows(): string[][] {
return slipped04.map(function (t: Task, i: number): string[] { return tableRow(t.name, push04.starts[i], t.dur, after04.slack[i]); });
}
function dayX(d: number): number { return PLOT_X + d * DAY_WIDTH; }
function rowMid(i: number): number { return ROWS_TOP + i * ROW_H + ROW_H / 2; }
function draw(): void {
clearStage();
const bottom = ROWS_TOP + tasks04.length * ROW_H;
tasks04.forEach(function (_: Task, i: number): void { rowStripe(ROWS_TOP + i * ROW_H, ROW_H, i, 1); rowGlow(ROWS_TOP + i * ROW_H, ROW_H, Math.max(Math.abs(startS04[i].v), Math.abs(durS04[i].v)) / 10); });
timeAxis(dayX, DAY_WIDTH, 0, 25, PLOT_X, W - 8, ROWS_TOP, bottom, { day: 1, week: 0, month: 0 });
// 화살표 — 지금 스프링 위치로 매 프레임 길을 다시 고른다
tasks04.forEach(function (t: Task, j: number): void {
t.deps.forEach(function (p: number): void {
const ex = dayX(startS04[p].x + durS04[p].x);
const sx = dayX(startS04[j].x) - (t.dur === 0 ? 7 : 0);
const pts = elbowRoute(ex, rowMid(p), sx, rowMid(j), ROWS_TOP + (p + 1) * ROW_H);
const hot = push04.depth[j] > 0 && Math.abs(startS04[j].x - startS04[j].to) > 0.05;
polyline(pts, hot ? TODAY_DEEP : INK_SOFT, hot ? 0.9 : 0.55, hot ? 2 : 1.4);
const tip = pointOnRoute(pts, routeLength(pts));
arrowHead(tip[0], tip[1], tip[2], tip[3], 5, hot ? TODAY_DEEP : INK_SOFT, 0.8);
});
});
tasks04.forEach(function (t: Task, i: number): void {
const cy = rowMid(i);
rowLabel(t.name, cy, 1, push04.depth[i] >= 0 && startS04[i].to !== before04.es[i]);
const s = startS04[i].x;
const shift = s - before04.es[i];
// 원래 자리 — 밀려난 만큼 점선 윤곽으로 남긴다
if (shift > 0.08) {
ctx.save();
ctx.setLineDash([3, 3]);
if (t.dur === 0) {
diamond(dayX(before04.es[i]), cy, 6, PAPER, 0.9);
} else {
roundRectPath(dayX(before04.es[i]), cy - BAR_H / 2, t.dur * DAY_WIDTH, BAR_H, 4);
ctx.lineWidth = 1.2;
ctx.strokeStyle = rgba(INK_SOFT, 0.8);
ctx.stroke();
}
ctx.restore();
}
const c = BAR_COLORS[i % BAR_COLORS.length];
if (t.dur === 0) {
diamond(dayX(s), cy, 7, PATH_DEEP, 1);
} else {
// 이동 중에는 막대가 속도만큼 살짝 기울어진 그림자를 끈다
const v = startS04[i].v;
bar(dayX(s) - v * 0.8, cy - BAR_H / 2 + 2, t.dur * DAY_WIDTH + Math.abs(v) * 0.8, BAR_H, INK, clamp(Math.abs(v) * 0.03, 0, 0.18), 0);
bar(dayX(s), cy - BAR_H / 2, durS04[i].x * DAY_WIDTH, BAR_H, c, 1, 1.2);
}
// 늘어난 날 — 늦어진 작업의 덧붙은 일수에 빗금
if (i === SLIP_TASK && durS04[i].x > t.dur + 0.05) {
hatch(dayX(s + t.dur), cy - BAR_H / 2, (durS04[i].x - t.dur) * DAY_WIDTH, BAR_H, 4, TODAY_DEEP, 0.85);
}
if (shift > 0.3) { label('+' + SLACK_FMT.replace('{n}', String(Math.round(shift))), dayX(s + Math.max(t.dur, durS04[i].x)) + (t.dur === 0 ? 10 : 4), cy, TODAY_DEEP, smooth(shift / 0.8), 10, 800, 'left'); }
});
}
// 한 프레임에 넘기는 시간 상한(초) — 탭을 오래 숨겼다 돌아온 첫 프레임에도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 손대면 시연 표시를 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
// 리스너는 한 묶음으로 달고 정리할 때 한 번에 뗀다
const off = new AbortController();
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
if (statusEl) { statusEl.setAttribute('aria-live', 'polite'); }
}
// 장면 시계를 dt 만큼 — 한 바퀴(LOOP_S)를 넘으면 처음 배치로 되돌려 다시 시연한다
function advance(dt: number): void {
sceneT += dt;
if (sceneT >= LOOP_S) {
sceneT -= LOOP_S;
reset();
}
update(dt);
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) { advance(dt); }
draw();
pushStatus();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 시계 다시 맞추기 — 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 동작 줄이기 — 루프를 돌리지 않고 시연이 끝난 최종 배치 한 장만 그린다
function showRest(): void {
stopLoop();
sceneT = 0;
reset();
restPose();
draw();
pushStatus();
}
function play(): void {
sceneT = 0;
reset();
draw();
pushStatus();
if (!paused) { startLoop(); }
}
pauseBtn.addEventListener('click', function (): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}, { signal: off.signal });
// 탭이 숨으면 루프를 멈추고, 돌아오면 시계를 다시 맞춰 시작한다 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 페이지를 연 채 바뀌면 — 켜지면 최종 배치로 멈추고, 꺼지면 시연을 처음부터
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { play(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
draw();
}, { signal: off.signal });
fitCanvas();
fillTable(TABLE_CAPTION, tableRows());
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("cf") + " is-demo"} ref={rootRef}>
<canvas className={cx("cf__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label="한 작업이 사흘 늦어지자 화살표를 따라 뒤 작업이 단계 순서대로 밀려나는 일정표."></canvas>
<table className={cx("cf__sr")} data-role="table"></table>
<p className={cx("cf__status")} data-role="status" aria-live="off">{'일정 그대로'}</p>
<button className={cx("cf__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
</div>
);
}04 · 한 작업이 밀리면 화살표를 따라 차례로 밀려나는 뒤 작업 — 측정 누적면적 40.26% · 움직인 프레임 11/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
const succ = successorsOf(tasks);
const out = starts.slice();
const depth = tasks.map(function () { return -1; });
depth[from] = 0;
const queue = [from];
let head = 0;
// 고리가 있어도 끝나도록 꺼내는 횟수에 상한을 둔다
const limit = tasks.length * tasks.length + 1;
while (head < queue.length && head < limit) {
const i = queue[head];
head += 1;
for (const j of succ[i]) {
let need = 0;
for (const p of tasks[j].deps) {
need = Math.max(need, out[p] + tasks[p].dur);
}
if (need > out[j]) {
out[j] = need;
depth[j] = Math.max(depth[j], depth[i] + 1);
queue.push(j);
}
}
}
return { starts: out, depth: depth };
05기준 날짜를 붙잡고 일·주·월로 늘었다 줄어드는 눈금
dayX 가 300 + (일수 − 52) × 하루 폭 으로 그리므로 기준 날짜인 5월 2일은 하루 폭이 26px 와 2.2px 사이를 오가는 동안 화면 x 가 0px 도 어긋나지 않고, 같은 통합 검수 시작일은 1028 에서 361.6 으로 기준 날짜 쪽으로 끌려온다. 하루 폭은 로그 값을 직선으로 섞어 되돌리는 zoomWidth 라 진행 절반에서 7.56px(두 끝의 기하평균, 직선 보간이면 14.1px)를 지나고, 폭이 문턱을 넘을 때마다 일 숫자·주차·월 경계 층이 겹쳐 흐려지며 바뀐다(주 층이 0.5 를 넘는 0.667초에 17.92px, 월 층은 1.346초에 4.49px). 분기 로드맵을 일 단위에서 월 단위 보기로 바꿀 때 지금 보던 날짜를 놓치지 않게 하는 장면이다.

<main class="stage">
<div class="cf is-demo" id="cf">
<canvas class="cf__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="기준 날짜 위치를 붙잡은 채 하루 폭이 바뀌며 일·주·월 눈금이 서로 흐려지며 바뀌는 일정표."></canvas>
<table class="cf__sr" data-role="table"></table>
<button class="cf__pause" data-role="pause" type="button" aria-pressed="false">일시정지</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.cf {
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-cream;
}
.cf__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.cf__pause {
position: absolute;
right: $sp-3;
bottom: $sp-3;
padding: $sp-1 $sp-3;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.cf__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.cf__pause:hover {
transform: translateY(-2px);
}
.cf__pause:active {
transform: translateY(1px);
}
.cf__pause:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 일정에 무슨 일이 일어났는지 글자로. 시연 중에는 aria-live off, 사람이 처음 손대면 polite
.cf__status {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: calc(100% - 120px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
// 숨김 표 — 화면에서는 1px 로 접어 두고 화면 읽기 도구만 읽는다
.cf__sr {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
@media (max-width: 340px) {
.cf__pause,
.cf__status { font-size: 10px; padding: $sp-1 $sp-2; }
}
// 동작 줄이기 — 장면은 스크립트가 최종 배치 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.cf__pause { transition: none !important; }
.cf__pause:hover,
.cf__pause:active { transform: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cf {
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: #fff7e6;
}
.cf__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.cf__pause {
position: absolute;
right: 12px;
bottom: 12px;
padding: 4px 12px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.cf__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.cf__pause:hover {
transform: translateY(-2px);
}
.cf__pause:active {
transform: translateY(1px);
}
.cf__pause:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.cf__status {
position: absolute;
left: 12px;
bottom: 12px;
max-width: calc(100% - 120px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
.cf__sr {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
@media (max-width: 340px) {
.cf__pause,
.cf__status {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.cf__pause {
transition: none !important;
}
.cf__pause:hover,
.cf__pause:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("cf");
if (!root) return;
// 판 좌표 — 캔버스 안은 늘 480×300. 화면에 보이는 크기와 상관없이 계산은 이 좌표로 한다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
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); }
// 따라가기 비율 — 1 − e^(−rate·dt). 프레임 시간이 달라도 1초에 같은 만큼 다가간다
function follow(rate, dt) { return 1 - Math.exp(-rate * dt); }
// 시드 고정 난수 — 같은 시드면 같은 일정이 다시 나온다 (32비트 정수 섞기)
function makeRng(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function makeSpring(x) { return { x: x, v: 0, to: x }; }
const SPRING_SUBSTEP = 1 / 240;
function stepSpring(s, stiffness, damping, dt) {
let left = dt;
while (left > 1e-9) {
const h = Math.min(SPRING_SUBSTEP, left);
s.v += (stiffness * (s.to - s.x) - damping * s.v) * h;
s.x += s.v * h;
left -= h;
}
}
function snapSpring(s, x) { s.x = x; s.v = 0; s.to = x; }
// ── 날짜 = 정수 일수 ─────────────────────────────────────────
// 날짜 객체를 쓰지 않는다. 기준일을 0 으로 세는 정수 일수(day index)만 다루고, 화면의 월·일 숫자는 아래 월 길이 표로 산수해 만든다
const MONTH_DAYS = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
const YEAR_DAYS = 365;
// 기준일(일수 0) = 한 해의 70번째 날 → 표로 세면 3월 11일
const DAY_ZERO_OF_YEAR = 69;
function calendarOf(day) {
let r = ((day + DAY_ZERO_OF_YEAR) % YEAR_DAYS + YEAR_DAYS) % YEAR_DAYS;
let m = 0;
while (r >= MONTH_DAYS[m]) {
r -= MONTH_DAYS[m];
m += 1;
}
return [m + 1, r + 1];
}
const DAY_FMT = '{n}일차';
const WEEK_FMT = '{n}주차';
const MONTH_FMT = '{n}월';
const SLACK_FMT = '{n}일';
function dayText(day) { return DAY_FMT.replace('{n}', String(day + 1)); }
// 하루 차 계산의 함정 — 시각(시간 단위)끼리 빼서 24 로 나누면, 시계가 한 시간 당겨진 날이 끼었을 때 정수가 아니다(23시간짜리 하루).
// 그래서 바깥에서 들어온 시각은 반올림으로 정수 일수에 붙이고, 그다음부터는 정수끼리만 더하고 뺀다
const HOURS_PER_DAY = 24;
const CLOCK_SHIFT_HOURS = 1;
function hoursToDayIndex(hours) { return Math.round(hours / HOURS_PER_DAY); }
const BASE_TASKS = [
{ name: '요구 정리', dur: 3, deps: [], load: 2 },
{ name: '디자인 시안', dur: 4, deps: [0], load: 1 },
{ name: '데이터 설계', dur: 3, deps: [0], load: 1 },
{ name: '화면 개발', dur: 5, deps: [1], load: 2 },
{ name: '서버 개발', dur: 7, deps: [2], load: 1 },
{ name: '기능 연동', dur: 3, deps: [3, 4], load: 2 },
{ name: '문서 작성', dur: 3, deps: [1], load: 1 },
{ name: '통합 검수', dur: 3, deps: [5], load: 2 },
{ name: '오류 수정', dur: 2, deps: [7], load: 1 },
{ name: '출시', dur: 0, deps: [8, 6], load: 0 }
];
function copyTasks(ts) { return ts.map(function (t) { return { name: t.name, dur: t.dur, deps: t.deps.slice(), load: t.load }; }); }
function successorsOf(tasks) {
const out = tasks.map(function () { return []; });
tasks.forEach(function (t, i) { t.deps.forEach(function (p) { out[p].push(i); }); });
return out;
}
function sortByDeps(tasks) {
const succ = successorsOf(tasks);
const waiting = tasks.map(function (t) { return t.deps.length; });
const ready = [];
waiting.forEach(function (w, i) { if (w === 0) {
ready.push(i);
} });
const order = [];
while (ready.length) {
ready.sort(function (a, b) { return a - b; });
const i = ready.shift();
order.push(i);
succ[i].forEach(function (j) { waiting[j] -= 1; if (waiting[j] === 0) {
ready.push(j);
} });
}
const stuck = [];
waiting.forEach(function (w, i) { if (w > 0) {
stuck.push(i);
} });
return { order: order, stuck: stuck };
}
function sweepBoth(tasks, order, minStart) {
const n = tasks.length;
const es = new Array(n).fill(0);
const ef = new Array(n).fill(0);
for (const i of order) {
let s = minStart ? minStart[i] : 0;
for (const p of tasks[i].deps) {
s = Math.max(s, ef[p]);
}
es[i] = s;
ef[i] = s + tasks[i].dur;
}
let end = 0;
for (const i of order) {
end = Math.max(end, ef[i]);
}
const succ = successorsOf(tasks);
const ls = new Array(n).fill(0);
const lf = new Array(n).fill(0);
for (let k = order.length - 1; k >= 0; k -= 1) {
const i = order[k];
let f = end;
for (const j of succ[i]) {
f = Math.min(f, ls[j]);
}
lf[i] = f;
ls[i] = f - tasks[i].dur;
}
const slack = es.map(function (s, i) { return ls[i] - s; });
return { es: es, ef: ef, ls: ls, lf: lf, slack: slack, end: end };
}
function pushFrom(tasks, starts, from) {
const succ = successorsOf(tasks);
const out = starts.slice();
const depth = tasks.map(function () { return -1; });
depth[from] = 0;
const queue = [from];
let head = 0;
// 고리가 있어도 끝나도록 꺼내는 횟수에 상한을 둔다
const limit = tasks.length * tasks.length + 1;
while (head < queue.length && head < limit) {
const i = queue[head];
head += 1;
for (const j of succ[i]) {
let need = 0;
for (const p of tasks[j].deps) {
need = Math.max(need, out[p] + tasks[p].dur);
}
if (need > out[j]) {
out[j] = need;
depth[j] = Math.max(depth[j], depth[i] + 1);
queue.push(j);
}
}
}
return { starts: out, depth: depth };
}
// 날마다 걸린 일감 — 작업이 덮는 날(시작 ≤ 날 < 시작 + 일수)마다 사람 수를 더한다
function dailyLoad(tasks, starts, days) {
const load = new Array(days).fill(0);
tasks.forEach(function (t, i) {
for (let d = starts[i]; d < starts[i] + t.dur; d += 1) {
if (d >= 0 && d < days) {
load[d] += t.load;
}
}
});
return load;
}
// ── 직각 경로 ───────────────────────────────────────────────
// 앞 끝(ex, ey)에서 오른쪽으로 ROUTE_STUB 나간 뒤 아래로 꺾는다. 뒤 시작(sx)이 그 꺾인 자리 + ROUTE_STUB 보다 오른쪽이면 곧장 내려가 오른쪽으로 들어가고,
// 아니면 앞 행 바로 아래 통로(gapY)까지 내려가 왼쪽으로 돌아간 다음 뒤 시작보다 ROUTE_STUB 앞에서 내려가 오른쪽으로 들어간다
const ROUTE_STUB = 7;
function elbowRoute(ex, ey, sx, sy, gapY) {
const x1 = ex + ROUTE_STUB;
if (sx - ROUTE_STUB >= x1) {
return [ex, ey, x1, ey, x1, sy, sx, sy];
}
const x2 = sx - ROUTE_STUB;
return [ex, ey, x1, ey, x1, gapY, x2, gapY, x2, sy, sx, sy];
}
function routeLength(pts) {
let s = 0;
for (let i = 2; i < pts.length; i += 2) {
s += Math.hypot(pts[i] - pts[i - 2], pts[i + 1] - pts[i - 1]);
}
return s;
}
// 경로 위 거리 dist 자리의 점과 그 마디의 방향
function pointOnRoute(pts, dist) {
let left = Math.max(0, dist);
for (let i = 2; i < pts.length; i += 2) {
const dx = pts[i] - pts[i - 2];
const dy = pts[i + 1] - pts[i - 1];
const len = Math.hypot(dx, dy);
if (left <= len || i === pts.length - 2) {
const k = len > 0 ? Math.min(1, left / len) : 0;
return [pts[i - 2] + dx * k, pts[i - 1] + dy * k, len > 0 ? dx / len : 1, len > 0 ? dy / len : 0];
}
left -= len;
}
return [pts[0], pts[1], 1, 0];
}
// 경로를 앞에서부터 dist 만큼만 자른 점 목록 — 화살표가 그려져 나가는 장면에 쓴다
function cutRoute(pts, dist) {
const out = [pts[0], pts[1]];
let left = Math.max(0, dist);
for (let i = 2; i < pts.length; i += 2) {
const len = Math.hypot(pts[i] - pts[i - 2], pts[i + 1] - pts[i - 1]);
if (left >= len) {
out.push(pts[i], pts[i + 1]);
left -= len;
continue;
}
const k = len > 0 ? left / len : 0;
out.push(lerp(pts[i - 2], pts[i], k), lerp(pts[i - 1], pts[i + 1], k));
break;
}
return out;
}
// 장면 시계(초)와 상태 줄 글자 — 장면 계산이 고치고, 화면 쪽이 읽어 그린다
let sceneT = 0;
let simStatus = '';
// 파스텔 팔레트 — 크림 바탕 위 라벤더·민트·버터·산호 막대. 가장 긴 경로·오늘 선·글자는 진한 쪽으로 명도 차를 둔다
const PAPER = [251, 247, 238];
const PAPER_DIM = [243, 237, 224];
const GRID = [226, 218, 204];
const INK = [44, 38, 58];
const INK_SOFT = [112, 104, 126];
const CREAM = [255, 251, 242];
const LAVENDER = [190, 174, 236];
const MINT = [158, 214, 190];
const BUTTER = [244, 222, 146];
const CORAL = [244, 170, 150];
const SKY = [160, 204, 236];
const PINK = [242, 182, 206];
const PATH_DEEP = [86, 56, 168];
const TODAY_DEEP = [190, 72, 60];
const BAR_COLORS = [LAVENDER, MINT, BUTTER, CORAL, SKY, PINK];
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// 표 한 줄 — 작업·시작·끝·여유. 끝은 그 작업이 덮는 마지막 날(일수 0 짜리는 시작과 같은 날)
function tableRow(name, start, dur, slack) {
return [name, dayText(start), dayText(start + Math.max(0, dur - 1)), SLACK_FMT.replace('{n}', String(slack))];
}
const canvasEl = root.querySelector('[data-role="canvas"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const tableEl = root.querySelector('[data-role="table"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function clearStage() {
ctx.fillStyle = rgba(PAPER, 1);
ctx.fillRect(0, 0, W, H);
}
function label(s, x, y, c, a, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
function roundRectPath(x, y, w, h, r) {
const rr = Math.max(0, Math.min(r, w / 2, h / 2));
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.lineTo(x + w - rr, y);
ctx.arcTo(x + w, y, x + w, y + rr, rr);
ctx.lineTo(x + w, y + h - rr);
ctx.arcTo(x + w, y + h, x + w - rr, y + h, rr);
ctx.lineTo(x + rr, y + h);
ctx.arcTo(x, y + h, x, y + h - rr, rr);
ctx.lineTo(x, y + rr);
ctx.arcTo(x, y, x + rr, y, rr);
ctx.closePath();
}
// 막대 하나 — 둥근 사각형 채움 + 진한 테두리. 폭이 0 이하이면 그리지 않는다
function bar(x, y, w, h, fill, a, line) {
if (w <= 0.2 || a <= 0) {
return;
}
roundRectPath(x, y, w, h, 4);
ctx.fillStyle = rgba(fill, a);
ctx.fill();
if (line > 0) {
ctx.lineWidth = line;
ctx.strokeStyle = rgba(INK, 0.55 * a);
ctx.stroke();
}
}
// 마일스톤 마름모
function diamond(cx, cy, r, fill, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.moveTo(cx, cy - r);
ctx.lineTo(cx + r, cy);
ctx.lineTo(cx, cy + r);
ctx.lineTo(cx - r, cy);
ctx.closePath();
ctx.fillStyle = rgba(fill, a);
ctx.fill();
ctx.lineWidth = 1.5;
ctx.strokeStyle = rgba(INK, 0.8 * a);
ctx.stroke();
}
function polyline(pts, c, a, width) {
if (pts.length < 4) {
return;
}
ctx.beginPath();
ctx.moveTo(pts[0], pts[1]);
for (let i = 2; i < pts.length; i += 2) {
ctx.lineTo(pts[i], pts[i + 1]);
}
ctx.lineWidth = width;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function arrowHead(x, y, dx, dy, size, c, a) {
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x - dx * size - dy * size * 0.6, y - dy * size + dx * size * 0.6);
ctx.lineTo(x - dx * size + dy * size * 0.6, y - dy * size - dx * size * 0.6);
ctx.closePath();
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
// 빗금 — (x, y, w, h) 안에 45° 줄을 spacing 간격으로. 간격이 좁을수록 짙어 보인다
function hatch(x, y, w, h, spacing, c, a) {
if (w <= 0.5 || a <= 0) {
return;
}
ctx.save();
roundRectPath(x, y, w, h, 3);
ctx.clip();
ctx.beginPath();
for (let s = x - h; s < x + w; s += Math.max(2, spacing)) {
ctx.moveTo(s, y + h);
ctx.lineTo(s + h, y);
}
ctx.lineWidth = 1.6;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
ctx.restore();
}
// 시간 축 — 위 띠는 월 이름, 아래 띠는 일(월 안의 날 숫자)·주(주차) 층. 층마다 불투명도를 따로 받아 겹쳐 흐려지며 바뀌게 한다.
// 세로 격자도 같은 불투명도로 행 영역(top~bottom)에 긋는다. xOf 는 일수 → 화면 x
function timeAxis(xOf, dayW, d0, d1, left, right, top, bottom, ly) {
ctx.save();
ctx.beginPath();
ctx.rect(left, 0, right - left, H);
ctx.clip();
ctx.fillStyle = rgba(PAPER_DIM, 1);
ctx.fillRect(left, 0, right - left, top - 4);
for (let d = Math.floor(d0); d <= Math.ceil(d1); d += 1) {
const x = xOf(d);
const cal = calendarOf(d);
if (ly.day > 0.01) {
ctx.fillStyle = rgba(GRID, 0.7 * ly.day);
ctx.fillRect(x, top, 1, bottom - top);
if (dayW >= 13) {
label(String(cal[1]), x + dayW / 2, top - 11, INK_SOFT, ly.day, 9, 600, 'center');
}
}
// 주 층 — 글자가 겹치지 않을 만큼(36px) 주를 건너뛰며 적는다
const weekStep = 7 * dayW >= 36 ? 1 : (14 * dayW >= 36 ? 2 : 4);
if (ly.week > 0.01 && d % 7 === 0) {
ctx.fillStyle = rgba(GRID, ly.week);
ctx.fillRect(x, top, 1, bottom - top);
if ((d / 7) % weekStep === 0) {
label(WEEK_FMT.replace('{n}', String(d / 7 + 1)), x + 3, top - 11, INK_SOFT, ly.week, 9, 600, 'left');
}
}
if (cal[1] === 1) {
ctx.fillStyle = rgba(INK_SOFT, 0.18 + 0.4 * ly.month);
ctx.fillRect(x, 4, 1, bottom - 4);
label(MONTH_FMT.replace('{n}', String(cal[0])), x + 4, 11, INK, 0.85, 10, 700, 'left');
// 월 층 — 그달 칸 가운데에 그달 일수(월 길이 표에서)를 적는다
const len = MONTH_DAYS[cal[0] - 1];
if (ly.month > 0.01 && len * dayW >= 30) {
label(SLACK_FMT.replace('{n}', String(len)), x + len * dayW / 2, top - 11, INK_SOFT, ly.month, 9, 700, 'center');
}
}
else if (d === Math.floor(d0)) {
// 왼쪽 끝 월 이름 — 바로 오른쪽에 새 달이 시작하면 겹치므로 적지 않는다
const toNext = MONTH_DAYS[cal[0] - 1] - cal[1] + 1;
if (toNext * dayW >= 44) {
label(MONTH_FMT.replace('{n}', String(cal[0])), Math.max(x, left) + 4, 11, INK, 0.85, 10, 700, 'left');
}
}
}
ctx.restore();
}
// 왼쪽 작업 이름 칸
function rowLabel(name, y, a, bold) {
label(name, 10, y, INK, a, 11, bold ? 800 : 600, 'left');
}
function rowStripe(y, h, i, a) {
if (i % 2 === 1) {
ctx.fillStyle = rgba(PAPER_DIM, 0.75 * a);
ctx.fillRect(0, y, W, h);
}
}
// 움직이는 행 빛 — 그 행 막대의 스프링 속도(k = 0~1)만큼 행 전체가 옅은 라벤더로 밝아진다. 막대가 서면 사라진다
function rowGlow(y, h, k) {
if (k <= 0.01) {
return;
}
ctx.fillStyle = rgba(LAVENDER, 0.42 * clamp(k, 0, 1));
ctx.fillRect(0, y, W, h);
}
// 상태 줄 — 바뀔 때만 글자를 고친다
let shownStatus = '';
function pushStatus() {
if (!statusEl || simStatus === shownStatus) {
return;
}
shownStatus = simStatus;
statusEl.textContent = simStatus;
}
// 숨김 표 — 같은 일정을 작업·시작·끝·여유 열로 읽게. 화면에는 안 보이고 화면 읽기 도구가 읽는다
const TABLE_HEAD = ['작업 이름', '시작일', '끝나는 날', '여유 일수'];
function fillTable(caption, rows) {
if (!tableEl) {
return;
}
const tb = tableEl;
while (tb.firstChild) {
tb.removeChild(tb.firstChild);
}
const cap = document.createElement('caption');
cap.textContent = caption;
tb.appendChild(cap);
const thead = document.createElement('thead');
const hr = document.createElement('tr');
TABLE_HEAD.forEach(function (h) {
const th = document.createElement('th');
th.scope = 'col';
th.textContent = h;
hr.appendChild(th);
});
thead.appendChild(hr);
tb.appendChild(thead);
const body = document.createElement('tbody');
rows.forEach(function (r) {
const tr = document.createElement('tr');
r.forEach(function (c, k) {
const cell = document.createElement(k === 0 ? 'th' : 'td');
if (k === 0) {
cell.scope = 'row';
}
cell.textContent = c;
tr.appendChild(cell);
});
body.appendChild(tr);
});
tb.appendChild(body);
}
// 05 — 기준 날짜를 붙잡은 확대·축소. x = ANCHOR_X + (일수 − ANCHOR_DAY) × 하루 폭 — 하루 폭이 k 배가 되면 x' = a + (x − a) × k 로, 기준 날짜(a) 자리만 그대로다
const LABEL_W = 96;
const PLOT_X = LABEL_W + 6;
const PLOT_R = W - 8;
const ROWS_TOP = 44;
const ROW_H = 20;
const BAR_H = 12;
// 같은 작업 열 개를 일수만 ZOOM_DUR_SCALE 배로 늘려 몇 달짜리 일정으로
const ZOOM_DUR_SCALE = 5;
const ANCHOR_DAY = 52;
const ANCHOR_X = 300;
// 하루 폭 두 끝(px) — 그 사이는 로그 공간에서 보간한다(두 배 → 네 배가 같은 빠르기로 보이게)
const DAY_W_NEAR = 26;
const DAY_W_FAR = 2.2;
const ZOOM_OUT_AT_S = 0.25;
const ZOOM_IN_AT_S = 4.2;
const ZOOM_S = 1.7;
const LOOP_S = 7.4;
// 눈금 층 문턱(하루 폭 px) — 일 층은 DAY_TICK_FADE 위에서 보이고, 주 층은 WEEK_TICK_FADE 사이에서, 월 층은 MONTH_TICK_FADE 아래에서 진해진다
const DAY_TICK_FADE = [9, 16];
const WEEK_TICK_FADE = [2.6, 4.8];
const WEEK_TICK_OUT = [14, 22];
const MONTH_TICK_FADE = [3, 6];
const TABLE_CAPTION = '작업 일정표 — 몇 달짜리 일정';
const tasks05 = BASE_TASKS.map(function (t) { return { name: t.name, dur: t.dur * ZOOM_DUR_SCALE, deps: t.deps.slice(), load: t.load }; });
const sweep05 = sweepBoth(tasks05, sortByDeps(tasks05).order, null);
let dayW05 = DAY_W_NEAR;
// 로그 보간 — ln(하루 폭)을 선형으로 섞은 뒤 되돌린다
function zoomWidth(p) { return Math.exp(lerp(Math.log(DAY_W_NEAR), Math.log(DAY_W_FAR), p)); }
function zoomProgress(t) {
if (t < ZOOM_IN_AT_S) {
return smooth((t - ZOOM_OUT_AT_S) / ZOOM_S);
}
return 1 - smooth((t - ZOOM_IN_AT_S) / ZOOM_S);
}
function dayX(d) { return ANCHOR_X + (d - ANCHOR_DAY) * dayW05; }
function xDay(x) { return ANCHOR_DAY + (x - ANCHOR_X) / dayW05; }
// 층 불투명도 — 하루 폭 하나에서 세 층이 겹쳐 흐려지는 비율
function layersAt(w) {
return {
day: smooth((w - DAY_TICK_FADE[0]) / (DAY_TICK_FADE[1] - DAY_TICK_FADE[0])),
week: smooth((w - WEEK_TICK_FADE[0]) / (WEEK_TICK_FADE[1] - WEEK_TICK_FADE[0])) * (1 - smooth((w - WEEK_TICK_OUT[0]) / (WEEK_TICK_OUT[1] - WEEK_TICK_OUT[0]))),
month: 1 - smooth((w - MONTH_TICK_FADE[0]) / (MONTH_TICK_FADE[1] - MONTH_TICK_FADE[0]))
};
}
function reset() { dayW05 = DAY_W_NEAR; }
function update(_dt) { dayW05 = zoomWidth(zoomProgress(sceneT)); }
function restPose() { dayW05 = DAY_W_FAR; }
function tableRows() {
return tasks05.map(function (t, i) { return tableRow(t.name, sweep05.es[i], t.dur, sweep05.slack[i]); });
}
const ANCHOR_FMT = '기준일 {m}월 {d}일';
const WIDTH_FMT = '하루 {w}px';
function rowMid(i) { return ROWS_TOP + i * ROW_H + ROW_H / 2; }
function draw() {
clearStage();
const bottom = ROWS_TOP + tasks05.length * ROW_H;
tasks05.forEach(function (_, i) { rowStripe(ROWS_TOP + i * ROW_H, ROW_H, i, 1); });
const ly = layersAt(dayW05);
timeAxis(dayX, dayW05, xDay(PLOT_X) - 1, xDay(PLOT_R) + 1, PLOT_X, PLOT_R, ROWS_TOP, bottom, ly);
ctx.save();
ctx.beginPath();
ctx.rect(PLOT_X, ROWS_TOP, PLOT_R - PLOT_X, bottom - ROWS_TOP);
ctx.clip();
tasks05.forEach(function (t, i) {
const cy = rowMid(i);
const x0 = dayX(sweep05.es[i]);
if (t.dur === 0) {
diamond(x0, cy, 7, PATH_DEEP, 1);
return;
}
bar(x0, cy - BAR_H / 2, t.dur * dayW05, BAR_H, BAR_COLORS[i % BAR_COLORS.length], 1, 1.2);
// 막대 안 일수 — 글자가 들어갈 만큼 넓을 때만
const w = t.dur * dayW05;
if (w > 34) {
label(SLACK_FMT.replace('{n}', String(t.dur)), Math.max(x0, PLOT_X) + 6, cy, INK, smooth((w - 34) / 20), 9, 700, 'left');
}
});
ctx.restore();
tasks05.forEach(function (t, i) { rowLabel(t.name, rowMid(i), 1, false); });
// 기준 날짜 — 확대·축소 내내 화면 x 가 ANCHOR_X 에 머문다
ctx.save();
ctx.setLineDash([4, 3]);
ctx.beginPath();
ctx.moveTo(ANCHOR_X, ROWS_TOP - 6);
ctx.lineTo(ANCHOR_X, bottom);
ctx.lineWidth = 2;
ctx.strokeStyle = rgba(PATH_DEEP, 0.95);
ctx.stroke();
ctx.restore();
const cal = calendarOf(ANCHOR_DAY);
const tag = ANCHOR_FMT.replace('{m}', String(cal[0])).replace('{d}', String(cal[1]));
ctx.font = '800 10px ' + FONT_STACK;
const tw = ctx.measureText(tag).width + 12;
bar(ANCHOR_X - tw / 2, bottom + 3, tw, 16, PATH_DEEP, 1, 0);
label(tag, ANCHOR_X, bottom + 11, CREAM, 1, 10, 800, 'center');
label(WIDTH_FMT.replace('{w}', dayW05.toFixed(1)), PLOT_X, bottom + 11, INK_SOFT, 1, 10, 700, 'left');
}
// 한 프레임에 넘기는 시간 상한(초) — 탭을 오래 숨겼다 돌아온 첫 프레임에도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 손대면 시연 표시를 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
// 리스너는 한 묶음으로 달고 정리할 때 한 번에 뗀다
const off = new AbortController();
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
if (statusEl) {
statusEl.setAttribute('aria-live', 'polite');
}
}
// 장면 시계를 dt 만큼 — 한 바퀴(LOOP_S)를 넘으면 처음 배치로 되돌려 다시 시연한다
function advance(dt) {
sceneT += dt;
if (sceneT >= LOOP_S) {
sceneT -= LOOP_S;
reset();
}
update(dt);
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
advance(dt);
}
draw();
pushStatus();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 시계 다시 맞추기 — 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 동작 줄이기 — 루프를 돌리지 않고 시연이 끝난 최종 배치 한 장만 그린다
function showRest() {
stopLoop();
sceneT = 0;
reset();
restPose();
draw();
pushStatus();
}
function play() {
sceneT = 0;
reset();
draw();
pushStatus();
if (!paused) {
startLoop();
}
}
pauseBtn.addEventListener('click', function () {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}, { signal: off.signal });
// 탭이 숨으면 루프를 멈추고, 돌아오면 시계를 다시 맞춰 시작한다 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 페이지를 연 채 바뀌면 — 켜지면 최종 배치로 멈추고, 꺼지면 시연을 처음부터
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
play();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
draw();
}, { signal: off.signal });
fitCanvas();
fillTable(TABLE_CAPTION, tableRows());
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// AnchoredZoomTicks - 같은 항목 vanilla 판 index.html 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./AnchoredZoomTicks.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function AnchoredZoomTicks() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
type Pt = [number, number];
// 판 좌표 — 캔버스 안은 늘 480×300. 화면에 보이는 크기와 상관없이 계산은 이 좌표로 한다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
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); }
// 따라가기 비율 — 1 − e^(−rate·dt). 프레임 시간이 달라도 1초에 같은 만큼 다가간다
function follow(rate: number, dt: number): number { return 1 - Math.exp(-rate * dt); }
// 시드 고정 난수 — 같은 시드면 같은 일정이 다시 나온다 (32비트 정수 섞기)
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// ── 감쇠 스프링 ──────────────────────────────────────────────
// 가속 = 단단함 × (목표 − 위치) − 감쇠 × 속도. 속도를 먼저 고친 뒤 위치를 옮기는 적분을 1/240초 잔걸음으로 나눠 돌린다
type Spring = { x: number; v: number; to: number };
function makeSpring(x: number): Spring { return { x: x, v: 0, to: x }; }
const SPRING_SUBSTEP = 1 / 240;
function stepSpring(s: Spring, stiffness: number, damping: number, dt: number): void {
let left = dt;
while (left > 1e-9) {
const h = Math.min(SPRING_SUBSTEP, left);
s.v += (stiffness * (s.to - s.x) - damping * s.v) * h;
s.x += s.v * h;
left -= h;
}
}
function snapSpring(s: Spring, x: number): void { s.x = x; s.v = 0; s.to = x; }
// ── 날짜 = 정수 일수 ─────────────────────────────────────────
// 날짜 객체를 쓰지 않는다. 기준일을 0 으로 세는 정수 일수(day index)만 다루고, 화면의 월·일 숫자는 아래 월 길이 표로 산수해 만든다
const MONTH_DAYS = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
const YEAR_DAYS = 365;
// 기준일(일수 0) = 한 해의 70번째 날 → 표로 세면 3월 11일
const DAY_ZERO_OF_YEAR = 69;
function calendarOf(day: number): [number, number] {
let r = ((day + DAY_ZERO_OF_YEAR) % YEAR_DAYS + YEAR_DAYS) % YEAR_DAYS;
let m = 0;
while (r >= MONTH_DAYS[m]) { r -= MONTH_DAYS[m]; m += 1; }
return [m + 1, r + 1];
}
const DAY_FMT = '{n}일차';
const WEEK_FMT = '{n}주차';
const MONTH_FMT = '{n}월';
const SLACK_FMT = '{n}일';
function dayText(day: number): string { return DAY_FMT.replace('{n}', String(day + 1)); }
// 하루 차 계산의 함정 — 시각(시간 단위)끼리 빼서 24 로 나누면, 시계가 한 시간 당겨진 날이 끼었을 때 정수가 아니다(23시간짜리 하루).
// 그래서 바깥에서 들어온 시각은 반올림으로 정수 일수에 붙이고, 그다음부터는 정수끼리만 더하고 뺀다
const HOURS_PER_DAY = 24;
const CLOCK_SHIFT_HOURS = 1;
function hoursToDayIndex(hours: number): number { return Math.round(hours / HOURS_PER_DAY); }
// ── 작업과 의존 ──────────────────────────────────────────────
// dur 는 일수(0 이면 마일스톤), deps 는 끝나야 시작할 수 있는 앞 작업 번호, load 는 그 작업에 붙는 사람 수
type Task = { name: string; dur: number; deps: number[]; load: number };
const BASE_TASKS: Task[] = [
{ name: '요구 정리', dur: 3, deps: [], load: 2 },
{ name: '디자인 시안', dur: 4, deps: [0], load: 1 },
{ name: '데이터 설계', dur: 3, deps: [0], load: 1 },
{ name: '화면 개발', dur: 5, deps: [1], load: 2 },
{ name: '서버 개발', dur: 7, deps: [2], load: 1 },
{ name: '기능 연동', dur: 3, deps: [3, 4], load: 2 },
{ name: '문서 작성', dur: 3, deps: [1], load: 1 },
{ name: '통합 검수', dur: 3, deps: [5], load: 2 },
{ name: '오류 수정', dur: 2, deps: [7], load: 1 },
{ name: '출시', dur: 0, deps: [8, 6], load: 0 }
];
function copyTasks(ts: Task[]): Task[] { return ts.map(function (t: Task): Task { return { name: t.name, dur: t.dur, deps: t.deps.slice(), load: t.load }; }); }
function successorsOf(tasks: Task[]): number[][] {
const out: number[][] = tasks.map(function (): number[] { return []; });
tasks.forEach(function (t: Task, i: number): void { t.deps.forEach(function (p: number): void { out[p].push(i); }); });
return out;
}
// 선후 순서로 줄 세우기 — 앞 작업이 하나도 안 남은(들어오는 화살표 0) 작업부터 꺼내고, 꺼낼 때마다 뒤 작업의 남은 수를 하나 줄인다.
// 꺼낼 것이 사라졌는데 남은 작업이 있으면 그 작업들은 서로를 기다리는 고리(순환 의존)에 걸렸거나 그 뒤에 매달린 것이다 → stuck
type Order = { order: number[]; stuck: number[] };
function sortByDeps(tasks: Task[]): Order {
const succ = successorsOf(tasks);
const waiting = tasks.map(function (t: Task): number { return t.deps.length; });
const ready: number[] = [];
waiting.forEach(function (w: number, i: number): void { if (w === 0) { ready.push(i); } });
const order: number[] = [];
while (ready.length) {
ready.sort(function (a: number, b: number): number { return a - b; });
const i = ready.shift() as number;
order.push(i);
succ[i].forEach(function (j: number): void { waiting[j] -= 1; if (waiting[j] === 0) { ready.push(j); } });
}
const stuck: number[] = [];
waiting.forEach(function (w: number, i: number): void { if (w > 0) { stuck.push(i); } });
return { order: order, stuck: stuck };
}
// 앞뒤 두 번 훑기 — 앞에서: 가장 이른 시작 = 앞 작업 끝 중 가장 늦은 날(과 정해 둔 시작일 중 늦은 쪽).
// 뒤에서: 가장 늦은 끝 = 뒤 작업의 가장 늦은 시작 중 가장 이른 날(뒤 작업이 없으면 전체 끝). 여유 = 가장 늦은 시작 − 가장 이른 시작
type Layers = { day: number; week: number; month: number };
type Sweep = { es: number[]; ef: number[]; ls: number[]; lf: number[]; slack: number[]; end: number };
function sweepBoth(tasks: Task[], order: number[], minStart: number[] | null): Sweep {
const n = tasks.length;
const es: number[] = new Array(n).fill(0);
const ef: number[] = new Array(n).fill(0);
for (const i of order) {
let s = minStart ? minStart[i] : 0;
for (const p of tasks[i].deps) { s = Math.max(s, ef[p]); }
es[i] = s;
ef[i] = s + tasks[i].dur;
}
let end = 0;
for (const i of order) { end = Math.max(end, ef[i]); }
const succ = successorsOf(tasks);
const ls: number[] = new Array(n).fill(0);
const lf: number[] = new Array(n).fill(0);
for (let k = order.length - 1; k >= 0; k -= 1) {
const i = order[k];
let f = end;
for (const j of succ[i]) { f = Math.min(f, ls[j]); }
lf[i] = f;
ls[i] = f - tasks[i].dur;
}
const slack = es.map(function (s: number, i: number): number { return ls[i] - s; });
return { es: es, ef: ef, ls: ls, lf: lf, slack: slack, end: end };
}
// 밀기 전파 — 밀린 작업에서 가까운 뒤 작업부터 너비 우선으로 훑으며, 새 시작 = 앞 작업 끝 중 가장 늦은 날(지금보다 늦을 때만).
// depth 는 밀린 작업에서 몇 단계 뒤인지(-1 이면 여유가 흡수해 안 밀림). 시작이 바뀐 작업만 다시 큐에 넣는다
type Push = { starts: number[]; depth: number[] };
function pushFrom(tasks: Task[], starts: number[], from: number): Push {
const succ = successorsOf(tasks);
const out = starts.slice();
const depth: number[] = tasks.map(function (): number { return -1; });
depth[from] = 0;
const queue: number[] = [from];
let head = 0;
// 고리가 있어도 끝나도록 꺼내는 횟수에 상한을 둔다
const limit = tasks.length * tasks.length + 1;
while (head < queue.length && head < limit) {
const i = queue[head];
head += 1;
for (const j of succ[i]) {
let need = 0;
for (const p of tasks[j].deps) { need = Math.max(need, out[p] + tasks[p].dur); }
if (need > out[j]) {
out[j] = need;
depth[j] = Math.max(depth[j], depth[i] + 1);
queue.push(j);
}
}
}
return { starts: out, depth: depth };
}
// 날마다 걸린 일감 — 작업이 덮는 날(시작 ≤ 날 < 시작 + 일수)마다 사람 수를 더한다
function dailyLoad(tasks: Task[], starts: number[], days: number): number[] {
const load: number[] = new Array(days).fill(0);
tasks.forEach(function (t: Task, i: number): void {
for (let d = starts[i]; d < starts[i] + t.dur; d += 1) { if (d >= 0 && d < days) { load[d] += t.load; } }
});
return load;
}
// ── 직각 경로 ───────────────────────────────────────────────
// 앞 끝(ex, ey)에서 오른쪽으로 ROUTE_STUB 나간 뒤 아래로 꺾는다. 뒤 시작(sx)이 그 꺾인 자리 + ROUTE_STUB 보다 오른쪽이면 곧장 내려가 오른쪽으로 들어가고,
// 아니면 앞 행 바로 아래 통로(gapY)까지 내려가 왼쪽으로 돌아간 다음 뒤 시작보다 ROUTE_STUB 앞에서 내려가 오른쪽으로 들어간다
const ROUTE_STUB = 7;
function elbowRoute(ex: number, ey: number, sx: number, sy: number, gapY: number): number[] {
const x1 = ex + ROUTE_STUB;
if (sx - ROUTE_STUB >= x1) { return [ex, ey, x1, ey, x1, sy, sx, sy]; }
const x2 = sx - ROUTE_STUB;
return [ex, ey, x1, ey, x1, gapY, x2, gapY, x2, sy, sx, sy];
}
function routeLength(pts: number[]): number {
let s = 0;
for (let i = 2; i < pts.length; i += 2) { s += Math.hypot(pts[i] - pts[i - 2], pts[i + 1] - pts[i - 1]); }
return s;
}
// 경로 위 거리 dist 자리의 점과 그 마디의 방향
function pointOnRoute(pts: number[], dist: number): [number, number, number, number] {
let left = Math.max(0, dist);
for (let i = 2; i < pts.length; i += 2) {
const dx = pts[i] - pts[i - 2];
const dy = pts[i + 1] - pts[i - 1];
const len = Math.hypot(dx, dy);
if (left <= len || i === pts.length - 2) {
const k = len > 0 ? Math.min(1, left / len) : 0;
return [pts[i - 2] + dx * k, pts[i - 1] + dy * k, len > 0 ? dx / len : 1, len > 0 ? dy / len : 0];
}
left -= len;
}
return [pts[0], pts[1], 1, 0];
}
// 경로를 앞에서부터 dist 만큼만 자른 점 목록 — 화살표가 그려져 나가는 장면에 쓴다
function cutRoute(pts: number[], dist: number): number[] {
const out: number[] = [pts[0], pts[1]];
let left = Math.max(0, dist);
for (let i = 2; i < pts.length; i += 2) {
const len = Math.hypot(pts[i] - pts[i - 2], pts[i + 1] - pts[i - 1]);
if (left >= len) { out.push(pts[i], pts[i + 1]); left -= len; continue; }
const k = len > 0 ? left / len : 0;
out.push(lerp(pts[i - 2], pts[i], k), lerp(pts[i - 1], pts[i + 1], k));
break;
}
return out;
}
// 장면 시계(초)와 상태 줄 글자 — 장면 계산이 고치고, 화면 쪽이 읽어 그린다
let sceneT = 0;
let simStatus = '';
// 파스텔 팔레트 — 크림 바탕 위 라벤더·민트·버터·산호 막대. 가장 긴 경로·오늘 선·글자는 진한 쪽으로 명도 차를 둔다
const PAPER: RGB = [251, 247, 238];
const PAPER_DIM: RGB = [243, 237, 224];
const GRID: RGB = [226, 218, 204];
const INK: RGB = [44, 38, 58];
const INK_SOFT: RGB = [112, 104, 126];
const CREAM: RGB = [255, 251, 242];
const LAVENDER: RGB = [190, 174, 236];
const MINT: RGB = [158, 214, 190];
const BUTTER: RGB = [244, 222, 146];
const CORAL: RGB = [244, 170, 150];
const SKY: RGB = [160, 204, 236];
const PINK: RGB = [242, 182, 206];
const PATH_DEEP: RGB = [86, 56, 168];
const TODAY_DEEP: RGB = [190, 72, 60];
const BAR_COLORS: RGB[] = [LAVENDER, MINT, BUTTER, CORAL, SKY, PINK];
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// 표 한 줄 — 작업·시작·끝·여유. 끝은 그 작업이 덮는 마지막 날(일수 0 짜리는 시작과 같은 날)
function tableRow(name: string, start: number, dur: number, slack: number): string[] {
return [name, dayText(start), dayText(start + Math.max(0, dur - 1)), SLACK_FMT.replace('{n}', String(slack))];
}
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const tableEl = root.querySelector('[data-role="table"]') as HTMLTableElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function clearStage(): void {
ctx.fillStyle = rgba(PAPER, 1);
ctx.fillRect(0, 0, W, H);
}
function label(s: string, x: number, y: number, c: RGB, a: number, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
function roundRectPath(x: number, y: number, w: number, h: number, r: number): void {
const rr = Math.max(0, Math.min(r, w / 2, h / 2));
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.lineTo(x + w - rr, y);
ctx.arcTo(x + w, y, x + w, y + rr, rr);
ctx.lineTo(x + w, y + h - rr);
ctx.arcTo(x + w, y + h, x + w - rr, y + h, rr);
ctx.lineTo(x + rr, y + h);
ctx.arcTo(x, y + h, x, y + h - rr, rr);
ctx.lineTo(x, y + rr);
ctx.arcTo(x, y, x + rr, y, rr);
ctx.closePath();
}
// 막대 하나 — 둥근 사각형 채움 + 진한 테두리. 폭이 0 이하이면 그리지 않는다
function bar(x: number, y: number, w: number, h: number, fill: RGB, a: number, line: number): void {
if (w <= 0.2 || a <= 0) { return; }
roundRectPath(x, y, w, h, 4);
ctx.fillStyle = rgba(fill, a);
ctx.fill();
if (line > 0) {
ctx.lineWidth = line;
ctx.strokeStyle = rgba(INK, 0.55 * a);
ctx.stroke();
}
}
// 마일스톤 마름모
function diamond(cx: number, cy: number, r: number, fill: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.moveTo(cx, cy - r);
ctx.lineTo(cx + r, cy);
ctx.lineTo(cx, cy + r);
ctx.lineTo(cx - r, cy);
ctx.closePath();
ctx.fillStyle = rgba(fill, a);
ctx.fill();
ctx.lineWidth = 1.5;
ctx.strokeStyle = rgba(INK, 0.8 * a);
ctx.stroke();
}
function polyline(pts: number[], c: RGB, a: number, width: number): void {
if (pts.length < 4) { return; }
ctx.beginPath();
ctx.moveTo(pts[0], pts[1]);
for (let i = 2; i < pts.length; i += 2) { ctx.lineTo(pts[i], pts[i + 1]); }
ctx.lineWidth = width;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function arrowHead(x: number, y: number, dx: number, dy: number, size: number, c: RGB, a: number): void {
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x - dx * size - dy * size * 0.6, y - dy * size + dx * size * 0.6);
ctx.lineTo(x - dx * size + dy * size * 0.6, y - dy * size - dx * size * 0.6);
ctx.closePath();
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
// 빗금 — (x, y, w, h) 안에 45° 줄을 spacing 간격으로. 간격이 좁을수록 짙어 보인다
function hatch(x: number, y: number, w: number, h: number, spacing: number, c: RGB, a: number): void {
if (w <= 0.5 || a <= 0) { return; }
ctx.save();
roundRectPath(x, y, w, h, 3);
ctx.clip();
ctx.beginPath();
for (let s = x - h; s < x + w; s += Math.max(2, spacing)) {
ctx.moveTo(s, y + h);
ctx.lineTo(s + h, y);
}
ctx.lineWidth = 1.6;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
ctx.restore();
}
// 시간 축 — 위 띠는 월 이름, 아래 띠는 일(월 안의 날 숫자)·주(주차) 층. 층마다 불투명도를 따로 받아 겹쳐 흐려지며 바뀌게 한다.
// 세로 격자도 같은 불투명도로 행 영역(top~bottom)에 긋는다. xOf 는 일수 → 화면 x
function timeAxis(xOf: (d: number) => number, dayW: number, d0: number, d1: number, left: number, right: number, top: number, bottom: number, ly: Layers): void {
ctx.save();
ctx.beginPath();
ctx.rect(left, 0, right - left, H);
ctx.clip();
ctx.fillStyle = rgba(PAPER_DIM, 1);
ctx.fillRect(left, 0, right - left, top - 4);
for (let d = Math.floor(d0); d <= Math.ceil(d1); d += 1) {
const x = xOf(d);
const cal = calendarOf(d);
if (ly.day > 0.01) {
ctx.fillStyle = rgba(GRID, 0.7 * ly.day);
ctx.fillRect(x, top, 1, bottom - top);
if (dayW >= 13) { label(String(cal[1]), x + dayW / 2, top - 11, INK_SOFT, ly.day, 9, 600, 'center'); }
}
// 주 층 — 글자가 겹치지 않을 만큼(36px) 주를 건너뛰며 적는다
const weekStep = 7 * dayW >= 36 ? 1 : (14 * dayW >= 36 ? 2 : 4);
if (ly.week > 0.01 && d % 7 === 0) {
ctx.fillStyle = rgba(GRID, ly.week);
ctx.fillRect(x, top, 1, bottom - top);
if ((d / 7) % weekStep === 0) { label(WEEK_FMT.replace('{n}', String(d / 7 + 1)), x + 3, top - 11, INK_SOFT, ly.week, 9, 600, 'left'); }
}
if (cal[1] === 1) {
ctx.fillStyle = rgba(INK_SOFT, 0.18 + 0.4 * ly.month);
ctx.fillRect(x, 4, 1, bottom - 4);
label(MONTH_FMT.replace('{n}', String(cal[0])), x + 4, 11, INK, 0.85, 10, 700, 'left');
// 월 층 — 그달 칸 가운데에 그달 일수(월 길이 표에서)를 적는다
const len = MONTH_DAYS[cal[0] - 1];
if (ly.month > 0.01 && len * dayW >= 30) { label(SLACK_FMT.replace('{n}', String(len)), x + len * dayW / 2, top - 11, INK_SOFT, ly.month, 9, 700, 'center'); }
} else if (d === Math.floor(d0)) {
// 왼쪽 끝 월 이름 — 바로 오른쪽에 새 달이 시작하면 겹치므로 적지 않는다
const toNext = MONTH_DAYS[cal[0] - 1] - cal[1] + 1;
if (toNext * dayW >= 44) { label(MONTH_FMT.replace('{n}', String(cal[0])), Math.max(x, left) + 4, 11, INK, 0.85, 10, 700, 'left'); }
}
}
ctx.restore();
}
// 왼쪽 작업 이름 칸
function rowLabel(name: string, y: number, a: number, bold: boolean): void {
label(name, 10, y, INK, a, 11, bold ? 800 : 600, 'left');
}
function rowStripe(y: number, h: number, i: number, a: number): void {
if (i % 2 === 1) {
ctx.fillStyle = rgba(PAPER_DIM, 0.75 * a);
ctx.fillRect(0, y, W, h);
}
}
// 움직이는 행 빛 — 그 행 막대의 스프링 속도(k = 0~1)만큼 행 전체가 옅은 라벤더로 밝아진다. 막대가 서면 사라진다
function rowGlow(y: number, h: number, k: number): void {
if (k <= 0.01) { return; }
ctx.fillStyle = rgba(LAVENDER, 0.42 * clamp(k, 0, 1));
ctx.fillRect(0, y, W, h);
}
// 상태 줄 — 바뀔 때만 글자를 고친다
let shownStatus = '';
function pushStatus(): void {
if (!statusEl || simStatus === shownStatus) { return; }
shownStatus = simStatus;
statusEl.textContent = simStatus;
}
// 숨김 표 — 같은 일정을 작업·시작·끝·여유 열로 읽게. 화면에는 안 보이고 화면 읽기 도구가 읽는다
const TABLE_HEAD = ['작업 이름', '시작일', '끝나는 날', '여유 일수'];
function fillTable(caption: string, rows: string[][]): void {
if (!tableEl) { return; }
const tb = tableEl;
while (tb.firstChild) { tb.removeChild(tb.firstChild); }
const cap = document.createElement('caption');
cap.textContent = caption;
tb.appendChild(cap);
const thead = document.createElement('thead');
const hr = document.createElement('tr');
TABLE_HEAD.forEach(function (h: string): void {
const th = document.createElement('th');
th.scope = 'col';
th.textContent = h;
hr.appendChild(th);
});
thead.appendChild(hr);
tb.appendChild(thead);
const body = document.createElement('tbody');
rows.forEach(function (r: string[]): void {
const tr = document.createElement('tr');
r.forEach(function (c: string, k: number): void {
const cell = document.createElement(k === 0 ? 'th' : 'td');
if (k === 0) { (cell as HTMLTableCellElement).scope = 'row'; }
cell.textContent = c;
tr.appendChild(cell);
});
body.appendChild(tr);
});
tb.appendChild(body);
}
// 05 — 기준 날짜를 붙잡은 확대·축소. x = ANCHOR_X + (일수 − ANCHOR_DAY) × 하루 폭 — 하루 폭이 k 배가 되면 x' = a + (x − a) × k 로, 기준 날짜(a) 자리만 그대로다
const LABEL_W = 96;
const PLOT_X = LABEL_W + 6;
const PLOT_R = W - 8;
const ROWS_TOP = 44;
const ROW_H = 20;
const BAR_H = 12;
// 같은 작업 열 개를 일수만 ZOOM_DUR_SCALE 배로 늘려 몇 달짜리 일정으로
const ZOOM_DUR_SCALE = 5;
const ANCHOR_DAY = 52;
const ANCHOR_X = 300;
// 하루 폭 두 끝(px) — 그 사이는 로그 공간에서 보간한다(두 배 → 네 배가 같은 빠르기로 보이게)
const DAY_W_NEAR = 26;
const DAY_W_FAR = 2.2;
const ZOOM_OUT_AT_S = 0.25;
const ZOOM_IN_AT_S = 4.2;
const ZOOM_S = 1.7;
const LOOP_S = 7.4;
// 눈금 층 문턱(하루 폭 px) — 일 층은 DAY_TICK_FADE 위에서 보이고, 주 층은 WEEK_TICK_FADE 사이에서, 월 층은 MONTH_TICK_FADE 아래에서 진해진다
const DAY_TICK_FADE: [number, number] = [9, 16];
const WEEK_TICK_FADE: [number, number] = [2.6, 4.8];
const WEEK_TICK_OUT: [number, number] = [14, 22];
const MONTH_TICK_FADE: [number, number] = [3, 6];
const TABLE_CAPTION = '작업 일정표 — 몇 달짜리 일정';
const tasks05: Task[] = BASE_TASKS.map(function (t: Task): Task { return { name: t.name, dur: t.dur * ZOOM_DUR_SCALE, deps: t.deps.slice(), load: t.load }; });
const sweep05 = sweepBoth(tasks05, sortByDeps(tasks05).order, null);
let dayW05 = DAY_W_NEAR;
// 로그 보간 — ln(하루 폭)을 선형으로 섞은 뒤 되돌린다
function zoomWidth(p: number): number { return Math.exp(lerp(Math.log(DAY_W_NEAR), Math.log(DAY_W_FAR), p)); }
function zoomProgress(t: number): number {
if (t < ZOOM_IN_AT_S) { return smooth((t - ZOOM_OUT_AT_S) / ZOOM_S); }
return 1 - smooth((t - ZOOM_IN_AT_S) / ZOOM_S);
}
function dayX(d: number): number { return ANCHOR_X + (d - ANCHOR_DAY) * dayW05; }
function xDay(x: number): number { return ANCHOR_DAY + (x - ANCHOR_X) / dayW05; }
// 층 불투명도 — 하루 폭 하나에서 세 층이 겹쳐 흐려지는 비율
function layersAt(w: number): Layers {
return {
day: smooth((w - DAY_TICK_FADE[0]) / (DAY_TICK_FADE[1] - DAY_TICK_FADE[0])),
week: smooth((w - WEEK_TICK_FADE[0]) / (WEEK_TICK_FADE[1] - WEEK_TICK_FADE[0])) * (1 - smooth((w - WEEK_TICK_OUT[0]) / (WEEK_TICK_OUT[1] - WEEK_TICK_OUT[0]))),
month: 1 - smooth((w - MONTH_TICK_FADE[0]) / (MONTH_TICK_FADE[1] - MONTH_TICK_FADE[0]))
};
}
function reset(): void { dayW05 = DAY_W_NEAR; }
function update(_dt: number): void { dayW05 = zoomWidth(zoomProgress(sceneT)); }
function restPose(): void { dayW05 = DAY_W_FAR; }
function tableRows(): string[][] {
return tasks05.map(function (t: Task, i: number): string[] { return tableRow(t.name, sweep05.es[i], t.dur, sweep05.slack[i]); });
}
const ANCHOR_FMT = '기준일 {m}월 {d}일';
const WIDTH_FMT = '하루 {w}px';
function rowMid(i: number): number { return ROWS_TOP + i * ROW_H + ROW_H / 2; }
function draw(): void {
clearStage();
const bottom = ROWS_TOP + tasks05.length * ROW_H;
tasks05.forEach(function (_: Task, i: number): void { rowStripe(ROWS_TOP + i * ROW_H, ROW_H, i, 1); });
const ly = layersAt(dayW05);
timeAxis(dayX, dayW05, xDay(PLOT_X) - 1, xDay(PLOT_R) + 1, PLOT_X, PLOT_R, ROWS_TOP, bottom, ly);
ctx.save();
ctx.beginPath();
ctx.rect(PLOT_X, ROWS_TOP, PLOT_R - PLOT_X, bottom - ROWS_TOP);
ctx.clip();
tasks05.forEach(function (t: Task, i: number): void {
const cy = rowMid(i);
const x0 = dayX(sweep05.es[i]);
if (t.dur === 0) {
diamond(x0, cy, 7, PATH_DEEP, 1);
return;
}
bar(x0, cy - BAR_H / 2, t.dur * dayW05, BAR_H, BAR_COLORS[i % BAR_COLORS.length], 1, 1.2);
// 막대 안 일수 — 글자가 들어갈 만큼 넓을 때만
const w = t.dur * dayW05;
if (w > 34) { label(SLACK_FMT.replace('{n}', String(t.dur)), Math.max(x0, PLOT_X) + 6, cy, INK, smooth((w - 34) / 20), 9, 700, 'left'); }
});
ctx.restore();
tasks05.forEach(function (t: Task, i: number): void { rowLabel(t.name, rowMid(i), 1, false); });
// 기준 날짜 — 확대·축소 내내 화면 x 가 ANCHOR_X 에 머문다
ctx.save();
ctx.setLineDash([4, 3]);
ctx.beginPath();
ctx.moveTo(ANCHOR_X, ROWS_TOP - 6);
ctx.lineTo(ANCHOR_X, bottom);
ctx.lineWidth = 2;
ctx.strokeStyle = rgba(PATH_DEEP, 0.95);
ctx.stroke();
ctx.restore();
const cal = calendarOf(ANCHOR_DAY);
const tag = ANCHOR_FMT.replace('{m}', String(cal[0])).replace('{d}', String(cal[1]));
ctx.font = '800 10px ' + FONT_STACK;
const tw = ctx.measureText(tag).width + 12;
bar(ANCHOR_X - tw / 2, bottom + 3, tw, 16, PATH_DEEP, 1, 0);
label(tag, ANCHOR_X, bottom + 11, CREAM, 1, 10, 800, 'center');
label(WIDTH_FMT.replace('{w}', dayW05.toFixed(1)), PLOT_X, bottom + 11, INK_SOFT, 1, 10, 700, 'left');
}
// 한 프레임에 넘기는 시간 상한(초) — 탭을 오래 숨겼다 돌아온 첫 프레임에도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 손대면 시연 표시를 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
// 리스너는 한 묶음으로 달고 정리할 때 한 번에 뗀다
const off = new AbortController();
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
if (statusEl) { statusEl.setAttribute('aria-live', 'polite'); }
}
// 장면 시계를 dt 만큼 — 한 바퀴(LOOP_S)를 넘으면 처음 배치로 되돌려 다시 시연한다
function advance(dt: number): void {
sceneT += dt;
if (sceneT >= LOOP_S) {
sceneT -= LOOP_S;
reset();
}
update(dt);
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) { advance(dt); }
draw();
pushStatus();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 시계 다시 맞추기 — 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 동작 줄이기 — 루프를 돌리지 않고 시연이 끝난 최종 배치 한 장만 그린다
function showRest(): void {
stopLoop();
sceneT = 0;
reset();
restPose();
draw();
pushStatus();
}
function play(): void {
sceneT = 0;
reset();
draw();
pushStatus();
if (!paused) { startLoop(); }
}
pauseBtn.addEventListener('click', function (): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}, { signal: off.signal });
// 탭이 숨으면 루프를 멈추고, 돌아오면 시계를 다시 맞춰 시작한다 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 페이지를 연 채 바뀌면 — 켜지면 최종 배치로 멈추고, 꺼지면 시연을 처음부터
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { play(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
draw();
}, { signal: off.signal });
fitCanvas();
fillTable(TABLE_CAPTION, tableRows());
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("cf") + " is-demo"} ref={rootRef}>
<canvas className={cx("cf__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label="기준 날짜 위치를 붙잡은 채 하루 폭이 바뀌며 일·주·월 눈금이 서로 흐려지며 바뀌는 일정표."></canvas>
<table className={cx("cf__sr")} data-role="table"></table>
<button className={cx("cf__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
</div>
);
}05 · 기준 날짜를 붙잡고 일·주·월로 늘었다 줄어드는 눈금 — 측정 누적면적 24.965% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 로그 보간 — ln(하루 폭)을 선형으로 섞은 뒤 되돌린다
function zoomWidth(p) { return Math.exp(lerp(Math.log(DAY_W_NEAR), Math.log(DAY_W_FAR), p)); }
function zoomProgress(t) {
if (t < ZOOM_IN_AT_S) {
return smooth((t - ZOOM_OUT_AT_S) / ZOOM_S);
}
return 1 - smooth((t - ZOOM_IN_AT_S) / ZOOM_S);
}
function dayX(d) { return ANCHOR_X + (d - ANCHOR_DAY) * dayW05; }
function xDay(x) { return ANCHOR_DAY + (x - ANCHOR_X) / dayW05; }
// 층 불투명도 — 하루 폭 하나에서 세 층이 겹쳐 흐려지는 비율
function layersAt(w) {
return {
day: smooth((w - DAY_TICK_FADE[0]) / (DAY_TICK_FADE[1] - DAY_TICK_FADE[0])),
week: smooth((w - WEEK_TICK_FADE[0]) / (WEEK_TICK_FADE[1] - WEEK_TICK_FADE[0])) * (1 - smooth((w - WEEK_TICK_OUT[0]) / (WEEK_TICK_OUT[1] - WEEK_TICK_OUT[0]))),
month: 1 - smooth((w - MONTH_TICK_FADE[0]) / (MONTH_TICK_FADE[1] - MONTH_TICK_FADE[0]))
};
}
06오늘 선이 지나가며 계획보다 늦은 막대를 드러내는 실적 비교
오늘 선이 초당 6.5일로 지나가는 동안 작업마다 기대 진행률(경과 ÷ 기간)과 실제 진행률(경과 ÷ 일수 × ACTUAL_PACE)을 0~1 로 잘라 구하고, 그 차이를 0.3 으로 나눈 짙기만큼 실제 끝과 기대 끝 사이의 빗금 간격을 7px 에서 2.5px 로 좁히며 진하게 칠한다. 문서 작성은 오늘이 기준일에서 10일 지났을 때 50%, 디자인 시안은 7일 지났을 때 40% 늦어 가장 크게 벌어지고, 계획 막대는 4px 오른쪽 아래로 민 옅은 그림자로 뒤에 깔린다. 주간 업무 보고 화면에서 늦은 정도를 '{n}% 늦음' 글자와 빗금 농도 두 겹으로 보여 준다.

<main class="stage">
<div class="cf is-demo" id="cf">
<canvas class="cf__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="오늘 선이 지나가며 계획보다 늦은 작업에 빗금이 짙어지고 계획 막대가 그림자로 깔린 일정표."></canvas>
<table class="cf__sr" data-role="table"></table>
<button class="cf__pause" data-role="pause" type="button" aria-pressed="false">일시정지</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.cf {
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-cream;
}
.cf__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.cf__pause {
position: absolute;
right: $sp-3;
bottom: $sp-3;
padding: $sp-1 $sp-3;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.cf__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.cf__pause:hover {
transform: translateY(-2px);
}
.cf__pause:active {
transform: translateY(1px);
}
.cf__pause:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 일정에 무슨 일이 일어났는지 글자로. 시연 중에는 aria-live off, 사람이 처음 손대면 polite
.cf__status {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: calc(100% - 120px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
// 숨김 표 — 화면에서는 1px 로 접어 두고 화면 읽기 도구만 읽는다
.cf__sr {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
@media (max-width: 340px) {
.cf__pause,
.cf__status { font-size: 10px; padding: $sp-1 $sp-2; }
}
// 동작 줄이기 — 장면은 스크립트가 최종 배치 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.cf__pause { transition: none !important; }
.cf__pause:hover,
.cf__pause:active { transform: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cf {
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: #fff7e6;
}
.cf__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.cf__pause {
position: absolute;
right: 12px;
bottom: 12px;
padding: 4px 12px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.cf__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.cf__pause:hover {
transform: translateY(-2px);
}
.cf__pause:active {
transform: translateY(1px);
}
.cf__pause:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.cf__status {
position: absolute;
left: 12px;
bottom: 12px;
max-width: calc(100% - 120px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
.cf__sr {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
@media (max-width: 340px) {
.cf__pause,
.cf__status {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.cf__pause {
transition: none !important;
}
.cf__pause:hover,
.cf__pause:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("cf");
if (!root) return;
// 판 좌표 — 캔버스 안은 늘 480×300. 화면에 보이는 크기와 상관없이 계산은 이 좌표로 한다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
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); }
// 따라가기 비율 — 1 − e^(−rate·dt). 프레임 시간이 달라도 1초에 같은 만큼 다가간다
function follow(rate, dt) { return 1 - Math.exp(-rate * dt); }
// 시드 고정 난수 — 같은 시드면 같은 일정이 다시 나온다 (32비트 정수 섞기)
function makeRng(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function makeSpring(x) { return { x: x, v: 0, to: x }; }
const SPRING_SUBSTEP = 1 / 240;
function stepSpring(s, stiffness, damping, dt) {
let left = dt;
while (left > 1e-9) {
const h = Math.min(SPRING_SUBSTEP, left);
s.v += (stiffness * (s.to - s.x) - damping * s.v) * h;
s.x += s.v * h;
left -= h;
}
}
function snapSpring(s, x) { s.x = x; s.v = 0; s.to = x; }
// ── 날짜 = 정수 일수 ─────────────────────────────────────────
// 날짜 객체를 쓰지 않는다. 기준일을 0 으로 세는 정수 일수(day index)만 다루고, 화면의 월·일 숫자는 아래 월 길이 표로 산수해 만든다
const MONTH_DAYS = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
const YEAR_DAYS = 365;
// 기준일(일수 0) = 한 해의 70번째 날 → 표로 세면 3월 11일
const DAY_ZERO_OF_YEAR = 69;
function calendarOf(day) {
let r = ((day + DAY_ZERO_OF_YEAR) % YEAR_DAYS + YEAR_DAYS) % YEAR_DAYS;
let m = 0;
while (r >= MONTH_DAYS[m]) {
r -= MONTH_DAYS[m];
m += 1;
}
return [m + 1, r + 1];
}
const DAY_FMT = '{n}일차';
const WEEK_FMT = '{n}주차';
const MONTH_FMT = '{n}월';
const SLACK_FMT = '{n}일';
function dayText(day) { return DAY_FMT.replace('{n}', String(day + 1)); }
// 하루 차 계산의 함정 — 시각(시간 단위)끼리 빼서 24 로 나누면, 시계가 한 시간 당겨진 날이 끼었을 때 정수가 아니다(23시간짜리 하루).
// 그래서 바깥에서 들어온 시각은 반올림으로 정수 일수에 붙이고, 그다음부터는 정수끼리만 더하고 뺀다
const HOURS_PER_DAY = 24;
const CLOCK_SHIFT_HOURS = 1;
function hoursToDayIndex(hours) { return Math.round(hours / HOURS_PER_DAY); }
const BASE_TASKS = [
{ name: '요구 정리', dur: 3, deps: [], load: 2 },
{ name: '디자인 시안', dur: 4, deps: [0], load: 1 },
{ name: '데이터 설계', dur: 3, deps: [0], load: 1 },
{ name: '화면 개발', dur: 5, deps: [1], load: 2 },
{ name: '서버 개발', dur: 7, deps: [2], load: 1 },
{ name: '기능 연동', dur: 3, deps: [3, 4], load: 2 },
{ name: '문서 작성', dur: 3, deps: [1], load: 1 },
{ name: '통합 검수', dur: 3, deps: [5], load: 2 },
{ name: '오류 수정', dur: 2, deps: [7], load: 1 },
{ name: '출시', dur: 0, deps: [8, 6], load: 0 }
];
function copyTasks(ts) { return ts.map(function (t) { return { name: t.name, dur: t.dur, deps: t.deps.slice(), load: t.load }; }); }
function successorsOf(tasks) {
const out = tasks.map(function () { return []; });
tasks.forEach(function (t, i) { t.deps.forEach(function (p) { out[p].push(i); }); });
return out;
}
function sortByDeps(tasks) {
const succ = successorsOf(tasks);
const waiting = tasks.map(function (t) { return t.deps.length; });
const ready = [];
waiting.forEach(function (w, i) { if (w === 0) {
ready.push(i);
} });
const order = [];
while (ready.length) {
ready.sort(function (a, b) { return a - b; });
const i = ready.shift();
order.push(i);
succ[i].forEach(function (j) { waiting[j] -= 1; if (waiting[j] === 0) {
ready.push(j);
} });
}
const stuck = [];
waiting.forEach(function (w, i) { if (w > 0) {
stuck.push(i);
} });
return { order: order, stuck: stuck };
}
function sweepBoth(tasks, order, minStart) {
const n = tasks.length;
const es = new Array(n).fill(0);
const ef = new Array(n).fill(0);
for (const i of order) {
let s = minStart ? minStart[i] : 0;
for (const p of tasks[i].deps) {
s = Math.max(s, ef[p]);
}
es[i] = s;
ef[i] = s + tasks[i].dur;
}
let end = 0;
for (const i of order) {
end = Math.max(end, ef[i]);
}
const succ = successorsOf(tasks);
const ls = new Array(n).fill(0);
const lf = new Array(n).fill(0);
for (let k = order.length - 1; k >= 0; k -= 1) {
const i = order[k];
let f = end;
for (const j of succ[i]) {
f = Math.min(f, ls[j]);
}
lf[i] = f;
ls[i] = f - tasks[i].dur;
}
const slack = es.map(function (s, i) { return ls[i] - s; });
return { es: es, ef: ef, ls: ls, lf: lf, slack: slack, end: end };
}
function pushFrom(tasks, starts, from) {
const succ = successorsOf(tasks);
const out = starts.slice();
const depth = tasks.map(function () { return -1; });
depth[from] = 0;
const queue = [from];
let head = 0;
// 고리가 있어도 끝나도록 꺼내는 횟수에 상한을 둔다
const limit = tasks.length * tasks.length + 1;
while (head < queue.length && head < limit) {
const i = queue[head];
head += 1;
for (const j of succ[i]) {
let need = 0;
for (const p of tasks[j].deps) {
need = Math.max(need, out[p] + tasks[p].dur);
}
if (need > out[j]) {
out[j] = need;
depth[j] = Math.max(depth[j], depth[i] + 1);
queue.push(j);
}
}
}
return { starts: out, depth: depth };
}
// 날마다 걸린 일감 — 작업이 덮는 날(시작 ≤ 날 < 시작 + 일수)마다 사람 수를 더한다
function dailyLoad(tasks, starts, days) {
const load = new Array(days).fill(0);
tasks.forEach(function (t, i) {
for (let d = starts[i]; d < starts[i] + t.dur; d += 1) {
if (d >= 0 && d < days) {
load[d] += t.load;
}
}
});
return load;
}
// ── 직각 경로 ───────────────────────────────────────────────
// 앞 끝(ex, ey)에서 오른쪽으로 ROUTE_STUB 나간 뒤 아래로 꺾는다. 뒤 시작(sx)이 그 꺾인 자리 + ROUTE_STUB 보다 오른쪽이면 곧장 내려가 오른쪽으로 들어가고,
// 아니면 앞 행 바로 아래 통로(gapY)까지 내려가 왼쪽으로 돌아간 다음 뒤 시작보다 ROUTE_STUB 앞에서 내려가 오른쪽으로 들어간다
const ROUTE_STUB = 7;
function elbowRoute(ex, ey, sx, sy, gapY) {
const x1 = ex + ROUTE_STUB;
if (sx - ROUTE_STUB >= x1) {
return [ex, ey, x1, ey, x1, sy, sx, sy];
}
const x2 = sx - ROUTE_STUB;
return [ex, ey, x1, ey, x1, gapY, x2, gapY, x2, sy, sx, sy];
}
function routeLength(pts) {
let s = 0;
for (let i = 2; i < pts.length; i += 2) {
s += Math.hypot(pts[i] - pts[i - 2], pts[i + 1] - pts[i - 1]);
}
return s;
}
// 경로 위 거리 dist 자리의 점과 그 마디의 방향
function pointOnRoute(pts, dist) {
let left = Math.max(0, dist);
for (let i = 2; i < pts.length; i += 2) {
const dx = pts[i] - pts[i - 2];
const dy = pts[i + 1] - pts[i - 1];
const len = Math.hypot(dx, dy);
if (left <= len || i === pts.length - 2) {
const k = len > 0 ? Math.min(1, left / len) : 0;
return [pts[i - 2] + dx * k, pts[i - 1] + dy * k, len > 0 ? dx / len : 1, len > 0 ? dy / len : 0];
}
left -= len;
}
return [pts[0], pts[1], 1, 0];
}
// 경로를 앞에서부터 dist 만큼만 자른 점 목록 — 화살표가 그려져 나가는 장면에 쓴다
function cutRoute(pts, dist) {
const out = [pts[0], pts[1]];
let left = Math.max(0, dist);
for (let i = 2; i < pts.length; i += 2) {
const len = Math.hypot(pts[i] - pts[i - 2], pts[i + 1] - pts[i - 1]);
if (left >= len) {
out.push(pts[i], pts[i + 1]);
left -= len;
continue;
}
const k = len > 0 ? left / len : 0;
out.push(lerp(pts[i - 2], pts[i], k), lerp(pts[i - 1], pts[i + 1], k));
break;
}
return out;
}
// 장면 시계(초)와 상태 줄 글자 — 장면 계산이 고치고, 화면 쪽이 읽어 그린다
let sceneT = 0;
let simStatus = '';
// 파스텔 팔레트 — 크림 바탕 위 라벤더·민트·버터·산호 막대. 가장 긴 경로·오늘 선·글자는 진한 쪽으로 명도 차를 둔다
const PAPER = [251, 247, 238];
const PAPER_DIM = [243, 237, 224];
const GRID = [226, 218, 204];
const INK = [44, 38, 58];
const INK_SOFT = [112, 104, 126];
const CREAM = [255, 251, 242];
const LAVENDER = [190, 174, 236];
const MINT = [158, 214, 190];
const BUTTER = [244, 222, 146];
const CORAL = [244, 170, 150];
const SKY = [160, 204, 236];
const PINK = [242, 182, 206];
const PATH_DEEP = [86, 56, 168];
const TODAY_DEEP = [190, 72, 60];
const BAR_COLORS = [LAVENDER, MINT, BUTTER, CORAL, SKY, PINK];
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// 표 한 줄 — 작업·시작·끝·여유. 끝은 그 작업이 덮는 마지막 날(일수 0 짜리는 시작과 같은 날)
function tableRow(name, start, dur, slack) {
return [name, dayText(start), dayText(start + Math.max(0, dur - 1)), SLACK_FMT.replace('{n}', String(slack))];
}
const canvasEl = root.querySelector('[data-role="canvas"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const tableEl = root.querySelector('[data-role="table"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function clearStage() {
ctx.fillStyle = rgba(PAPER, 1);
ctx.fillRect(0, 0, W, H);
}
function label(s, x, y, c, a, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
function roundRectPath(x, y, w, h, r) {
const rr = Math.max(0, Math.min(r, w / 2, h / 2));
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.lineTo(x + w - rr, y);
ctx.arcTo(x + w, y, x + w, y + rr, rr);
ctx.lineTo(x + w, y + h - rr);
ctx.arcTo(x + w, y + h, x + w - rr, y + h, rr);
ctx.lineTo(x + rr, y + h);
ctx.arcTo(x, y + h, x, y + h - rr, rr);
ctx.lineTo(x, y + rr);
ctx.arcTo(x, y, x + rr, y, rr);
ctx.closePath();
}
// 막대 하나 — 둥근 사각형 채움 + 진한 테두리. 폭이 0 이하이면 그리지 않는다
function bar(x, y, w, h, fill, a, line) {
if (w <= 0.2 || a <= 0) {
return;
}
roundRectPath(x, y, w, h, 4);
ctx.fillStyle = rgba(fill, a);
ctx.fill();
if (line > 0) {
ctx.lineWidth = line;
ctx.strokeStyle = rgba(INK, 0.55 * a);
ctx.stroke();
}
}
// 마일스톤 마름모
function diamond(cx, cy, r, fill, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.moveTo(cx, cy - r);
ctx.lineTo(cx + r, cy);
ctx.lineTo(cx, cy + r);
ctx.lineTo(cx - r, cy);
ctx.closePath();
ctx.fillStyle = rgba(fill, a);
ctx.fill();
ctx.lineWidth = 1.5;
ctx.strokeStyle = rgba(INK, 0.8 * a);
ctx.stroke();
}
function polyline(pts, c, a, width) {
if (pts.length < 4) {
return;
}
ctx.beginPath();
ctx.moveTo(pts[0], pts[1]);
for (let i = 2; i < pts.length; i += 2) {
ctx.lineTo(pts[i], pts[i + 1]);
}
ctx.lineWidth = width;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function arrowHead(x, y, dx, dy, size, c, a) {
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x - dx * size - dy * size * 0.6, y - dy * size + dx * size * 0.6);
ctx.lineTo(x - dx * size + dy * size * 0.6, y - dy * size - dx * size * 0.6);
ctx.closePath();
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
// 빗금 — (x, y, w, h) 안에 45° 줄을 spacing 간격으로. 간격이 좁을수록 짙어 보인다
function hatch(x, y, w, h, spacing, c, a) {
if (w <= 0.5 || a <= 0) {
return;
}
ctx.save();
roundRectPath(x, y, w, h, 3);
ctx.clip();
ctx.beginPath();
for (let s = x - h; s < x + w; s += Math.max(2, spacing)) {
ctx.moveTo(s, y + h);
ctx.lineTo(s + h, y);
}
ctx.lineWidth = 1.6;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
ctx.restore();
}
// 시간 축 — 위 띠는 월 이름, 아래 띠는 일(월 안의 날 숫자)·주(주차) 층. 층마다 불투명도를 따로 받아 겹쳐 흐려지며 바뀌게 한다.
// 세로 격자도 같은 불투명도로 행 영역(top~bottom)에 긋는다. xOf 는 일수 → 화면 x
function timeAxis(xOf, dayW, d0, d1, left, right, top, bottom, ly) {
ctx.save();
ctx.beginPath();
ctx.rect(left, 0, right - left, H);
ctx.clip();
ctx.fillStyle = rgba(PAPER_DIM, 1);
ctx.fillRect(left, 0, right - left, top - 4);
for (let d = Math.floor(d0); d <= Math.ceil(d1); d += 1) {
const x = xOf(d);
const cal = calendarOf(d);
if (ly.day > 0.01) {
ctx.fillStyle = rgba(GRID, 0.7 * ly.day);
ctx.fillRect(x, top, 1, bottom - top);
if (dayW >= 13) {
label(String(cal[1]), x + dayW / 2, top - 11, INK_SOFT, ly.day, 9, 600, 'center');
}
}
// 주 층 — 글자가 겹치지 않을 만큼(36px) 주를 건너뛰며 적는다
const weekStep = 7 * dayW >= 36 ? 1 : (14 * dayW >= 36 ? 2 : 4);
if (ly.week > 0.01 && d % 7 === 0) {
ctx.fillStyle = rgba(GRID, ly.week);
ctx.fillRect(x, top, 1, bottom - top);
if ((d / 7) % weekStep === 0) {
label(WEEK_FMT.replace('{n}', String(d / 7 + 1)), x + 3, top - 11, INK_SOFT, ly.week, 9, 600, 'left');
}
}
if (cal[1] === 1) {
ctx.fillStyle = rgba(INK_SOFT, 0.18 + 0.4 * ly.month);
ctx.fillRect(x, 4, 1, bottom - 4);
label(MONTH_FMT.replace('{n}', String(cal[0])), x + 4, 11, INK, 0.85, 10, 700, 'left');
// 월 층 — 그달 칸 가운데에 그달 일수(월 길이 표에서)를 적는다
const len = MONTH_DAYS[cal[0] - 1];
if (ly.month > 0.01 && len * dayW >= 30) {
label(SLACK_FMT.replace('{n}', String(len)), x + len * dayW / 2, top - 11, INK_SOFT, ly.month, 9, 700, 'center');
}
}
else if (d === Math.floor(d0)) {
// 왼쪽 끝 월 이름 — 바로 오른쪽에 새 달이 시작하면 겹치므로 적지 않는다
const toNext = MONTH_DAYS[cal[0] - 1] - cal[1] + 1;
if (toNext * dayW >= 44) {
label(MONTH_FMT.replace('{n}', String(cal[0])), Math.max(x, left) + 4, 11, INK, 0.85, 10, 700, 'left');
}
}
}
ctx.restore();
}
// 왼쪽 작업 이름 칸
function rowLabel(name, y, a, bold) {
label(name, 10, y, INK, a, 11, bold ? 800 : 600, 'left');
}
function rowStripe(y, h, i, a) {
if (i % 2 === 1) {
ctx.fillStyle = rgba(PAPER_DIM, 0.75 * a);
ctx.fillRect(0, y, W, h);
}
}
// 움직이는 행 빛 — 그 행 막대의 스프링 속도(k = 0~1)만큼 행 전체가 옅은 라벤더로 밝아진다. 막대가 서면 사라진다
function rowGlow(y, h, k) {
if (k <= 0.01) {
return;
}
ctx.fillStyle = rgba(LAVENDER, 0.42 * clamp(k, 0, 1));
ctx.fillRect(0, y, W, h);
}
// 상태 줄 — 바뀔 때만 글자를 고친다
let shownStatus = '';
function pushStatus() {
if (!statusEl || simStatus === shownStatus) {
return;
}
shownStatus = simStatus;
statusEl.textContent = simStatus;
}
// 숨김 표 — 같은 일정을 작업·시작·끝·여유 열로 읽게. 화면에는 안 보이고 화면 읽기 도구가 읽는다
const TABLE_HEAD = ['작업 이름', '시작일', '끝나는 날', '여유 일수'];
function fillTable(caption, rows) {
if (!tableEl) {
return;
}
const tb = tableEl;
while (tb.firstChild) {
tb.removeChild(tb.firstChild);
}
const cap = document.createElement('caption');
cap.textContent = caption;
tb.appendChild(cap);
const thead = document.createElement('thead');
const hr = document.createElement('tr');
TABLE_HEAD.forEach(function (h) {
const th = document.createElement('th');
th.scope = 'col';
th.textContent = h;
hr.appendChild(th);
});
thead.appendChild(hr);
tb.appendChild(thead);
const body = document.createElement('tbody');
rows.forEach(function (r) {
const tr = document.createElement('tr');
r.forEach(function (c, k) {
const cell = document.createElement(k === 0 ? 'th' : 'td');
if (k === 0) {
cell.scope = 'row';
}
cell.textContent = c;
tr.appendChild(cell);
});
body.appendChild(tr);
});
tb.appendChild(body);
}
// 06 — 계획 대비 실적. 기대 진행률 = clamp((오늘 − 시작) / (끝 − 시작), 0, 1), 실제 진행률은 작업마다 정해 둔 빠르기로 쌓인다. 차이(늦음)만큼 빗금을 짙게 한다
const LABEL_W = 96;
const PLOT_X = LABEL_W + 6;
const DAY_WIDTH = 15;
const ROWS_TOP = 44;
const ROW_H = 20;
const BAR_H = 11;
// 계획 막대(그림자)를 실적 막대 뒤로 미는 거리(px)
const PLAN_SHADOW_OFFSET = 4;
// 작업마다 실제 빠르기 — 1 이면 계획대로, 0.6 이면 계획의 60% 속도
const ACTUAL_PACE = [1, 0.6, 1, 0.7, 0.9, 1, 0.5, 0.75, 0.8, 1];
// 늦음이 이만큼(진행률 차)이면 빗금이 가장 짙다
const LAG_FULL = 0.3;
// 오늘 선 속도(일수/초)와 출발·멈춤
const TODAY_DAYS_PER_S = 6.5;
const TODAY_START_S = 0.25;
const TODAY_FROM = -0.5;
const TODAY_TO = 22.5;
const LOOP_S = 5.4;
const REST_TODAY = 12.5;
const TABLE_CAPTION = '작업 일정표 — 계획';
const tasks06 = BASE_TASKS;
const sweep06 = sweepBoth(tasks06, sortByDeps(tasks06).order, null);
let today06 = TODAY_FROM;
function expectedAt(i, today) {
const t = tasks06[i];
if (t.dur === 0) {
return today >= sweep06.es[i] ? 1 : 0;
}
return clamp((today - sweep06.es[i]) / (sweep06.ef[i] - sweep06.es[i]), 0, 1);
}
function actualAt(i, today) {
const t = tasks06[i];
if (t.dur === 0) {
return today >= sweep06.es[i] ? 1 : 0;
}
return clamp((today - sweep06.es[i]) / t.dur * ACTUAL_PACE[i], 0, 1);
}
// 늦음 → 빗금 짙기 0~1
function lagLevel(i, today) { return clamp((expectedAt(i, today) - actualAt(i, today)) / LAG_FULL, 0, 1); }
function reset() { today06 = TODAY_FROM; }
function update(_dt) { today06 = clamp(TODAY_FROM + Math.max(0, sceneT - TODAY_START_S) * TODAY_DAYS_PER_S, TODAY_FROM, TODAY_TO); }
function restPose() { today06 = REST_TODAY; }
function tableRows() {
return tasks06.map(function (t, i) { return tableRow(t.name, sweep06.es[i], t.dur, sweep06.slack[i]); });
}
const LAG_FMT = '{n}% 늦음';
function dayX(d) { return PLOT_X + d * DAY_WIDTH; }
function rowMid(i) { return ROWS_TOP + i * ROW_H + ROW_H / 2; }
function draw() {
clearStage();
const bottom = ROWS_TOP + tasks06.length * ROW_H;
tasks06.forEach(function (_, i) { rowStripe(ROWS_TOP + i * ROW_H, ROW_H, i, 1); });
timeAxis(dayX, DAY_WIDTH, 0, 25, PLOT_X, W - 8, ROWS_TOP, bottom, { day: 1, week: 0, month: 0 });
// 지나간 날 — 오늘 선 왼쪽을 옅게 덮는다
const tx = dayX(today06);
ctx.fillStyle = rgba(INK, 0.045);
ctx.fillRect(PLOT_X, ROWS_TOP, Math.max(0, tx - PLOT_X), bottom - ROWS_TOP);
tasks06.forEach(function (t, i) {
const cy = rowMid(i);
const x0 = dayX(sweep06.es[i]);
const lag = lagLevel(i, today06);
rowLabel(t.name, cy, 1, lag > 0.3);
if (t.dur === 0) {
diamond(x0, cy, 7, actualAt(i, today06) >= 1 ? PATH_DEEP : PAPER, 1);
return;
}
const w = t.dur * DAY_WIDTH;
const c = BAR_COLORS[i % BAR_COLORS.length];
// 계획 막대 — 뒤에 깔린 그림자
bar(x0 + PLAN_SHADOW_OFFSET, cy - BAR_H / 2 + PLAN_SHADOW_OFFSET, w, BAR_H, INK_SOFT, 0.22, 0);
bar(x0, cy - BAR_H / 2, w, BAR_H, c, 0.32, 1);
const act = actualAt(i, today06);
const exp = expectedAt(i, today06);
bar(x0, cy - BAR_H / 2, w * act, BAR_H, c, 1, 1.2);
// 늦은 몫 — 실제 끝에서 기대 끝까지 빗금. 늦을수록 촘촘하고 짙다
if (exp - act > 0.005) {
hatch(x0 + w * act, cy - BAR_H / 2, w * (exp - act), BAR_H, lerp(7, 2.5, lag), TODAY_DEEP, 0.35 + 0.6 * lag);
}
if (lag > 0.15) {
label(LAG_FMT.replace('{n}', String(Math.round((exp - act) * 100))), x0 + w + 6, cy, TODAY_DEEP, smooth((lag - 0.15) / 0.3), 9, 800, 'left');
}
});
// 오늘 선
ctx.fillStyle = rgba(TODAY_DEEP, 1);
ctx.fillRect(tx - 1, ROWS_TOP - 6, 2, bottom - ROWS_TOP + 6);
const tag = dayText(Math.max(0, Math.floor(today06)));
ctx.font = '800 10px ' + FONT_STACK;
const tw = ctx.measureText(tag).width + 12;
const bx = clamp(tx - tw / 2, PLOT_X, W - 8 - tw);
bar(bx, bottom + 3, tw, 16, TODAY_DEEP, 1, 0);
label(tag, bx + tw / 2, bottom + 11, CREAM, 1, 10, 800, 'center');
}
// 한 프레임에 넘기는 시간 상한(초) — 탭을 오래 숨겼다 돌아온 첫 프레임에도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 손대면 시연 표시를 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
// 리스너는 한 묶음으로 달고 정리할 때 한 번에 뗀다
const off = new AbortController();
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
if (statusEl) {
statusEl.setAttribute('aria-live', 'polite');
}
}
// 장면 시계를 dt 만큼 — 한 바퀴(LOOP_S)를 넘으면 처음 배치로 되돌려 다시 시연한다
function advance(dt) {
sceneT += dt;
if (sceneT >= LOOP_S) {
sceneT -= LOOP_S;
reset();
}
update(dt);
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
advance(dt);
}
draw();
pushStatus();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 시계 다시 맞추기 — 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 동작 줄이기 — 루프를 돌리지 않고 시연이 끝난 최종 배치 한 장만 그린다
function showRest() {
stopLoop();
sceneT = 0;
reset();
restPose();
draw();
pushStatus();
}
function play() {
sceneT = 0;
reset();
draw();
pushStatus();
if (!paused) {
startLoop();
}
}
pauseBtn.addEventListener('click', function () {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}, { signal: off.signal });
// 탭이 숨으면 루프를 멈추고, 돌아오면 시계를 다시 맞춰 시작한다 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 페이지를 연 채 바뀌면 — 켜지면 최종 배치로 멈추고, 꺼지면 시연을 처음부터
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
play();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
draw();
}, { signal: off.signal });
fitCanvas();
fillTable(TABLE_CAPTION, tableRows());
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// PlanActualTodayLag - 같은 항목 vanilla 판 index.html 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./PlanActualTodayLag.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function PlanActualTodayLag() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
type Pt = [number, number];
// 판 좌표 — 캔버스 안은 늘 480×300. 화면에 보이는 크기와 상관없이 계산은 이 좌표로 한다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
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); }
// 따라가기 비율 — 1 − e^(−rate·dt). 프레임 시간이 달라도 1초에 같은 만큼 다가간다
function follow(rate: number, dt: number): number { return 1 - Math.exp(-rate * dt); }
// 시드 고정 난수 — 같은 시드면 같은 일정이 다시 나온다 (32비트 정수 섞기)
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// ── 감쇠 스프링 ──────────────────────────────────────────────
// 가속 = 단단함 × (목표 − 위치) − 감쇠 × 속도. 속도를 먼저 고친 뒤 위치를 옮기는 적분을 1/240초 잔걸음으로 나눠 돌린다
type Spring = { x: number; v: number; to: number };
function makeSpring(x: number): Spring { return { x: x, v: 0, to: x }; }
const SPRING_SUBSTEP = 1 / 240;
function stepSpring(s: Spring, stiffness: number, damping: number, dt: number): void {
let left = dt;
while (left > 1e-9) {
const h = Math.min(SPRING_SUBSTEP, left);
s.v += (stiffness * (s.to - s.x) - damping * s.v) * h;
s.x += s.v * h;
left -= h;
}
}
function snapSpring(s: Spring, x: number): void { s.x = x; s.v = 0; s.to = x; }
// ── 날짜 = 정수 일수 ─────────────────────────────────────────
// 날짜 객체를 쓰지 않는다. 기준일을 0 으로 세는 정수 일수(day index)만 다루고, 화면의 월·일 숫자는 아래 월 길이 표로 산수해 만든다
const MONTH_DAYS = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
const YEAR_DAYS = 365;
// 기준일(일수 0) = 한 해의 70번째 날 → 표로 세면 3월 11일
const DAY_ZERO_OF_YEAR = 69;
function calendarOf(day: number): [number, number] {
let r = ((day + DAY_ZERO_OF_YEAR) % YEAR_DAYS + YEAR_DAYS) % YEAR_DAYS;
let m = 0;
while (r >= MONTH_DAYS[m]) { r -= MONTH_DAYS[m]; m += 1; }
return [m + 1, r + 1];
}
const DAY_FMT = '{n}일차';
const WEEK_FMT = '{n}주차';
const MONTH_FMT = '{n}월';
const SLACK_FMT = '{n}일';
function dayText(day: number): string { return DAY_FMT.replace('{n}', String(day + 1)); }
// 하루 차 계산의 함정 — 시각(시간 단위)끼리 빼서 24 로 나누면, 시계가 한 시간 당겨진 날이 끼었을 때 정수가 아니다(23시간짜리 하루).
// 그래서 바깥에서 들어온 시각은 반올림으로 정수 일수에 붙이고, 그다음부터는 정수끼리만 더하고 뺀다
const HOURS_PER_DAY = 24;
const CLOCK_SHIFT_HOURS = 1;
function hoursToDayIndex(hours: number): number { return Math.round(hours / HOURS_PER_DAY); }
// ── 작업과 의존 ──────────────────────────────────────────────
// dur 는 일수(0 이면 마일스톤), deps 는 끝나야 시작할 수 있는 앞 작업 번호, load 는 그 작업에 붙는 사람 수
type Task = { name: string; dur: number; deps: number[]; load: number };
const BASE_TASKS: Task[] = [
{ name: '요구 정리', dur: 3, deps: [], load: 2 },
{ name: '디자인 시안', dur: 4, deps: [0], load: 1 },
{ name: '데이터 설계', dur: 3, deps: [0], load: 1 },
{ name: '화면 개발', dur: 5, deps: [1], load: 2 },
{ name: '서버 개발', dur: 7, deps: [2], load: 1 },
{ name: '기능 연동', dur: 3, deps: [3, 4], load: 2 },
{ name: '문서 작성', dur: 3, deps: [1], load: 1 },
{ name: '통합 검수', dur: 3, deps: [5], load: 2 },
{ name: '오류 수정', dur: 2, deps: [7], load: 1 },
{ name: '출시', dur: 0, deps: [8, 6], load: 0 }
];
function copyTasks(ts: Task[]): Task[] { return ts.map(function (t: Task): Task { return { name: t.name, dur: t.dur, deps: t.deps.slice(), load: t.load }; }); }
function successorsOf(tasks: Task[]): number[][] {
const out: number[][] = tasks.map(function (): number[] { return []; });
tasks.forEach(function (t: Task, i: number): void { t.deps.forEach(function (p: number): void { out[p].push(i); }); });
return out;
}
// 선후 순서로 줄 세우기 — 앞 작업이 하나도 안 남은(들어오는 화살표 0) 작업부터 꺼내고, 꺼낼 때마다 뒤 작업의 남은 수를 하나 줄인다.
// 꺼낼 것이 사라졌는데 남은 작업이 있으면 그 작업들은 서로를 기다리는 고리(순환 의존)에 걸렸거나 그 뒤에 매달린 것이다 → stuck
type Order = { order: number[]; stuck: number[] };
function sortByDeps(tasks: Task[]): Order {
const succ = successorsOf(tasks);
const waiting = tasks.map(function (t: Task): number { return t.deps.length; });
const ready: number[] = [];
waiting.forEach(function (w: number, i: number): void { if (w === 0) { ready.push(i); } });
const order: number[] = [];
while (ready.length) {
ready.sort(function (a: number, b: number): number { return a - b; });
const i = ready.shift() as number;
order.push(i);
succ[i].forEach(function (j: number): void { waiting[j] -= 1; if (waiting[j] === 0) { ready.push(j); } });
}
const stuck: number[] = [];
waiting.forEach(function (w: number, i: number): void { if (w > 0) { stuck.push(i); } });
return { order: order, stuck: stuck };
}
// 앞뒤 두 번 훑기 — 앞에서: 가장 이른 시작 = 앞 작업 끝 중 가장 늦은 날(과 정해 둔 시작일 중 늦은 쪽).
// 뒤에서: 가장 늦은 끝 = 뒤 작업의 가장 늦은 시작 중 가장 이른 날(뒤 작업이 없으면 전체 끝). 여유 = 가장 늦은 시작 − 가장 이른 시작
type Layers = { day: number; week: number; month: number };
type Sweep = { es: number[]; ef: number[]; ls: number[]; lf: number[]; slack: number[]; end: number };
function sweepBoth(tasks: Task[], order: number[], minStart: number[] | null): Sweep {
const n = tasks.length;
const es: number[] = new Array(n).fill(0);
const ef: number[] = new Array(n).fill(0);
for (const i of order) {
let s = minStart ? minStart[i] : 0;
for (const p of tasks[i].deps) { s = Math.max(s, ef[p]); }
es[i] = s;
ef[i] = s + tasks[i].dur;
}
let end = 0;
for (const i of order) { end = Math.max(end, ef[i]); }
const succ = successorsOf(tasks);
const ls: number[] = new Array(n).fill(0);
const lf: number[] = new Array(n).fill(0);
for (let k = order.length - 1; k >= 0; k -= 1) {
const i = order[k];
let f = end;
for (const j of succ[i]) { f = Math.min(f, ls[j]); }
lf[i] = f;
ls[i] = f - tasks[i].dur;
}
const slack = es.map(function (s: number, i: number): number { return ls[i] - s; });
return { es: es, ef: ef, ls: ls, lf: lf, slack: slack, end: end };
}
// 밀기 전파 — 밀린 작업에서 가까운 뒤 작업부터 너비 우선으로 훑으며, 새 시작 = 앞 작업 끝 중 가장 늦은 날(지금보다 늦을 때만).
// depth 는 밀린 작업에서 몇 단계 뒤인지(-1 이면 여유가 흡수해 안 밀림). 시작이 바뀐 작업만 다시 큐에 넣는다
type Push = { starts: number[]; depth: number[] };
function pushFrom(tasks: Task[], starts: number[], from: number): Push {
const succ = successorsOf(tasks);
const out = starts.slice();
const depth: number[] = tasks.map(function (): number { return -1; });
depth[from] = 0;
const queue: number[] = [from];
let head = 0;
// 고리가 있어도 끝나도록 꺼내는 횟수에 상한을 둔다
const limit = tasks.length * tasks.length + 1;
while (head < queue.length && head < limit) {
const i = queue[head];
head += 1;
for (const j of succ[i]) {
let need = 0;
for (const p of tasks[j].deps) { need = Math.max(need, out[p] + tasks[p].dur); }
if (need > out[j]) {
out[j] = need;
depth[j] = Math.max(depth[j], depth[i] + 1);
queue.push(j);
}
}
}
return { starts: out, depth: depth };
}
// 날마다 걸린 일감 — 작업이 덮는 날(시작 ≤ 날 < 시작 + 일수)마다 사람 수를 더한다
function dailyLoad(tasks: Task[], starts: number[], days: number): number[] {
const load: number[] = new Array(days).fill(0);
tasks.forEach(function (t: Task, i: number): void {
for (let d = starts[i]; d < starts[i] + t.dur; d += 1) { if (d >= 0 && d < days) { load[d] += t.load; } }
});
return load;
}
// ── 직각 경로 ───────────────────────────────────────────────
// 앞 끝(ex, ey)에서 오른쪽으로 ROUTE_STUB 나간 뒤 아래로 꺾는다. 뒤 시작(sx)이 그 꺾인 자리 + ROUTE_STUB 보다 오른쪽이면 곧장 내려가 오른쪽으로 들어가고,
// 아니면 앞 행 바로 아래 통로(gapY)까지 내려가 왼쪽으로 돌아간 다음 뒤 시작보다 ROUTE_STUB 앞에서 내려가 오른쪽으로 들어간다
const ROUTE_STUB = 7;
function elbowRoute(ex: number, ey: number, sx: number, sy: number, gapY: number): number[] {
const x1 = ex + ROUTE_STUB;
if (sx - ROUTE_STUB >= x1) { return [ex, ey, x1, ey, x1, sy, sx, sy]; }
const x2 = sx - ROUTE_STUB;
return [ex, ey, x1, ey, x1, gapY, x2, gapY, x2, sy, sx, sy];
}
function routeLength(pts: number[]): number {
let s = 0;
for (let i = 2; i < pts.length; i += 2) { s += Math.hypot(pts[i] - pts[i - 2], pts[i + 1] - pts[i - 1]); }
return s;
}
// 경로 위 거리 dist 자리의 점과 그 마디의 방향
function pointOnRoute(pts: number[], dist: number): [number, number, number, number] {
let left = Math.max(0, dist);
for (let i = 2; i < pts.length; i += 2) {
const dx = pts[i] - pts[i - 2];
const dy = pts[i + 1] - pts[i - 1];
const len = Math.hypot(dx, dy);
if (left <= len || i === pts.length - 2) {
const k = len > 0 ? Math.min(1, left / len) : 0;
return [pts[i - 2] + dx * k, pts[i - 1] + dy * k, len > 0 ? dx / len : 1, len > 0 ? dy / len : 0];
}
left -= len;
}
return [pts[0], pts[1], 1, 0];
}
// 경로를 앞에서부터 dist 만큼만 자른 점 목록 — 화살표가 그려져 나가는 장면에 쓴다
function cutRoute(pts: number[], dist: number): number[] {
const out: number[] = [pts[0], pts[1]];
let left = Math.max(0, dist);
for (let i = 2; i < pts.length; i += 2) {
const len = Math.hypot(pts[i] - pts[i - 2], pts[i + 1] - pts[i - 1]);
if (left >= len) { out.push(pts[i], pts[i + 1]); left -= len; continue; }
const k = len > 0 ? left / len : 0;
out.push(lerp(pts[i - 2], pts[i], k), lerp(pts[i - 1], pts[i + 1], k));
break;
}
return out;
}
// 장면 시계(초)와 상태 줄 글자 — 장면 계산이 고치고, 화면 쪽이 읽어 그린다
let sceneT = 0;
let simStatus = '';
// 파스텔 팔레트 — 크림 바탕 위 라벤더·민트·버터·산호 막대. 가장 긴 경로·오늘 선·글자는 진한 쪽으로 명도 차를 둔다
const PAPER: RGB = [251, 247, 238];
const PAPER_DIM: RGB = [243, 237, 224];
const GRID: RGB = [226, 218, 204];
const INK: RGB = [44, 38, 58];
const INK_SOFT: RGB = [112, 104, 126];
const CREAM: RGB = [255, 251, 242];
const LAVENDER: RGB = [190, 174, 236];
const MINT: RGB = [158, 214, 190];
const BUTTER: RGB = [244, 222, 146];
const CORAL: RGB = [244, 170, 150];
const SKY: RGB = [160, 204, 236];
const PINK: RGB = [242, 182, 206];
const PATH_DEEP: RGB = [86, 56, 168];
const TODAY_DEEP: RGB = [190, 72, 60];
const BAR_COLORS: RGB[] = [LAVENDER, MINT, BUTTER, CORAL, SKY, PINK];
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// 표 한 줄 — 작업·시작·끝·여유. 끝은 그 작업이 덮는 마지막 날(일수 0 짜리는 시작과 같은 날)
function tableRow(name: string, start: number, dur: number, slack: number): string[] {
return [name, dayText(start), dayText(start + Math.max(0, dur - 1)), SLACK_FMT.replace('{n}', String(slack))];
}
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const tableEl = root.querySelector('[data-role="table"]') as HTMLTableElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function clearStage(): void {
ctx.fillStyle = rgba(PAPER, 1);
ctx.fillRect(0, 0, W, H);
}
function label(s: string, x: number, y: number, c: RGB, a: number, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
function roundRectPath(x: number, y: number, w: number, h: number, r: number): void {
const rr = Math.max(0, Math.min(r, w / 2, h / 2));
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.lineTo(x + w - rr, y);
ctx.arcTo(x + w, y, x + w, y + rr, rr);
ctx.lineTo(x + w, y + h - rr);
ctx.arcTo(x + w, y + h, x + w - rr, y + h, rr);
ctx.lineTo(x + rr, y + h);
ctx.arcTo(x, y + h, x, y + h - rr, rr);
ctx.lineTo(x, y + rr);
ctx.arcTo(x, y, x + rr, y, rr);
ctx.closePath();
}
// 막대 하나 — 둥근 사각형 채움 + 진한 테두리. 폭이 0 이하이면 그리지 않는다
function bar(x: number, y: number, w: number, h: number, fill: RGB, a: number, line: number): void {
if (w <= 0.2 || a <= 0) { return; }
roundRectPath(x, y, w, h, 4);
ctx.fillStyle = rgba(fill, a);
ctx.fill();
if (line > 0) {
ctx.lineWidth = line;
ctx.strokeStyle = rgba(INK, 0.55 * a);
ctx.stroke();
}
}
// 마일스톤 마름모
function diamond(cx: number, cy: number, r: number, fill: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.moveTo(cx, cy - r);
ctx.lineTo(cx + r, cy);
ctx.lineTo(cx, cy + r);
ctx.lineTo(cx - r, cy);
ctx.closePath();
ctx.fillStyle = rgba(fill, a);
ctx.fill();
ctx.lineWidth = 1.5;
ctx.strokeStyle = rgba(INK, 0.8 * a);
ctx.stroke();
}
function polyline(pts: number[], c: RGB, a: number, width: number): void {
if (pts.length < 4) { return; }
ctx.beginPath();
ctx.moveTo(pts[0], pts[1]);
for (let i = 2; i < pts.length; i += 2) { ctx.lineTo(pts[i], pts[i + 1]); }
ctx.lineWidth = width;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function arrowHead(x: number, y: number, dx: number, dy: number, size: number, c: RGB, a: number): void {
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x - dx * size - dy * size * 0.6, y - dy * size + dx * size * 0.6);
ctx.lineTo(x - dx * size + dy * size * 0.6, y - dy * size - dx * size * 0.6);
ctx.closePath();
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
// 빗금 — (x, y, w, h) 안에 45° 줄을 spacing 간격으로. 간격이 좁을수록 짙어 보인다
function hatch(x: number, y: number, w: number, h: number, spacing: number, c: RGB, a: number): void {
if (w <= 0.5 || a <= 0) { return; }
ctx.save();
roundRectPath(x, y, w, h, 3);
ctx.clip();
ctx.beginPath();
for (let s = x - h; s < x + w; s += Math.max(2, spacing)) {
ctx.moveTo(s, y + h);
ctx.lineTo(s + h, y);
}
ctx.lineWidth = 1.6;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
ctx.restore();
}
// 시간 축 — 위 띠는 월 이름, 아래 띠는 일(월 안의 날 숫자)·주(주차) 층. 층마다 불투명도를 따로 받아 겹쳐 흐려지며 바뀌게 한다.
// 세로 격자도 같은 불투명도로 행 영역(top~bottom)에 긋는다. xOf 는 일수 → 화면 x
function timeAxis(xOf: (d: number) => number, dayW: number, d0: number, d1: number, left: number, right: number, top: number, bottom: number, ly: Layers): void {
ctx.save();
ctx.beginPath();
ctx.rect(left, 0, right - left, H);
ctx.clip();
ctx.fillStyle = rgba(PAPER_DIM, 1);
ctx.fillRect(left, 0, right - left, top - 4);
for (let d = Math.floor(d0); d <= Math.ceil(d1); d += 1) {
const x = xOf(d);
const cal = calendarOf(d);
if (ly.day > 0.01) {
ctx.fillStyle = rgba(GRID, 0.7 * ly.day);
ctx.fillRect(x, top, 1, bottom - top);
if (dayW >= 13) { label(String(cal[1]), x + dayW / 2, top - 11, INK_SOFT, ly.day, 9, 600, 'center'); }
}
// 주 층 — 글자가 겹치지 않을 만큼(36px) 주를 건너뛰며 적는다
const weekStep = 7 * dayW >= 36 ? 1 : (14 * dayW >= 36 ? 2 : 4);
if (ly.week > 0.01 && d % 7 === 0) {
ctx.fillStyle = rgba(GRID, ly.week);
ctx.fillRect(x, top, 1, bottom - top);
if ((d / 7) % weekStep === 0) { label(WEEK_FMT.replace('{n}', String(d / 7 + 1)), x + 3, top - 11, INK_SOFT, ly.week, 9, 600, 'left'); }
}
if (cal[1] === 1) {
ctx.fillStyle = rgba(INK_SOFT, 0.18 + 0.4 * ly.month);
ctx.fillRect(x, 4, 1, bottom - 4);
label(MONTH_FMT.replace('{n}', String(cal[0])), x + 4, 11, INK, 0.85, 10, 700, 'left');
// 월 층 — 그달 칸 가운데에 그달 일수(월 길이 표에서)를 적는다
const len = MONTH_DAYS[cal[0] - 1];
if (ly.month > 0.01 && len * dayW >= 30) { label(SLACK_FMT.replace('{n}', String(len)), x + len * dayW / 2, top - 11, INK_SOFT, ly.month, 9, 700, 'center'); }
} else if (d === Math.floor(d0)) {
// 왼쪽 끝 월 이름 — 바로 오른쪽에 새 달이 시작하면 겹치므로 적지 않는다
const toNext = MONTH_DAYS[cal[0] - 1] - cal[1] + 1;
if (toNext * dayW >= 44) { label(MONTH_FMT.replace('{n}', String(cal[0])), Math.max(x, left) + 4, 11, INK, 0.85, 10, 700, 'left'); }
}
}
ctx.restore();
}
// 왼쪽 작업 이름 칸
function rowLabel(name: string, y: number, a: number, bold: boolean): void {
label(name, 10, y, INK, a, 11, bold ? 800 : 600, 'left');
}
function rowStripe(y: number, h: number, i: number, a: number): void {
if (i % 2 === 1) {
ctx.fillStyle = rgba(PAPER_DIM, 0.75 * a);
ctx.fillRect(0, y, W, h);
}
}
// 움직이는 행 빛 — 그 행 막대의 스프링 속도(k = 0~1)만큼 행 전체가 옅은 라벤더로 밝아진다. 막대가 서면 사라진다
function rowGlow(y: number, h: number, k: number): void {
if (k <= 0.01) { return; }
ctx.fillStyle = rgba(LAVENDER, 0.42 * clamp(k, 0, 1));
ctx.fillRect(0, y, W, h);
}
// 상태 줄 — 바뀔 때만 글자를 고친다
let shownStatus = '';
function pushStatus(): void {
if (!statusEl || simStatus === shownStatus) { return; }
shownStatus = simStatus;
statusEl.textContent = simStatus;
}
// 숨김 표 — 같은 일정을 작업·시작·끝·여유 열로 읽게. 화면에는 안 보이고 화면 읽기 도구가 읽는다
const TABLE_HEAD = ['작업 이름', '시작일', '끝나는 날', '여유 일수'];
function fillTable(caption: string, rows: string[][]): void {
if (!tableEl) { return; }
const tb = tableEl;
while (tb.firstChild) { tb.removeChild(tb.firstChild); }
const cap = document.createElement('caption');
cap.textContent = caption;
tb.appendChild(cap);
const thead = document.createElement('thead');
const hr = document.createElement('tr');
TABLE_HEAD.forEach(function (h: string): void {
const th = document.createElement('th');
th.scope = 'col';
th.textContent = h;
hr.appendChild(th);
});
thead.appendChild(hr);
tb.appendChild(thead);
const body = document.createElement('tbody');
rows.forEach(function (r: string[]): void {
const tr = document.createElement('tr');
r.forEach(function (c: string, k: number): void {
const cell = document.createElement(k === 0 ? 'th' : 'td');
if (k === 0) { (cell as HTMLTableCellElement).scope = 'row'; }
cell.textContent = c;
tr.appendChild(cell);
});
body.appendChild(tr);
});
tb.appendChild(body);
}
// 06 — 계획 대비 실적. 기대 진행률 = clamp((오늘 − 시작) / (끝 − 시작), 0, 1), 실제 진행률은 작업마다 정해 둔 빠르기로 쌓인다. 차이(늦음)만큼 빗금을 짙게 한다
const LABEL_W = 96;
const PLOT_X = LABEL_W + 6;
const DAY_WIDTH = 15;
const ROWS_TOP = 44;
const ROW_H = 20;
const BAR_H = 11;
// 계획 막대(그림자)를 실적 막대 뒤로 미는 거리(px)
const PLAN_SHADOW_OFFSET = 4;
// 작업마다 실제 빠르기 — 1 이면 계획대로, 0.6 이면 계획의 60% 속도
const ACTUAL_PACE = [1, 0.6, 1, 0.7, 0.9, 1, 0.5, 0.75, 0.8, 1];
// 늦음이 이만큼(진행률 차)이면 빗금이 가장 짙다
const LAG_FULL = 0.3;
// 오늘 선 속도(일수/초)와 출발·멈춤
const TODAY_DAYS_PER_S = 6.5;
const TODAY_START_S = 0.25;
const TODAY_FROM = -0.5;
const TODAY_TO = 22.5;
const LOOP_S = 5.4;
const REST_TODAY = 12.5;
const TABLE_CAPTION = '작업 일정표 — 계획';
const tasks06 = BASE_TASKS;
const sweep06 = sweepBoth(tasks06, sortByDeps(tasks06).order, null);
let today06 = TODAY_FROM;
function expectedAt(i: number, today: number): number {
const t = tasks06[i];
if (t.dur === 0) { return today >= sweep06.es[i] ? 1 : 0; }
return clamp((today - sweep06.es[i]) / (sweep06.ef[i] - sweep06.es[i]), 0, 1);
}
function actualAt(i: number, today: number): number {
const t = tasks06[i];
if (t.dur === 0) { return today >= sweep06.es[i] ? 1 : 0; }
return clamp((today - sweep06.es[i]) / t.dur * ACTUAL_PACE[i], 0, 1);
}
// 늦음 → 빗금 짙기 0~1
function lagLevel(i: number, today: number): number { return clamp((expectedAt(i, today) - actualAt(i, today)) / LAG_FULL, 0, 1); }
function reset(): void { today06 = TODAY_FROM; }
function update(_dt: number): void { today06 = clamp(TODAY_FROM + Math.max(0, sceneT - TODAY_START_S) * TODAY_DAYS_PER_S, TODAY_FROM, TODAY_TO); }
function restPose(): void { today06 = REST_TODAY; }
function tableRows(): string[][] {
return tasks06.map(function (t: Task, i: number): string[] { return tableRow(t.name, sweep06.es[i], t.dur, sweep06.slack[i]); });
}
const LAG_FMT = '{n}% 늦음';
function dayX(d: number): number { return PLOT_X + d * DAY_WIDTH; }
function rowMid(i: number): number { return ROWS_TOP + i * ROW_H + ROW_H / 2; }
function draw(): void {
clearStage();
const bottom = ROWS_TOP + tasks06.length * ROW_H;
tasks06.forEach(function (_: Task, i: number): void { rowStripe(ROWS_TOP + i * ROW_H, ROW_H, i, 1); });
timeAxis(dayX, DAY_WIDTH, 0, 25, PLOT_X, W - 8, ROWS_TOP, bottom, { day: 1, week: 0, month: 0 });
// 지나간 날 — 오늘 선 왼쪽을 옅게 덮는다
const tx = dayX(today06);
ctx.fillStyle = rgba(INK, 0.045);
ctx.fillRect(PLOT_X, ROWS_TOP, Math.max(0, tx - PLOT_X), bottom - ROWS_TOP);
tasks06.forEach(function (t: Task, i: number): void {
const cy = rowMid(i);
const x0 = dayX(sweep06.es[i]);
const lag = lagLevel(i, today06);
rowLabel(t.name, cy, 1, lag > 0.3);
if (t.dur === 0) {
diamond(x0, cy, 7, actualAt(i, today06) >= 1 ? PATH_DEEP : PAPER, 1);
return;
}
const w = t.dur * DAY_WIDTH;
const c = BAR_COLORS[i % BAR_COLORS.length];
// 계획 막대 — 뒤에 깔린 그림자
bar(x0 + PLAN_SHADOW_OFFSET, cy - BAR_H / 2 + PLAN_SHADOW_OFFSET, w, BAR_H, INK_SOFT, 0.22, 0);
bar(x0, cy - BAR_H / 2, w, BAR_H, c, 0.32, 1);
const act = actualAt(i, today06);
const exp = expectedAt(i, today06);
bar(x0, cy - BAR_H / 2, w * act, BAR_H, c, 1, 1.2);
// 늦은 몫 — 실제 끝에서 기대 끝까지 빗금. 늦을수록 촘촘하고 짙다
if (exp - act > 0.005) {
hatch(x0 + w * act, cy - BAR_H / 2, w * (exp - act), BAR_H, lerp(7, 2.5, lag), TODAY_DEEP, 0.35 + 0.6 * lag);
}
if (lag > 0.15) { label(LAG_FMT.replace('{n}', String(Math.round((exp - act) * 100))), x0 + w + 6, cy, TODAY_DEEP, smooth((lag - 0.15) / 0.3), 9, 800, 'left'); }
});
// 오늘 선
ctx.fillStyle = rgba(TODAY_DEEP, 1);
ctx.fillRect(tx - 1, ROWS_TOP - 6, 2, bottom - ROWS_TOP + 6);
const tag = dayText(Math.max(0, Math.floor(today06)));
ctx.font = '800 10px ' + FONT_STACK;
const tw = ctx.measureText(tag).width + 12;
const bx = clamp(tx - tw / 2, PLOT_X, W - 8 - tw);
bar(bx, bottom + 3, tw, 16, TODAY_DEEP, 1, 0);
label(tag, bx + tw / 2, bottom + 11, CREAM, 1, 10, 800, 'center');
}
// 한 프레임에 넘기는 시간 상한(초) — 탭을 오래 숨겼다 돌아온 첫 프레임에도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 손대면 시연 표시를 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
// 리스너는 한 묶음으로 달고 정리할 때 한 번에 뗀다
const off = new AbortController();
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
if (statusEl) { statusEl.setAttribute('aria-live', 'polite'); }
}
// 장면 시계를 dt 만큼 — 한 바퀴(LOOP_S)를 넘으면 처음 배치로 되돌려 다시 시연한다
function advance(dt: number): void {
sceneT += dt;
if (sceneT >= LOOP_S) {
sceneT -= LOOP_S;
reset();
}
update(dt);
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) { advance(dt); }
draw();
pushStatus();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 시계 다시 맞추기 — 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 동작 줄이기 — 루프를 돌리지 않고 시연이 끝난 최종 배치 한 장만 그린다
function showRest(): void {
stopLoop();
sceneT = 0;
reset();
restPose();
draw();
pushStatus();
}
function play(): void {
sceneT = 0;
reset();
draw();
pushStatus();
if (!paused) { startLoop(); }
}
pauseBtn.addEventListener('click', function (): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}, { signal: off.signal });
// 탭이 숨으면 루프를 멈추고, 돌아오면 시계를 다시 맞춰 시작한다 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 페이지를 연 채 바뀌면 — 켜지면 최종 배치로 멈추고, 꺼지면 시연을 처음부터
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { play(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
draw();
}, { signal: off.signal });
fitCanvas();
fillTable(TABLE_CAPTION, tableRows());
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("cf") + " is-demo"} ref={rootRef}>
<canvas className={cx("cf__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label="오늘 선이 지나가며 계획보다 늦은 작업에 빗금이 짙어지고 계획 막대가 그림자로 깔린 일정표."></canvas>
<table className={cx("cf__sr")} data-role="table"></table>
<button className={cx("cf__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
</div>
);
}06 · 오늘 선이 지나가며 계획보다 늦은 막대를 드러내는 실적 비교 — 측정 누적면적 14.033% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function expectedAt(i, today) {
const t = tasks06[i];
if (t.dur === 0) {
return today >= sweep06.es[i] ? 1 : 0;
}
return clamp((today - sweep06.es[i]) / (sweep06.ef[i] - sweep06.es[i]), 0, 1);
}
function actualAt(i, today) {
const t = tasks06[i];
if (t.dur === 0) {
return today >= sweep06.es[i] ? 1 : 0;
}
return clamp((today - sweep06.es[i]) / t.dur * ACTUAL_PACE[i], 0, 1);
}
// 늦음 → 빗금 짙기 0~1
function lagLevel(i, today) { return clamp((expectedAt(i, today) - actualAt(i, today)) / LAG_FULL, 0, 1); }
07하루 정원을 넘는 날을 깎아 내는 인력 부하 평준화
아래 그래프가 날마다 묶인 사람 수를 더해 정원 3명을 넘는 날(8~10일차에 4명) 가운데 첫날을 찾으면, levelSteps 가 그날 시작하는 작업 가운데 여유가 가장 큰 것을 하루 늦추고 pushFrom 으로 뒤를 미는 걸음을 넘침이 0 이 되거나 40번이 찰 때까지 되풀이한다. 정원 3 에서는 여유 11일인 문서 작성만 다섯 번 하루씩 늦어져 넘침 0·최고 3명이 되고 전체 21일은 그대로이며, 걸음은 0.3초부터 150ms 마다 하나씩 막대 x 와 그래프 높이를 스프링으로 다시 흘린다. 팀 인력 배치 계획에서 출시일을 건드리지 않고 누구의 시작을 미루면 하루 정원이 지켜지는지를 찾는 계산이다.

<main class="stage">
<div class="cf is-demo" id="cf">
<canvas class="cf__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="아래 막대그래프에서 정원을 넘는 날을 찾아 여유가 큰 작업을 하루씩 늦추는 인력 부하 평준화."></canvas>
<table class="cf__sr" data-role="table"></table>
<p class="cf__status" data-role="status" aria-live="off">하루 정원 확인 중</p>
<button class="cf__pause" data-role="pause" type="button" aria-pressed="false">일시정지</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.cf {
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-cream;
}
.cf__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.cf__pause {
position: absolute;
right: $sp-3;
bottom: $sp-3;
padding: $sp-1 $sp-3;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.cf__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.cf__pause:hover {
transform: translateY(-2px);
}
.cf__pause:active {
transform: translateY(1px);
}
.cf__pause:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 일정에 무슨 일이 일어났는지 글자로. 시연 중에는 aria-live off, 사람이 처음 손대면 polite
.cf__status {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: calc(100% - 120px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
// 숨김 표 — 화면에서는 1px 로 접어 두고 화면 읽기 도구만 읽는다
.cf__sr {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
@media (max-width: 340px) {
.cf__pause,
.cf__status { font-size: 10px; padding: $sp-1 $sp-2; }
}
// 동작 줄이기 — 장면은 스크립트가 최종 배치 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.cf__pause { transition: none !important; }
.cf__pause:hover,
.cf__pause:active { transform: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cf {
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: #fff7e6;
}
.cf__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.cf__pause {
position: absolute;
right: 12px;
bottom: 12px;
padding: 4px 12px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.cf__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.cf__pause:hover {
transform: translateY(-2px);
}
.cf__pause:active {
transform: translateY(1px);
}
.cf__pause:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.cf__status {
position: absolute;
left: 12px;
bottom: 12px;
max-width: calc(100% - 120px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
.cf__sr {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
@media (max-width: 340px) {
.cf__pause,
.cf__status {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.cf__pause {
transition: none !important;
}
.cf__pause:hover,
.cf__pause:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("cf");
if (!root) return;
// 판 좌표 — 캔버스 안은 늘 480×300. 화면에 보이는 크기와 상관없이 계산은 이 좌표로 한다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
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); }
// 따라가기 비율 — 1 − e^(−rate·dt). 프레임 시간이 달라도 1초에 같은 만큼 다가간다
function follow(rate, dt) { return 1 - Math.exp(-rate * dt); }
// 시드 고정 난수 — 같은 시드면 같은 일정이 다시 나온다 (32비트 정수 섞기)
function makeRng(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function makeSpring(x) { return { x: x, v: 0, to: x }; }
const SPRING_SUBSTEP = 1 / 240;
function stepSpring(s, stiffness, damping, dt) {
let left = dt;
while (left > 1e-9) {
const h = Math.min(SPRING_SUBSTEP, left);
s.v += (stiffness * (s.to - s.x) - damping * s.v) * h;
s.x += s.v * h;
left -= h;
}
}
function snapSpring(s, x) { s.x = x; s.v = 0; s.to = x; }
// ── 날짜 = 정수 일수 ─────────────────────────────────────────
// 날짜 객체를 쓰지 않는다. 기준일을 0 으로 세는 정수 일수(day index)만 다루고, 화면의 월·일 숫자는 아래 월 길이 표로 산수해 만든다
const MONTH_DAYS = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
const YEAR_DAYS = 365;
// 기준일(일수 0) = 한 해의 70번째 날 → 표로 세면 3월 11일
const DAY_ZERO_OF_YEAR = 69;
function calendarOf(day) {
let r = ((day + DAY_ZERO_OF_YEAR) % YEAR_DAYS + YEAR_DAYS) % YEAR_DAYS;
let m = 0;
while (r >= MONTH_DAYS[m]) {
r -= MONTH_DAYS[m];
m += 1;
}
return [m + 1, r + 1];
}
const DAY_FMT = '{n}일차';
const WEEK_FMT = '{n}주차';
const MONTH_FMT = '{n}월';
const SLACK_FMT = '{n}일';
function dayText(day) { return DAY_FMT.replace('{n}', String(day + 1)); }
// 하루 차 계산의 함정 — 시각(시간 단위)끼리 빼서 24 로 나누면, 시계가 한 시간 당겨진 날이 끼었을 때 정수가 아니다(23시간짜리 하루).
// 그래서 바깥에서 들어온 시각은 반올림으로 정수 일수에 붙이고, 그다음부터는 정수끼리만 더하고 뺀다
const HOURS_PER_DAY = 24;
const CLOCK_SHIFT_HOURS = 1;
function hoursToDayIndex(hours) { return Math.round(hours / HOURS_PER_DAY); }
const BASE_TASKS = [
{ name: '요구 정리', dur: 3, deps: [], load: 2 },
{ name: '디자인 시안', dur: 4, deps: [0], load: 1 },
{ name: '데이터 설계', dur: 3, deps: [0], load: 1 },
{ name: '화면 개발', dur: 5, deps: [1], load: 2 },
{ name: '서버 개발', dur: 7, deps: [2], load: 1 },
{ name: '기능 연동', dur: 3, deps: [3, 4], load: 2 },
{ name: '문서 작성', dur: 3, deps: [1], load: 1 },
{ name: '통합 검수', dur: 3, deps: [5], load: 2 },
{ name: '오류 수정', dur: 2, deps: [7], load: 1 },
{ name: '출시', dur: 0, deps: [8, 6], load: 0 }
];
function copyTasks(ts) { return ts.map(function (t) { return { name: t.name, dur: t.dur, deps: t.deps.slice(), load: t.load }; }); }
function successorsOf(tasks) {
const out = tasks.map(function () { return []; });
tasks.forEach(function (t, i) { t.deps.forEach(function (p) { out[p].push(i); }); });
return out;
}
function sortByDeps(tasks) {
const succ = successorsOf(tasks);
const waiting = tasks.map(function (t) { return t.deps.length; });
const ready = [];
waiting.forEach(function (w, i) { if (w === 0) {
ready.push(i);
} });
const order = [];
while (ready.length) {
ready.sort(function (a, b) { return a - b; });
const i = ready.shift();
order.push(i);
succ[i].forEach(function (j) { waiting[j] -= 1; if (waiting[j] === 0) {
ready.push(j);
} });
}
const stuck = [];
waiting.forEach(function (w, i) { if (w > 0) {
stuck.push(i);
} });
return { order: order, stuck: stuck };
}
function sweepBoth(tasks, order, minStart) {
const n = tasks.length;
const es = new Array(n).fill(0);
const ef = new Array(n).fill(0);
for (const i of order) {
let s = minStart ? minStart[i] : 0;
for (const p of tasks[i].deps) {
s = Math.max(s, ef[p]);
}
es[i] = s;
ef[i] = s + tasks[i].dur;
}
let end = 0;
for (const i of order) {
end = Math.max(end, ef[i]);
}
const succ = successorsOf(tasks);
const ls = new Array(n).fill(0);
const lf = new Array(n).fill(0);
for (let k = order.length - 1; k >= 0; k -= 1) {
const i = order[k];
let f = end;
for (const j of succ[i]) {
f = Math.min(f, ls[j]);
}
lf[i] = f;
ls[i] = f - tasks[i].dur;
}
const slack = es.map(function (s, i) { return ls[i] - s; });
return { es: es, ef: ef, ls: ls, lf: lf, slack: slack, end: end };
}
function pushFrom(tasks, starts, from) {
const succ = successorsOf(tasks);
const out = starts.slice();
const depth = tasks.map(function () { return -1; });
depth[from] = 0;
const queue = [from];
let head = 0;
// 고리가 있어도 끝나도록 꺼내는 횟수에 상한을 둔다
const limit = tasks.length * tasks.length + 1;
while (head < queue.length && head < limit) {
const i = queue[head];
head += 1;
for (const j of succ[i]) {
let need = 0;
for (const p of tasks[j].deps) {
need = Math.max(need, out[p] + tasks[p].dur);
}
if (need > out[j]) {
out[j] = need;
depth[j] = Math.max(depth[j], depth[i] + 1);
queue.push(j);
}
}
}
return { starts: out, depth: depth };
}
// 날마다 걸린 일감 — 작업이 덮는 날(시작 ≤ 날 < 시작 + 일수)마다 사람 수를 더한다
function dailyLoad(tasks, starts, days) {
const load = new Array(days).fill(0);
tasks.forEach(function (t, i) {
for (let d = starts[i]; d < starts[i] + t.dur; d += 1) {
if (d >= 0 && d < days) {
load[d] += t.load;
}
}
});
return load;
}
// ── 직각 경로 ───────────────────────────────────────────────
// 앞 끝(ex, ey)에서 오른쪽으로 ROUTE_STUB 나간 뒤 아래로 꺾는다. 뒤 시작(sx)이 그 꺾인 자리 + ROUTE_STUB 보다 오른쪽이면 곧장 내려가 오른쪽으로 들어가고,
// 아니면 앞 행 바로 아래 통로(gapY)까지 내려가 왼쪽으로 돌아간 다음 뒤 시작보다 ROUTE_STUB 앞에서 내려가 오른쪽으로 들어간다
const ROUTE_STUB = 7;
function elbowRoute(ex, ey, sx, sy, gapY) {
const x1 = ex + ROUTE_STUB;
if (sx - ROUTE_STUB >= x1) {
return [ex, ey, x1, ey, x1, sy, sx, sy];
}
const x2 = sx - ROUTE_STUB;
return [ex, ey, x1, ey, x1, gapY, x2, gapY, x2, sy, sx, sy];
}
function routeLength(pts) {
let s = 0;
for (let i = 2; i < pts.length; i += 2) {
s += Math.hypot(pts[i] - pts[i - 2], pts[i + 1] - pts[i - 1]);
}
return s;
}
// 경로 위 거리 dist 자리의 점과 그 마디의 방향
function pointOnRoute(pts, dist) {
let left = Math.max(0, dist);
for (let i = 2; i < pts.length; i += 2) {
const dx = pts[i] - pts[i - 2];
const dy = pts[i + 1] - pts[i - 1];
const len = Math.hypot(dx, dy);
if (left <= len || i === pts.length - 2) {
const k = len > 0 ? Math.min(1, left / len) : 0;
return [pts[i - 2] + dx * k, pts[i - 1] + dy * k, len > 0 ? dx / len : 1, len > 0 ? dy / len : 0];
}
left -= len;
}
return [pts[0], pts[1], 1, 0];
}
// 경로를 앞에서부터 dist 만큼만 자른 점 목록 — 화살표가 그려져 나가는 장면에 쓴다
function cutRoute(pts, dist) {
const out = [pts[0], pts[1]];
let left = Math.max(0, dist);
for (let i = 2; i < pts.length; i += 2) {
const len = Math.hypot(pts[i] - pts[i - 2], pts[i + 1] - pts[i - 1]);
if (left >= len) {
out.push(pts[i], pts[i + 1]);
left -= len;
continue;
}
const k = len > 0 ? left / len : 0;
out.push(lerp(pts[i - 2], pts[i], k), lerp(pts[i - 1], pts[i + 1], k));
break;
}
return out;
}
// 장면 시계(초)와 상태 줄 글자 — 장면 계산이 고치고, 화면 쪽이 읽어 그린다
let sceneT = 0;
let simStatus = '';
// 파스텔 팔레트 — 크림 바탕 위 라벤더·민트·버터·산호 막대. 가장 긴 경로·오늘 선·글자는 진한 쪽으로 명도 차를 둔다
const PAPER = [251, 247, 238];
const PAPER_DIM = [243, 237, 224];
const GRID = [226, 218, 204];
const INK = [44, 38, 58];
const INK_SOFT = [112, 104, 126];
const CREAM = [255, 251, 242];
const LAVENDER = [190, 174, 236];
const MINT = [158, 214, 190];
const BUTTER = [244, 222, 146];
const CORAL = [244, 170, 150];
const SKY = [160, 204, 236];
const PINK = [242, 182, 206];
const PATH_DEEP = [86, 56, 168];
const TODAY_DEEP = [190, 72, 60];
const BAR_COLORS = [LAVENDER, MINT, BUTTER, CORAL, SKY, PINK];
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// 표 한 줄 — 작업·시작·끝·여유. 끝은 그 작업이 덮는 마지막 날(일수 0 짜리는 시작과 같은 날)
function tableRow(name, start, dur, slack) {
return [name, dayText(start), dayText(start + Math.max(0, dur - 1)), SLACK_FMT.replace('{n}', String(slack))];
}
const canvasEl = root.querySelector('[data-role="canvas"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const tableEl = root.querySelector('[data-role="table"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function clearStage() {
ctx.fillStyle = rgba(PAPER, 1);
ctx.fillRect(0, 0, W, H);
}
function label(s, x, y, c, a, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
function roundRectPath(x, y, w, h, r) {
const rr = Math.max(0, Math.min(r, w / 2, h / 2));
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.lineTo(x + w - rr, y);
ctx.arcTo(x + w, y, x + w, y + rr, rr);
ctx.lineTo(x + w, y + h - rr);
ctx.arcTo(x + w, y + h, x + w - rr, y + h, rr);
ctx.lineTo(x + rr, y + h);
ctx.arcTo(x, y + h, x, y + h - rr, rr);
ctx.lineTo(x, y + rr);
ctx.arcTo(x, y, x + rr, y, rr);
ctx.closePath();
}
// 막대 하나 — 둥근 사각형 채움 + 진한 테두리. 폭이 0 이하이면 그리지 않는다
function bar(x, y, w, h, fill, a, line) {
if (w <= 0.2 || a <= 0) {
return;
}
roundRectPath(x, y, w, h, 4);
ctx.fillStyle = rgba(fill, a);
ctx.fill();
if (line > 0) {
ctx.lineWidth = line;
ctx.strokeStyle = rgba(INK, 0.55 * a);
ctx.stroke();
}
}
// 마일스톤 마름모
function diamond(cx, cy, r, fill, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.moveTo(cx, cy - r);
ctx.lineTo(cx + r, cy);
ctx.lineTo(cx, cy + r);
ctx.lineTo(cx - r, cy);
ctx.closePath();
ctx.fillStyle = rgba(fill, a);
ctx.fill();
ctx.lineWidth = 1.5;
ctx.strokeStyle = rgba(INK, 0.8 * a);
ctx.stroke();
}
function polyline(pts, c, a, width) {
if (pts.length < 4) {
return;
}
ctx.beginPath();
ctx.moveTo(pts[0], pts[1]);
for (let i = 2; i < pts.length; i += 2) {
ctx.lineTo(pts[i], pts[i + 1]);
}
ctx.lineWidth = width;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function arrowHead(x, y, dx, dy, size, c, a) {
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x - dx * size - dy * size * 0.6, y - dy * size + dx * size * 0.6);
ctx.lineTo(x - dx * size + dy * size * 0.6, y - dy * size - dx * size * 0.6);
ctx.closePath();
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
// 빗금 — (x, y, w, h) 안에 45° 줄을 spacing 간격으로. 간격이 좁을수록 짙어 보인다
function hatch(x, y, w, h, spacing, c, a) {
if (w <= 0.5 || a <= 0) {
return;
}
ctx.save();
roundRectPath(x, y, w, h, 3);
ctx.clip();
ctx.beginPath();
for (let s = x - h; s < x + w; s += Math.max(2, spacing)) {
ctx.moveTo(s, y + h);
ctx.lineTo(s + h, y);
}
ctx.lineWidth = 1.6;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
ctx.restore();
}
// 시간 축 — 위 띠는 월 이름, 아래 띠는 일(월 안의 날 숫자)·주(주차) 층. 층마다 불투명도를 따로 받아 겹쳐 흐려지며 바뀌게 한다.
// 세로 격자도 같은 불투명도로 행 영역(top~bottom)에 긋는다. xOf 는 일수 → 화면 x
function timeAxis(xOf, dayW, d0, d1, left, right, top, bottom, ly) {
ctx.save();
ctx.beginPath();
ctx.rect(left, 0, right - left, H);
ctx.clip();
ctx.fillStyle = rgba(PAPER_DIM, 1);
ctx.fillRect(left, 0, right - left, top - 4);
for (let d = Math.floor(d0); d <= Math.ceil(d1); d += 1) {
const x = xOf(d);
const cal = calendarOf(d);
if (ly.day > 0.01) {
ctx.fillStyle = rgba(GRID, 0.7 * ly.day);
ctx.fillRect(x, top, 1, bottom - top);
if (dayW >= 13) {
label(String(cal[1]), x + dayW / 2, top - 11, INK_SOFT, ly.day, 9, 600, 'center');
}
}
// 주 층 — 글자가 겹치지 않을 만큼(36px) 주를 건너뛰며 적는다
const weekStep = 7 * dayW >= 36 ? 1 : (14 * dayW >= 36 ? 2 : 4);
if (ly.week > 0.01 && d % 7 === 0) {
ctx.fillStyle = rgba(GRID, ly.week);
ctx.fillRect(x, top, 1, bottom - top);
if ((d / 7) % weekStep === 0) {
label(WEEK_FMT.replace('{n}', String(d / 7 + 1)), x + 3, top - 11, INK_SOFT, ly.week, 9, 600, 'left');
}
}
if (cal[1] === 1) {
ctx.fillStyle = rgba(INK_SOFT, 0.18 + 0.4 * ly.month);
ctx.fillRect(x, 4, 1, bottom - 4);
label(MONTH_FMT.replace('{n}', String(cal[0])), x + 4, 11, INK, 0.85, 10, 700, 'left');
// 월 층 — 그달 칸 가운데에 그달 일수(월 길이 표에서)를 적는다
const len = MONTH_DAYS[cal[0] - 1];
if (ly.month > 0.01 && len * dayW >= 30) {
label(SLACK_FMT.replace('{n}', String(len)), x + len * dayW / 2, top - 11, INK_SOFT, ly.month, 9, 700, 'center');
}
}
else if (d === Math.floor(d0)) {
// 왼쪽 끝 월 이름 — 바로 오른쪽에 새 달이 시작하면 겹치므로 적지 않는다
const toNext = MONTH_DAYS[cal[0] - 1] - cal[1] + 1;
if (toNext * dayW >= 44) {
label(MONTH_FMT.replace('{n}', String(cal[0])), Math.max(x, left) + 4, 11, INK, 0.85, 10, 700, 'left');
}
}
}
ctx.restore();
}
// 왼쪽 작업 이름 칸
function rowLabel(name, y, a, bold) {
label(name, 10, y, INK, a, 11, bold ? 800 : 600, 'left');
}
function rowStripe(y, h, i, a) {
if (i % 2 === 1) {
ctx.fillStyle = rgba(PAPER_DIM, 0.75 * a);
ctx.fillRect(0, y, W, h);
}
}
// 움직이는 행 빛 — 그 행 막대의 스프링 속도(k = 0~1)만큼 행 전체가 옅은 라벤더로 밝아진다. 막대가 서면 사라진다
function rowGlow(y, h, k) {
if (k <= 0.01) {
return;
}
ctx.fillStyle = rgba(LAVENDER, 0.42 * clamp(k, 0, 1));
ctx.fillRect(0, y, W, h);
}
// 상태 줄 — 바뀔 때만 글자를 고친다
let shownStatus = '';
function pushStatus() {
if (!statusEl || simStatus === shownStatus) {
return;
}
shownStatus = simStatus;
statusEl.textContent = simStatus;
}
// 숨김 표 — 같은 일정을 작업·시작·끝·여유 열로 읽게. 화면에는 안 보이고 화면 읽기 도구가 읽는다
const TABLE_HEAD = ['작업 이름', '시작일', '끝나는 날', '여유 일수'];
function fillTable(caption, rows) {
if (!tableEl) {
return;
}
const tb = tableEl;
while (tb.firstChild) {
tb.removeChild(tb.firstChild);
}
const cap = document.createElement('caption');
cap.textContent = caption;
tb.appendChild(cap);
const thead = document.createElement('thead');
const hr = document.createElement('tr');
TABLE_HEAD.forEach(function (h) {
const th = document.createElement('th');
th.scope = 'col';
th.textContent = h;
hr.appendChild(th);
});
thead.appendChild(hr);
tb.appendChild(thead);
const body = document.createElement('tbody');
rows.forEach(function (r) {
const tr = document.createElement('tr');
r.forEach(function (c, k) {
const cell = document.createElement(k === 0 ? 'th' : 'td');
if (k === 0) {
cell.scope = 'row';
}
cell.textContent = c;
tr.appendChild(cell);
});
body.appendChild(tr);
});
tb.appendChild(body);
}
// 07 — 인력 부하 평준화. 날마다 걸린 사람 수를 더해 DAY_CAPACITY 를 넘는 첫날을 찾고, 그날 걸린 작업 중 여유가 가장 큰 것을 하루 늦춘다(뒤 작업은 pushFrom 으로 따라 밀림). 넘침이 0 이 되거나 LEVEL_MAX_STEPS 에 닿을 때까지 되풀이
const LABEL_W = 96;
const PLOT_X = LABEL_W + 6;
const PLOT_R = W - 8;
const ROWS_TOP = 40;
const ROW_H = 15;
const BAR_H = 10;
const HIST_BASE = 248;
const HIST_UNIT = 7;
const DAY_CAPACITY = 3;
const LEVEL_MAX_STEPS = 40;
const LEVEL_START_S = 0.3;
const LEVEL_STEP_MS = 150;
// 막대 이동·그래프 높이 스프링
const LEVEL_STIFFNESS = 260;
const LEVEL_DAMPING = 20;
const TABLE_CAPTION = '작업 일정표 — 하루 정원에 맞춘 뒤';
const STATUS_LEVEL = '넘친 날 {n}일 · {m}번 늦춤';
const STATUS_DONE = '넘친 날 0일 · {m}번 늦춤 · 전체 {a} → {b}';
const tasks07 = BASE_TASKS;
const order07 = sortByDeps(tasks07).order;
const first07 = sweepBoth(tasks07, order07, null);
function overflowDays(load, cap) { return load.filter(function (v) { return v > cap; }).length; }
function levelSteps(tasks, startsIn, cap, maxSteps) {
const steps = [];
let starts = startsIn.slice();
const order = sortByDeps(tasks).order;
for (let s = 0; s < maxSteps; s += 1) {
const span = Math.max.apply(null, starts.map(function (v, i) { return v + tasks[i].dur; })) + 1;
const load = dailyLoad(tasks, starts, span);
const day = load.findIndex(function (v) { return v > cap; });
if (day < 0) {
break;
}
// 지금 배치 기준의 여유 — 정해 둔 시작일을 지키며 앞뒤로 훑는다
const sw = sweepBoth(tasks, order, starts);
let pick = -1;
// 후보는 그날 「시작하는」 작업만 — 이미 시작한 작업은 건드리지 않는다. 첫 넘침 날은 전날보다 일감이 늘어난 날이라 그날 시작하는 작업이 늘 하나 이상 있고,
// 시작한 작업까지 후보로 두면 겹친 작업들이 여유를 주고받으며 번갈아 하루씩 밀려 끝없이 뛰어넘는다(정원 2 에서 40번을 다 쓰고도 넘침 5일)
tasks.forEach(function (t, i) {
if (t.load === 0 || starts[i] !== day) {
return;
}
if (pick < 0 || sw.slack[i] > sw.slack[pick] || (sw.slack[i] === sw.slack[pick] && i > pick)) {
pick = i;
}
});
if (pick < 0) {
break;
}
const nudged = starts.slice();
nudged[pick] += 1;
starts = pushFrom(tasks, nudged, pick).starts;
const after = dailyLoad(tasks, starts, span + 1);
steps.push({ starts: starts.slice(), moved: pick, day: day, over: overflowDays(after, cap) });
}
return steps;
}
const steps07 = levelSteps(tasks07, first07.es, DAY_CAPACITY, LEVEL_MAX_STEPS);
const final07 = steps07.length ? steps07[steps07.length - 1].starts : first07.es;
const endBefore07 = first07.end;
const endAfter07 = Math.max.apply(null, final07.map(function (v, i) { return v + tasks07[i].dur; }));
const SPAN07 = endAfter07 + 1;
const DAY_WIDTH = Math.min(16, (PLOT_R - PLOT_X) / SPAN07);
const LOOP_S = LEVEL_START_S + steps07.length * LEVEL_STEP_MS / 1000 + 3.2;
const startS07 = tasks07.map(function (_, i) { return makeSpring(first07.es[i]); });
const histS07 = new Array(SPAN07).fill(0).map(function () { return makeSpring(0); });
let stepNow07 = 0;
let flash07 = -1;
let flashT07 = 0;
function startsAt(k) { return k <= 0 ? first07.es : steps07[k - 1].starts; }
function setHistTargets(starts, snap) {
const load = dailyLoad(tasks07, starts, SPAN07);
histS07.forEach(function (s, d) { if (snap) {
snapSpring(s, load[d]);
}
else {
s.to = load[d];
} });
}
function reset() {
stepNow07 = 0;
flash07 = -1;
tasks07.forEach(function (_, i) { snapSpring(startS07[i], first07.es[i]); });
setHistTargets(first07.es, true);
}
function statusFor(k) {
const load = dailyLoad(tasks07, startsAt(k), SPAN07);
const over = overflowDays(load, DAY_CAPACITY);
if (over === 0) {
return STATUS_DONE.replace('{m}', String(k)).replace('{a}', SLACK_FMT.replace('{n}', String(endBefore07))).replace('{b}', SLACK_FMT.replace('{n}', String(endAfter07)));
}
return STATUS_LEVEL.replace('{n}', String(over)).replace('{m}', String(k));
}
function update(dt) {
const k = clamp(Math.floor((sceneT - LEVEL_START_S) / (LEVEL_STEP_MS / 1000)) + 1, 0, steps07.length);
if (k !== stepNow07) {
stepNow07 = k;
if (k > 0) {
flash07 = steps07[k - 1].moved;
flashT07 = 0;
}
const st = startsAt(k);
tasks07.forEach(function (_, i) { startS07[i].to = st[i]; });
setHistTargets(st, false);
}
flashT07 += dt;
startS07.forEach(function (s) { stepSpring(s, LEVEL_STIFFNESS, LEVEL_DAMPING, dt); });
histS07.forEach(function (s) { stepSpring(s, LEVEL_STIFFNESS, LEVEL_DAMPING, dt); });
simStatus = statusFor(stepNow07);
}
function restPose() {
stepNow07 = steps07.length;
flash07 = -1;
tasks07.forEach(function (_, i) { snapSpring(startS07[i], final07[i]); });
setHistTargets(final07, true);
simStatus = statusFor(stepNow07);
}
function tableRows() {
const sw = sweepBoth(tasks07, order07, final07);
return tasks07.map(function (t, i) { return tableRow(t.name, final07[i], t.dur, sw.slack[i]); });
}
const CAP_FMT = '정원 {n}명';
function dayX(d) { return PLOT_X + d * DAY_WIDTH; }
function rowMid(i) { return ROWS_TOP + i * ROW_H + ROW_H / 2; }
function draw() {
clearStage();
const bottom = ROWS_TOP + tasks07.length * ROW_H;
tasks07.forEach(function (_, i) { rowStripe(ROWS_TOP + i * ROW_H, ROW_H, i, 1); rowGlow(ROWS_TOP + i * ROW_H, ROW_H, Math.abs(startS07[i].v) / 8); });
// 지금 살피는 날 — 다음 걸음이 고칠 「넘치는 첫날」 칸을 행부터 그래프까지 세로 띠로
if (stepNow07 < steps07.length && sceneT >= LEVEL_START_S - 0.1) {
const sd = steps07[stepNow07].day;
ctx.fillStyle = rgba(TODAY_DEEP, 0.13);
ctx.fillRect(dayX(sd), ROWS_TOP, DAY_WIDTH, HIST_BASE - ROWS_TOP);
}
timeAxis(dayX, DAY_WIDTH, 0, SPAN07 + 1, PLOT_X, PLOT_R, ROWS_TOP, HIST_BASE, { day: 1, week: 0, month: 0 });
const fl = flash07 >= 0 ? Math.exp(-flashT07 * 5) : 0;
tasks07.forEach(function (t, i) {
const cy = rowMid(i);
rowLabel(t.name, cy, 1, i === flash07 && fl > 0.2);
const x0 = dayX(startS07[i].x);
if (t.dur === 0) {
diamond(x0, cy, 6, PATH_DEEP, 1);
return;
}
bar(x0, cy - BAR_H / 2, t.dur * DAY_WIDTH, BAR_H, BAR_COLORS[i % BAR_COLORS.length], 1, 1.2);
// 사람 수 점 — 막대 안에 한 사람당 점 하나
for (let p = 0; p < t.load; p += 1) {
ctx.beginPath();
ctx.arc(x0 + 6 + p * 6, cy, 1.8, 0, TAU);
ctx.fillStyle = rgba(INK, 0.75);
ctx.fill();
}
if (i === flash07 && fl > 0.01) {
roundRectPath(x0 - 2, cy - BAR_H / 2 - 2, t.dur * DAY_WIDTH + 4, BAR_H + 4, 5);
ctx.lineWidth = 2.2;
ctx.strokeStyle = rgba(PATH_DEEP, fl);
ctx.stroke();
}
});
// 날마다 일감 막대 — 정원을 넘는 몫은 진한 색으로
const capY = HIST_BASE - DAY_CAPACITY * HIST_UNIT;
ctx.fillStyle = rgba(PAPER_DIM, 1);
ctx.fillRect(PLOT_X, bottom + 4, PLOT_R - PLOT_X, HIST_BASE - bottom - 4);
histS07.forEach(function (s, d) {
const v = Math.max(0, s.x);
if (v < 0.02) {
return;
}
const x = dayX(d) + 1;
const w = DAY_WIDTH - 2;
const h = v * HIST_UNIT;
const okH = Math.min(h, DAY_CAPACITY * HIST_UNIT);
ctx.fillStyle = rgba(MINT, 1);
ctx.fillRect(x, HIST_BASE - okH, w, okH);
if (h > okH) {
ctx.fillStyle = rgba(TODAY_DEEP, 0.9);
ctx.fillRect(x, HIST_BASE - h, w, h - okH);
}
});
ctx.fillStyle = rgba(INK, 0.6);
ctx.fillRect(PLOT_X, HIST_BASE, PLOT_R - PLOT_X, 1);
ctx.save();
ctx.setLineDash([4, 3]);
ctx.beginPath();
ctx.moveTo(PLOT_X, capY);
ctx.lineTo(PLOT_R, capY);
ctx.lineWidth = 1.6;
ctx.strokeStyle = rgba(PATH_DEEP, 1);
ctx.stroke();
ctx.restore();
label(CAP_FMT.replace('{n}', String(DAY_CAPACITY)), PLOT_X - 6, capY, PATH_DEEP, 1, 10, 800, 'right');
}
// 한 프레임에 넘기는 시간 상한(초) — 탭을 오래 숨겼다 돌아온 첫 프레임에도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 손대면 시연 표시를 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
// 리스너는 한 묶음으로 달고 정리할 때 한 번에 뗀다
const off = new AbortController();
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
if (statusEl) {
statusEl.setAttribute('aria-live', 'polite');
}
}
// 장면 시계를 dt 만큼 — 한 바퀴(LOOP_S)를 넘으면 처음 배치로 되돌려 다시 시연한다
function advance(dt) {
sceneT += dt;
if (sceneT >= LOOP_S) {
sceneT -= LOOP_S;
reset();
}
update(dt);
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
advance(dt);
}
draw();
pushStatus();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 시계 다시 맞추기 — 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 동작 줄이기 — 루프를 돌리지 않고 시연이 끝난 최종 배치 한 장만 그린다
function showRest() {
stopLoop();
sceneT = 0;
reset();
restPose();
draw();
pushStatus();
}
function play() {
sceneT = 0;
reset();
draw();
pushStatus();
if (!paused) {
startLoop();
}
}
pauseBtn.addEventListener('click', function () {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}, { signal: off.signal });
// 탭이 숨으면 루프를 멈추고, 돌아오면 시계를 다시 맞춰 시작한다 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 페이지를 연 채 바뀌면 — 켜지면 최종 배치로 멈추고, 꺼지면 시연을 처음부터
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
play();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
draw();
}, { signal: off.signal });
fitCanvas();
fillTable(TABLE_CAPTION, tableRows());
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// WorkloadLevelReflow - 같은 항목 vanilla 판 index.html 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./WorkloadLevelReflow.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function WorkloadLevelReflow() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
type Pt = [number, number];
// 판 좌표 — 캔버스 안은 늘 480×300. 화면에 보이는 크기와 상관없이 계산은 이 좌표로 한다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
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); }
// 따라가기 비율 — 1 − e^(−rate·dt). 프레임 시간이 달라도 1초에 같은 만큼 다가간다
function follow(rate: number, dt: number): number { return 1 - Math.exp(-rate * dt); }
// 시드 고정 난수 — 같은 시드면 같은 일정이 다시 나온다 (32비트 정수 섞기)
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// ── 감쇠 스프링 ──────────────────────────────────────────────
// 가속 = 단단함 × (목표 − 위치) − 감쇠 × 속도. 속도를 먼저 고친 뒤 위치를 옮기는 적분을 1/240초 잔걸음으로 나눠 돌린다
type Spring = { x: number; v: number; to: number };
function makeSpring(x: number): Spring { return { x: x, v: 0, to: x }; }
const SPRING_SUBSTEP = 1 / 240;
function stepSpring(s: Spring, stiffness: number, damping: number, dt: number): void {
let left = dt;
while (left > 1e-9) {
const h = Math.min(SPRING_SUBSTEP, left);
s.v += (stiffness * (s.to - s.x) - damping * s.v) * h;
s.x += s.v * h;
left -= h;
}
}
function snapSpring(s: Spring, x: number): void { s.x = x; s.v = 0; s.to = x; }
// ── 날짜 = 정수 일수 ─────────────────────────────────────────
// 날짜 객체를 쓰지 않는다. 기준일을 0 으로 세는 정수 일수(day index)만 다루고, 화면의 월·일 숫자는 아래 월 길이 표로 산수해 만든다
const MONTH_DAYS = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
const YEAR_DAYS = 365;
// 기준일(일수 0) = 한 해의 70번째 날 → 표로 세면 3월 11일
const DAY_ZERO_OF_YEAR = 69;
function calendarOf(day: number): [number, number] {
let r = ((day + DAY_ZERO_OF_YEAR) % YEAR_DAYS + YEAR_DAYS) % YEAR_DAYS;
let m = 0;
while (r >= MONTH_DAYS[m]) { r -= MONTH_DAYS[m]; m += 1; }
return [m + 1, r + 1];
}
const DAY_FMT = '{n}일차';
const WEEK_FMT = '{n}주차';
const MONTH_FMT = '{n}월';
const SLACK_FMT = '{n}일';
function dayText(day: number): string { return DAY_FMT.replace('{n}', String(day + 1)); }
// 하루 차 계산의 함정 — 시각(시간 단위)끼리 빼서 24 로 나누면, 시계가 한 시간 당겨진 날이 끼었을 때 정수가 아니다(23시간짜리 하루).
// 그래서 바깥에서 들어온 시각은 반올림으로 정수 일수에 붙이고, 그다음부터는 정수끼리만 더하고 뺀다
const HOURS_PER_DAY = 24;
const CLOCK_SHIFT_HOURS = 1;
function hoursToDayIndex(hours: number): number { return Math.round(hours / HOURS_PER_DAY); }
// ── 작업과 의존 ──────────────────────────────────────────────
// dur 는 일수(0 이면 마일스톤), deps 는 끝나야 시작할 수 있는 앞 작업 번호, load 는 그 작업에 붙는 사람 수
type Task = { name: string; dur: number; deps: number[]; load: number };
const BASE_TASKS: Task[] = [
{ name: '요구 정리', dur: 3, deps: [], load: 2 },
{ name: '디자인 시안', dur: 4, deps: [0], load: 1 },
{ name: '데이터 설계', dur: 3, deps: [0], load: 1 },
{ name: '화면 개발', dur: 5, deps: [1], load: 2 },
{ name: '서버 개발', dur: 7, deps: [2], load: 1 },
{ name: '기능 연동', dur: 3, deps: [3, 4], load: 2 },
{ name: '문서 작성', dur: 3, deps: [1], load: 1 },
{ name: '통합 검수', dur: 3, deps: [5], load: 2 },
{ name: '오류 수정', dur: 2, deps: [7], load: 1 },
{ name: '출시', dur: 0, deps: [8, 6], load: 0 }
];
function copyTasks(ts: Task[]): Task[] { return ts.map(function (t: Task): Task { return { name: t.name, dur: t.dur, deps: t.deps.slice(), load: t.load }; }); }
function successorsOf(tasks: Task[]): number[][] {
const out: number[][] = tasks.map(function (): number[] { return []; });
tasks.forEach(function (t: Task, i: number): void { t.deps.forEach(function (p: number): void { out[p].push(i); }); });
return out;
}
// 선후 순서로 줄 세우기 — 앞 작업이 하나도 안 남은(들어오는 화살표 0) 작업부터 꺼내고, 꺼낼 때마다 뒤 작업의 남은 수를 하나 줄인다.
// 꺼낼 것이 사라졌는데 남은 작업이 있으면 그 작업들은 서로를 기다리는 고리(순환 의존)에 걸렸거나 그 뒤에 매달린 것이다 → stuck
type Order = { order: number[]; stuck: number[] };
function sortByDeps(tasks: Task[]): Order {
const succ = successorsOf(tasks);
const waiting = tasks.map(function (t: Task): number { return t.deps.length; });
const ready: number[] = [];
waiting.forEach(function (w: number, i: number): void { if (w === 0) { ready.push(i); } });
const order: number[] = [];
while (ready.length) {
ready.sort(function (a: number, b: number): number { return a - b; });
const i = ready.shift() as number;
order.push(i);
succ[i].forEach(function (j: number): void { waiting[j] -= 1; if (waiting[j] === 0) { ready.push(j); } });
}
const stuck: number[] = [];
waiting.forEach(function (w: number, i: number): void { if (w > 0) { stuck.push(i); } });
return { order: order, stuck: stuck };
}
// 앞뒤 두 번 훑기 — 앞에서: 가장 이른 시작 = 앞 작업 끝 중 가장 늦은 날(과 정해 둔 시작일 중 늦은 쪽).
// 뒤에서: 가장 늦은 끝 = 뒤 작업의 가장 늦은 시작 중 가장 이른 날(뒤 작업이 없으면 전체 끝). 여유 = 가장 늦은 시작 − 가장 이른 시작
type Layers = { day: number; week: number; month: number };
type Sweep = { es: number[]; ef: number[]; ls: number[]; lf: number[]; slack: number[]; end: number };
function sweepBoth(tasks: Task[], order: number[], minStart: number[] | null): Sweep {
const n = tasks.length;
const es: number[] = new Array(n).fill(0);
const ef: number[] = new Array(n).fill(0);
for (const i of order) {
let s = minStart ? minStart[i] : 0;
for (const p of tasks[i].deps) { s = Math.max(s, ef[p]); }
es[i] = s;
ef[i] = s + tasks[i].dur;
}
let end = 0;
for (const i of order) { end = Math.max(end, ef[i]); }
const succ = successorsOf(tasks);
const ls: number[] = new Array(n).fill(0);
const lf: number[] = new Array(n).fill(0);
for (let k = order.length - 1; k >= 0; k -= 1) {
const i = order[k];
let f = end;
for (const j of succ[i]) { f = Math.min(f, ls[j]); }
lf[i] = f;
ls[i] = f - tasks[i].dur;
}
const slack = es.map(function (s: number, i: number): number { return ls[i] - s; });
return { es: es, ef: ef, ls: ls, lf: lf, slack: slack, end: end };
}
// 밀기 전파 — 밀린 작업에서 가까운 뒤 작업부터 너비 우선으로 훑으며, 새 시작 = 앞 작업 끝 중 가장 늦은 날(지금보다 늦을 때만).
// depth 는 밀린 작업에서 몇 단계 뒤인지(-1 이면 여유가 흡수해 안 밀림). 시작이 바뀐 작업만 다시 큐에 넣는다
type Push = { starts: number[]; depth: number[] };
function pushFrom(tasks: Task[], starts: number[], from: number): Push {
const succ = successorsOf(tasks);
const out = starts.slice();
const depth: number[] = tasks.map(function (): number { return -1; });
depth[from] = 0;
const queue: number[] = [from];
let head = 0;
// 고리가 있어도 끝나도록 꺼내는 횟수에 상한을 둔다
const limit = tasks.length * tasks.length + 1;
while (head < queue.length && head < limit) {
const i = queue[head];
head += 1;
for (const j of succ[i]) {
let need = 0;
for (const p of tasks[j].deps) { need = Math.max(need, out[p] + tasks[p].dur); }
if (need > out[j]) {
out[j] = need;
depth[j] = Math.max(depth[j], depth[i] + 1);
queue.push(j);
}
}
}
return { starts: out, depth: depth };
}
// 날마다 걸린 일감 — 작업이 덮는 날(시작 ≤ 날 < 시작 + 일수)마다 사람 수를 더한다
function dailyLoad(tasks: Task[], starts: number[], days: number): number[] {
const load: number[] = new Array(days).fill(0);
tasks.forEach(function (t: Task, i: number): void {
for (let d = starts[i]; d < starts[i] + t.dur; d += 1) { if (d >= 0 && d < days) { load[d] += t.load; } }
});
return load;
}
// ── 직각 경로 ───────────────────────────────────────────────
// 앞 끝(ex, ey)에서 오른쪽으로 ROUTE_STUB 나간 뒤 아래로 꺾는다. 뒤 시작(sx)이 그 꺾인 자리 + ROUTE_STUB 보다 오른쪽이면 곧장 내려가 오른쪽으로 들어가고,
// 아니면 앞 행 바로 아래 통로(gapY)까지 내려가 왼쪽으로 돌아간 다음 뒤 시작보다 ROUTE_STUB 앞에서 내려가 오른쪽으로 들어간다
const ROUTE_STUB = 7;
function elbowRoute(ex: number, ey: number, sx: number, sy: number, gapY: number): number[] {
const x1 = ex + ROUTE_STUB;
if (sx - ROUTE_STUB >= x1) { return [ex, ey, x1, ey, x1, sy, sx, sy]; }
const x2 = sx - ROUTE_STUB;
return [ex, ey, x1, ey, x1, gapY, x2, gapY, x2, sy, sx, sy];
}
function routeLength(pts: number[]): number {
let s = 0;
for (let i = 2; i < pts.length; i += 2) { s += Math.hypot(pts[i] - pts[i - 2], pts[i + 1] - pts[i - 1]); }
return s;
}
// 경로 위 거리 dist 자리의 점과 그 마디의 방향
function pointOnRoute(pts: number[], dist: number): [number, number, number, number] {
let left = Math.max(0, dist);
for (let i = 2; i < pts.length; i += 2) {
const dx = pts[i] - pts[i - 2];
const dy = pts[i + 1] - pts[i - 1];
const len = Math.hypot(dx, dy);
if (left <= len || i === pts.length - 2) {
const k = len > 0 ? Math.min(1, left / len) : 0;
return [pts[i - 2] + dx * k, pts[i - 1] + dy * k, len > 0 ? dx / len : 1, len > 0 ? dy / len : 0];
}
left -= len;
}
return [pts[0], pts[1], 1, 0];
}
// 경로를 앞에서부터 dist 만큼만 자른 점 목록 — 화살표가 그려져 나가는 장면에 쓴다
function cutRoute(pts: number[], dist: number): number[] {
const out: number[] = [pts[0], pts[1]];
let left = Math.max(0, dist);
for (let i = 2; i < pts.length; i += 2) {
const len = Math.hypot(pts[i] - pts[i - 2], pts[i + 1] - pts[i - 1]);
if (left >= len) { out.push(pts[i], pts[i + 1]); left -= len; continue; }
const k = len > 0 ? left / len : 0;
out.push(lerp(pts[i - 2], pts[i], k), lerp(pts[i - 1], pts[i + 1], k));
break;
}
return out;
}
// 장면 시계(초)와 상태 줄 글자 — 장면 계산이 고치고, 화면 쪽이 읽어 그린다
let sceneT = 0;
let simStatus = '';
// 파스텔 팔레트 — 크림 바탕 위 라벤더·민트·버터·산호 막대. 가장 긴 경로·오늘 선·글자는 진한 쪽으로 명도 차를 둔다
const PAPER: RGB = [251, 247, 238];
const PAPER_DIM: RGB = [243, 237, 224];
const GRID: RGB = [226, 218, 204];
const INK: RGB = [44, 38, 58];
const INK_SOFT: RGB = [112, 104, 126];
const CREAM: RGB = [255, 251, 242];
const LAVENDER: RGB = [190, 174, 236];
const MINT: RGB = [158, 214, 190];
const BUTTER: RGB = [244, 222, 146];
const CORAL: RGB = [244, 170, 150];
const SKY: RGB = [160, 204, 236];
const PINK: RGB = [242, 182, 206];
const PATH_DEEP: RGB = [86, 56, 168];
const TODAY_DEEP: RGB = [190, 72, 60];
const BAR_COLORS: RGB[] = [LAVENDER, MINT, BUTTER, CORAL, SKY, PINK];
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// 표 한 줄 — 작업·시작·끝·여유. 끝은 그 작업이 덮는 마지막 날(일수 0 짜리는 시작과 같은 날)
function tableRow(name: string, start: number, dur: number, slack: number): string[] {
return [name, dayText(start), dayText(start + Math.max(0, dur - 1)), SLACK_FMT.replace('{n}', String(slack))];
}
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const tableEl = root.querySelector('[data-role="table"]') as HTMLTableElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function clearStage(): void {
ctx.fillStyle = rgba(PAPER, 1);
ctx.fillRect(0, 0, W, H);
}
function label(s: string, x: number, y: number, c: RGB, a: number, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
function roundRectPath(x: number, y: number, w: number, h: number, r: number): void {
const rr = Math.max(0, Math.min(r, w / 2, h / 2));
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.lineTo(x + w - rr, y);
ctx.arcTo(x + w, y, x + w, y + rr, rr);
ctx.lineTo(x + w, y + h - rr);
ctx.arcTo(x + w, y + h, x + w - rr, y + h, rr);
ctx.lineTo(x + rr, y + h);
ctx.arcTo(x, y + h, x, y + h - rr, rr);
ctx.lineTo(x, y + rr);
ctx.arcTo(x, y, x + rr, y, rr);
ctx.closePath();
}
// 막대 하나 — 둥근 사각형 채움 + 진한 테두리. 폭이 0 이하이면 그리지 않는다
function bar(x: number, y: number, w: number, h: number, fill: RGB, a: number, line: number): void {
if (w <= 0.2 || a <= 0) { return; }
roundRectPath(x, y, w, h, 4);
ctx.fillStyle = rgba(fill, a);
ctx.fill();
if (line > 0) {
ctx.lineWidth = line;
ctx.strokeStyle = rgba(INK, 0.55 * a);
ctx.stroke();
}
}
// 마일스톤 마름모
function diamond(cx: number, cy: number, r: number, fill: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.moveTo(cx, cy - r);
ctx.lineTo(cx + r, cy);
ctx.lineTo(cx, cy + r);
ctx.lineTo(cx - r, cy);
ctx.closePath();
ctx.fillStyle = rgba(fill, a);
ctx.fill();
ctx.lineWidth = 1.5;
ctx.strokeStyle = rgba(INK, 0.8 * a);
ctx.stroke();
}
function polyline(pts: number[], c: RGB, a: number, width: number): void {
if (pts.length < 4) { return; }
ctx.beginPath();
ctx.moveTo(pts[0], pts[1]);
for (let i = 2; i < pts.length; i += 2) { ctx.lineTo(pts[i], pts[i + 1]); }
ctx.lineWidth = width;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function arrowHead(x: number, y: number, dx: number, dy: number, size: number, c: RGB, a: number): void {
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x - dx * size - dy * size * 0.6, y - dy * size + dx * size * 0.6);
ctx.lineTo(x - dx * size + dy * size * 0.6, y - dy * size - dx * size * 0.6);
ctx.closePath();
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
// 빗금 — (x, y, w, h) 안에 45° 줄을 spacing 간격으로. 간격이 좁을수록 짙어 보인다
function hatch(x: number, y: number, w: number, h: number, spacing: number, c: RGB, a: number): void {
if (w <= 0.5 || a <= 0) { return; }
ctx.save();
roundRectPath(x, y, w, h, 3);
ctx.clip();
ctx.beginPath();
for (let s = x - h; s < x + w; s += Math.max(2, spacing)) {
ctx.moveTo(s, y + h);
ctx.lineTo(s + h, y);
}
ctx.lineWidth = 1.6;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
ctx.restore();
}
// 시간 축 — 위 띠는 월 이름, 아래 띠는 일(월 안의 날 숫자)·주(주차) 층. 층마다 불투명도를 따로 받아 겹쳐 흐려지며 바뀌게 한다.
// 세로 격자도 같은 불투명도로 행 영역(top~bottom)에 긋는다. xOf 는 일수 → 화면 x
function timeAxis(xOf: (d: number) => number, dayW: number, d0: number, d1: number, left: number, right: number, top: number, bottom: number, ly: Layers): void {
ctx.save();
ctx.beginPath();
ctx.rect(left, 0, right - left, H);
ctx.clip();
ctx.fillStyle = rgba(PAPER_DIM, 1);
ctx.fillRect(left, 0, right - left, top - 4);
for (let d = Math.floor(d0); d <= Math.ceil(d1); d += 1) {
const x = xOf(d);
const cal = calendarOf(d);
if (ly.day > 0.01) {
ctx.fillStyle = rgba(GRID, 0.7 * ly.day);
ctx.fillRect(x, top, 1, bottom - top);
if (dayW >= 13) { label(String(cal[1]), x + dayW / 2, top - 11, INK_SOFT, ly.day, 9, 600, 'center'); }
}
// 주 층 — 글자가 겹치지 않을 만큼(36px) 주를 건너뛰며 적는다
const weekStep = 7 * dayW >= 36 ? 1 : (14 * dayW >= 36 ? 2 : 4);
if (ly.week > 0.01 && d % 7 === 0) {
ctx.fillStyle = rgba(GRID, ly.week);
ctx.fillRect(x, top, 1, bottom - top);
if ((d / 7) % weekStep === 0) { label(WEEK_FMT.replace('{n}', String(d / 7 + 1)), x + 3, top - 11, INK_SOFT, ly.week, 9, 600, 'left'); }
}
if (cal[1] === 1) {
ctx.fillStyle = rgba(INK_SOFT, 0.18 + 0.4 * ly.month);
ctx.fillRect(x, 4, 1, bottom - 4);
label(MONTH_FMT.replace('{n}', String(cal[0])), x + 4, 11, INK, 0.85, 10, 700, 'left');
// 월 층 — 그달 칸 가운데에 그달 일수(월 길이 표에서)를 적는다
const len = MONTH_DAYS[cal[0] - 1];
if (ly.month > 0.01 && len * dayW >= 30) { label(SLACK_FMT.replace('{n}', String(len)), x + len * dayW / 2, top - 11, INK_SOFT, ly.month, 9, 700, 'center'); }
} else if (d === Math.floor(d0)) {
// 왼쪽 끝 월 이름 — 바로 오른쪽에 새 달이 시작하면 겹치므로 적지 않는다
const toNext = MONTH_DAYS[cal[0] - 1] - cal[1] + 1;
if (toNext * dayW >= 44) { label(MONTH_FMT.replace('{n}', String(cal[0])), Math.max(x, left) + 4, 11, INK, 0.85, 10, 700, 'left'); }
}
}
ctx.restore();
}
// 왼쪽 작업 이름 칸
function rowLabel(name: string, y: number, a: number, bold: boolean): void {
label(name, 10, y, INK, a, 11, bold ? 800 : 600, 'left');
}
function rowStripe(y: number, h: number, i: number, a: number): void {
if (i % 2 === 1) {
ctx.fillStyle = rgba(PAPER_DIM, 0.75 * a);
ctx.fillRect(0, y, W, h);
}
}
// 움직이는 행 빛 — 그 행 막대의 스프링 속도(k = 0~1)만큼 행 전체가 옅은 라벤더로 밝아진다. 막대가 서면 사라진다
function rowGlow(y: number, h: number, k: number): void {
if (k <= 0.01) { return; }
ctx.fillStyle = rgba(LAVENDER, 0.42 * clamp(k, 0, 1));
ctx.fillRect(0, y, W, h);
}
// 상태 줄 — 바뀔 때만 글자를 고친다
let shownStatus = '';
function pushStatus(): void {
if (!statusEl || simStatus === shownStatus) { return; }
shownStatus = simStatus;
statusEl.textContent = simStatus;
}
// 숨김 표 — 같은 일정을 작업·시작·끝·여유 열로 읽게. 화면에는 안 보이고 화면 읽기 도구가 읽는다
const TABLE_HEAD = ['작업 이름', '시작일', '끝나는 날', '여유 일수'];
function fillTable(caption: string, rows: string[][]): void {
if (!tableEl) { return; }
const tb = tableEl;
while (tb.firstChild) { tb.removeChild(tb.firstChild); }
const cap = document.createElement('caption');
cap.textContent = caption;
tb.appendChild(cap);
const thead = document.createElement('thead');
const hr = document.createElement('tr');
TABLE_HEAD.forEach(function (h: string): void {
const th = document.createElement('th');
th.scope = 'col';
th.textContent = h;
hr.appendChild(th);
});
thead.appendChild(hr);
tb.appendChild(thead);
const body = document.createElement('tbody');
rows.forEach(function (r: string[]): void {
const tr = document.createElement('tr');
r.forEach(function (c: string, k: number): void {
const cell = document.createElement(k === 0 ? 'th' : 'td');
if (k === 0) { (cell as HTMLTableCellElement).scope = 'row'; }
cell.textContent = c;
tr.appendChild(cell);
});
body.appendChild(tr);
});
tb.appendChild(body);
}
// 07 — 인력 부하 평준화. 날마다 걸린 사람 수를 더해 DAY_CAPACITY 를 넘는 첫날을 찾고, 그날 걸린 작업 중 여유가 가장 큰 것을 하루 늦춘다(뒤 작업은 pushFrom 으로 따라 밀림). 넘침이 0 이 되거나 LEVEL_MAX_STEPS 에 닿을 때까지 되풀이
const LABEL_W = 96;
const PLOT_X = LABEL_W + 6;
const PLOT_R = W - 8;
const ROWS_TOP = 40;
const ROW_H = 15;
const BAR_H = 10;
const HIST_BASE = 248;
const HIST_UNIT = 7;
const DAY_CAPACITY = 3;
const LEVEL_MAX_STEPS = 40;
const LEVEL_START_S = 0.3;
const LEVEL_STEP_MS = 150;
// 막대 이동·그래프 높이 스프링
const LEVEL_STIFFNESS = 260;
const LEVEL_DAMPING = 20;
const TABLE_CAPTION = '작업 일정표 — 하루 정원에 맞춘 뒤';
const STATUS_LEVEL = '넘친 날 {n}일 · {m}번 늦춤';
const STATUS_DONE = '넘친 날 0일 · {m}번 늦춤 · 전체 {a} → {b}';
const tasks07 = BASE_TASKS;
const order07 = sortByDeps(tasks07).order;
const first07 = sweepBoth(tasks07, order07, null);
type LevelStep = { starts: number[]; moved: number; day: number; over: number };
function overflowDays(load: number[], cap: number): number { return load.filter(function (v: number): boolean { return v > cap; }).length; }
function levelSteps(tasks: Task[], startsIn: number[], cap: number, maxSteps: number): LevelStep[] {
const steps: LevelStep[] = [];
let starts = startsIn.slice();
const order = sortByDeps(tasks).order;
for (let s = 0; s < maxSteps; s += 1) {
const span = Math.max.apply(null, starts.map(function (v: number, i: number): number { return v + tasks[i].dur; })) + 1;
const load = dailyLoad(tasks, starts, span);
const day = load.findIndex(function (v: number): boolean { return v > cap; });
if (day < 0) { break; }
// 지금 배치 기준의 여유 — 정해 둔 시작일을 지키며 앞뒤로 훑는다
const sw = sweepBoth(tasks, order, starts);
let pick = -1;
// 후보는 그날 「시작하는」 작업만 — 이미 시작한 작업은 건드리지 않는다. 첫 넘침 날은 전날보다 일감이 늘어난 날이라 그날 시작하는 작업이 늘 하나 이상 있고,
// 시작한 작업까지 후보로 두면 겹친 작업들이 여유를 주고받으며 번갈아 하루씩 밀려 끝없이 뛰어넘는다(정원 2 에서 40번을 다 쓰고도 넘침 5일)
tasks.forEach(function (t: Task, i: number): void {
if (t.load === 0 || starts[i] !== day) { return; }
if (pick < 0 || sw.slack[i] > sw.slack[pick] || (sw.slack[i] === sw.slack[pick] && i > pick)) { pick = i; }
});
if (pick < 0) { break; }
const nudged = starts.slice();
nudged[pick] += 1;
starts = pushFrom(tasks, nudged, pick).starts;
const after = dailyLoad(tasks, starts, span + 1);
steps.push({ starts: starts.slice(), moved: pick, day: day, over: overflowDays(after, cap) });
}
return steps;
}
const steps07 = levelSteps(tasks07, first07.es, DAY_CAPACITY, LEVEL_MAX_STEPS);
const final07 = steps07.length ? steps07[steps07.length - 1].starts : first07.es;
const endBefore07 = first07.end;
const endAfter07 = Math.max.apply(null, final07.map(function (v: number, i: number): number { return v + tasks07[i].dur; }));
const SPAN07 = endAfter07 + 1;
const DAY_WIDTH = Math.min(16, (PLOT_R - PLOT_X) / SPAN07);
const LOOP_S = LEVEL_START_S + steps07.length * LEVEL_STEP_MS / 1000 + 3.2;
const startS07: Spring[] = tasks07.map(function (_: Task, i: number): Spring { return makeSpring(first07.es[i]); });
const histS07: Spring[] = new Array(SPAN07).fill(0).map(function (): Spring { return makeSpring(0); });
let stepNow07 = 0;
let flash07 = -1;
let flashT07 = 0;
function startsAt(k: number): number[] { return k <= 0 ? first07.es : steps07[k - 1].starts; }
function setHistTargets(starts: number[], snap: boolean): void {
const load = dailyLoad(tasks07, starts, SPAN07);
histS07.forEach(function (s: Spring, d: number): void { if (snap) { snapSpring(s, load[d]); } else { s.to = load[d]; } });
}
function reset(): void {
stepNow07 = 0;
flash07 = -1;
tasks07.forEach(function (_: Task, i: number): void { snapSpring(startS07[i], first07.es[i]); });
setHistTargets(first07.es, true);
}
function statusFor(k: number): string {
const load = dailyLoad(tasks07, startsAt(k), SPAN07);
const over = overflowDays(load, DAY_CAPACITY);
if (over === 0) { return STATUS_DONE.replace('{m}', String(k)).replace('{a}', SLACK_FMT.replace('{n}', String(endBefore07))).replace('{b}', SLACK_FMT.replace('{n}', String(endAfter07))); }
return STATUS_LEVEL.replace('{n}', String(over)).replace('{m}', String(k));
}
function update(dt: number): void {
const k = clamp(Math.floor((sceneT - LEVEL_START_S) / (LEVEL_STEP_MS / 1000)) + 1, 0, steps07.length);
if (k !== stepNow07) {
stepNow07 = k;
if (k > 0) { flash07 = steps07[k - 1].moved; flashT07 = 0; }
const st = startsAt(k);
tasks07.forEach(function (_: Task, i: number): void { startS07[i].to = st[i]; });
setHistTargets(st, false);
}
flashT07 += dt;
startS07.forEach(function (s: Spring): void { stepSpring(s, LEVEL_STIFFNESS, LEVEL_DAMPING, dt); });
histS07.forEach(function (s: Spring): void { stepSpring(s, LEVEL_STIFFNESS, LEVEL_DAMPING, dt); });
simStatus = statusFor(stepNow07);
}
function restPose(): void {
stepNow07 = steps07.length;
flash07 = -1;
tasks07.forEach(function (_: Task, i: number): void { snapSpring(startS07[i], final07[i]); });
setHistTargets(final07, true);
simStatus = statusFor(stepNow07);
}
function tableRows(): string[][] {
const sw = sweepBoth(tasks07, order07, final07);
return tasks07.map(function (t: Task, i: number): string[] { return tableRow(t.name, final07[i], t.dur, sw.slack[i]); });
}
const CAP_FMT = '정원 {n}명';
function dayX(d: number): number { return PLOT_X + d * DAY_WIDTH; }
function rowMid(i: number): number { return ROWS_TOP + i * ROW_H + ROW_H / 2; }
function draw(): void {
clearStage();
const bottom = ROWS_TOP + tasks07.length * ROW_H;
tasks07.forEach(function (_: Task, i: number): void { rowStripe(ROWS_TOP + i * ROW_H, ROW_H, i, 1); rowGlow(ROWS_TOP + i * ROW_H, ROW_H, Math.abs(startS07[i].v) / 8); });
// 지금 살피는 날 — 다음 걸음이 고칠 「넘치는 첫날」 칸을 행부터 그래프까지 세로 띠로
if (stepNow07 < steps07.length && sceneT >= LEVEL_START_S - 0.1) {
const sd = steps07[stepNow07].day;
ctx.fillStyle = rgba(TODAY_DEEP, 0.13);
ctx.fillRect(dayX(sd), ROWS_TOP, DAY_WIDTH, HIST_BASE - ROWS_TOP);
}
timeAxis(dayX, DAY_WIDTH, 0, SPAN07 + 1, PLOT_X, PLOT_R, ROWS_TOP, HIST_BASE, { day: 1, week: 0, month: 0 });
const fl = flash07 >= 0 ? Math.exp(-flashT07 * 5) : 0;
tasks07.forEach(function (t: Task, i: number): void {
const cy = rowMid(i);
rowLabel(t.name, cy, 1, i === flash07 && fl > 0.2);
const x0 = dayX(startS07[i].x);
if (t.dur === 0) {
diamond(x0, cy, 6, PATH_DEEP, 1);
return;
}
bar(x0, cy - BAR_H / 2, t.dur * DAY_WIDTH, BAR_H, BAR_COLORS[i % BAR_COLORS.length], 1, 1.2);
// 사람 수 점 — 막대 안에 한 사람당 점 하나
for (let p = 0; p < t.load; p += 1) {
ctx.beginPath();
ctx.arc(x0 + 6 + p * 6, cy, 1.8, 0, TAU);
ctx.fillStyle = rgba(INK, 0.75);
ctx.fill();
}
if (i === flash07 && fl > 0.01) {
roundRectPath(x0 - 2, cy - BAR_H / 2 - 2, t.dur * DAY_WIDTH + 4, BAR_H + 4, 5);
ctx.lineWidth = 2.2;
ctx.strokeStyle = rgba(PATH_DEEP, fl);
ctx.stroke();
}
});
// 날마다 일감 막대 — 정원을 넘는 몫은 진한 색으로
const capY = HIST_BASE - DAY_CAPACITY * HIST_UNIT;
ctx.fillStyle = rgba(PAPER_DIM, 1);
ctx.fillRect(PLOT_X, bottom + 4, PLOT_R - PLOT_X, HIST_BASE - bottom - 4);
histS07.forEach(function (s: Spring, d: number): void {
const v = Math.max(0, s.x);
if (v < 0.02) { return; }
const x = dayX(d) + 1;
const w = DAY_WIDTH - 2;
const h = v * HIST_UNIT;
const okH = Math.min(h, DAY_CAPACITY * HIST_UNIT);
ctx.fillStyle = rgba(MINT, 1);
ctx.fillRect(x, HIST_BASE - okH, w, okH);
if (h > okH) {
ctx.fillStyle = rgba(TODAY_DEEP, 0.9);
ctx.fillRect(x, HIST_BASE - h, w, h - okH);
}
});
ctx.fillStyle = rgba(INK, 0.6);
ctx.fillRect(PLOT_X, HIST_BASE, PLOT_R - PLOT_X, 1);
ctx.save();
ctx.setLineDash([4, 3]);
ctx.beginPath();
ctx.moveTo(PLOT_X, capY);
ctx.lineTo(PLOT_R, capY);
ctx.lineWidth = 1.6;
ctx.strokeStyle = rgba(PATH_DEEP, 1);
ctx.stroke();
ctx.restore();
label(CAP_FMT.replace('{n}', String(DAY_CAPACITY)), PLOT_X - 6, capY, PATH_DEEP, 1, 10, 800, 'right');
}
// 한 프레임에 넘기는 시간 상한(초) — 탭을 오래 숨겼다 돌아온 첫 프레임에도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 손대면 시연 표시를 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
// 리스너는 한 묶음으로 달고 정리할 때 한 번에 뗀다
const off = new AbortController();
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
if (statusEl) { statusEl.setAttribute('aria-live', 'polite'); }
}
// 장면 시계를 dt 만큼 — 한 바퀴(LOOP_S)를 넘으면 처음 배치로 되돌려 다시 시연한다
function advance(dt: number): void {
sceneT += dt;
if (sceneT >= LOOP_S) {
sceneT -= LOOP_S;
reset();
}
update(dt);
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) { advance(dt); }
draw();
pushStatus();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 시계 다시 맞추기 — 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 동작 줄이기 — 루프를 돌리지 않고 시연이 끝난 최종 배치 한 장만 그린다
function showRest(): void {
stopLoop();
sceneT = 0;
reset();
restPose();
draw();
pushStatus();
}
function play(): void {
sceneT = 0;
reset();
draw();
pushStatus();
if (!paused) { startLoop(); }
}
pauseBtn.addEventListener('click', function (): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}, { signal: off.signal });
// 탭이 숨으면 루프를 멈추고, 돌아오면 시계를 다시 맞춰 시작한다 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 페이지를 연 채 바뀌면 — 켜지면 최종 배치로 멈추고, 꺼지면 시연을 처음부터
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { play(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
draw();
}, { signal: off.signal });
fitCanvas();
fillTable(TABLE_CAPTION, tableRows());
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("cf") + " is-demo"} ref={rootRef}>
<canvas className={cx("cf__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label="아래 막대그래프에서 정원을 넘는 날을 찾아 여유가 큰 작업을 하루씩 늦추는 인력 부하 평준화."></canvas>
<table className={cx("cf__sr")} data-role="table"></table>
<p className={cx("cf__status")} data-role="status" aria-live="off">{'하루 정원 확인 중'}</p>
<button className={cx("cf__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
</div>
);
}07 · 하루 정원을 넘는 날을 깎아 내는 인력 부하 평준화 — 측정 누적면적 10.849% · 움직인 프레임 11/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 지금 배치 기준의 여유 — 정해 둔 시작일을 지키며 앞뒤로 훑는다
const sw = sweepBoth(tasks, order, starts);
let pick = -1;
// 후보는 그날 「시작하는」 작업만 — 이미 시작한 작업은 건드리지 않는다. 첫 넘침 날은 전날보다 일감이 늘어난 날이라 그날 시작하는 작업이 늘 하나 이상 있고,
// 시작한 작업까지 후보로 두면 겹친 작업들이 여유를 주고받으며 번갈아 하루씩 밀려 끝없이 뛰어넘는다(정원 2 에서 40번을 다 쓰고도 넘침 5일)
tasks.forEach(function (t, i) {
if (t.load === 0 || starts[i] !== day) {
return;
}
if (pick < 0 || sw.slack[i] > sw.slack[pick] || (sw.slack[i] === sw.slack[pick] && i > pick)) {
pick = i;
}
});
if (pick < 0) {
break;
}
const nudged = starts.slice();
nudged[pick] += 1;
starts = pushFrom(tasks, nudged, pick).starts;
const after = dailyLoad(tasks, starts, span + 1);
steps.push({ starts: starts.slice(), moved: pick, day: day, over: overflowDays(after, cap) });
08하위 작업이 상위 막대 하나로 모여드는 묶음 접기
상위 막대 구간은 하위 작업의 가장 이른 시작과 가장 늦은 끝으로 계산하고(기획 0~7일·개발 6~16일·검수 7~21일), 0.3초에 개발·0.65초에 기획이 접히면 하위 막대가 세로 위치와 높이를 상위 막대 쪽으로 보간해 색 조각으로 겹쳐 남는다. layoutTargets 는 접히는 하위 행 높이를 17 × (1 − 목표 접힘)으로 쳐서 아래 행 목표 y 를 다시 쌓고, 행은 스프링(190·17)으로 따라가 펼친 채 남는 검수 묶음 머리 행이 102px 올라와 1.104초에 자리를 굳힌다. 부서별 일정 요약에서 지금 볼 묶음 하나만 펼치고 나머지는 한 줄로 접어 두는 화면이다.

<main class="stage">
<div class="cf is-demo" id="cf">
<canvas class="cf__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="묶음이 접힐 때 하위 막대가 상위 막대 하나로 모여들고 아래 행이 올라오는 일정표."></canvas>
<table class="cf__sr" data-role="table"></table>
<button class="cf__pause" data-role="pause" type="button" aria-pressed="false">일시정지</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.cf {
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-cream;
}
.cf__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.cf__pause {
position: absolute;
right: $sp-3;
bottom: $sp-3;
padding: $sp-1 $sp-3;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.cf__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.cf__pause:hover {
transform: translateY(-2px);
}
.cf__pause:active {
transform: translateY(1px);
}
.cf__pause:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 일정에 무슨 일이 일어났는지 글자로. 시연 중에는 aria-live off, 사람이 처음 손대면 polite
.cf__status {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: calc(100% - 120px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
// 숨김 표 — 화면에서는 1px 로 접어 두고 화면 읽기 도구만 읽는다
.cf__sr {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
@media (max-width: 340px) {
.cf__pause,
.cf__status { font-size: 10px; padding: $sp-1 $sp-2; }
}
// 동작 줄이기 — 장면은 스크립트가 최종 배치 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.cf__pause { transition: none !important; }
.cf__pause:hover,
.cf__pause:active { transform: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cf {
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: #fff7e6;
}
.cf__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.cf__pause {
position: absolute;
right: 12px;
bottom: 12px;
padding: 4px 12px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.cf__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.cf__pause:hover {
transform: translateY(-2px);
}
.cf__pause:active {
transform: translateY(1px);
}
.cf__pause:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.cf__status {
position: absolute;
left: 12px;
bottom: 12px;
max-width: calc(100% - 120px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
.cf__sr {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
@media (max-width: 340px) {
.cf__pause,
.cf__status {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.cf__pause {
transition: none !important;
}
.cf__pause:hover,
.cf__pause:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("cf");
if (!root) return;
// 판 좌표 — 캔버스 안은 늘 480×300. 화면에 보이는 크기와 상관없이 계산은 이 좌표로 한다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
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); }
// 따라가기 비율 — 1 − e^(−rate·dt). 프레임 시간이 달라도 1초에 같은 만큼 다가간다
function follow(rate, dt) { return 1 - Math.exp(-rate * dt); }
// 시드 고정 난수 — 같은 시드면 같은 일정이 다시 나온다 (32비트 정수 섞기)
function makeRng(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function makeSpring(x) { return { x: x, v: 0, to: x }; }
const SPRING_SUBSTEP = 1 / 240;
function stepSpring(s, stiffness, damping, dt) {
let left = dt;
while (left > 1e-9) {
const h = Math.min(SPRING_SUBSTEP, left);
s.v += (stiffness * (s.to - s.x) - damping * s.v) * h;
s.x += s.v * h;
left -= h;
}
}
function snapSpring(s, x) { s.x = x; s.v = 0; s.to = x; }
// ── 날짜 = 정수 일수 ─────────────────────────────────────────
// 날짜 객체를 쓰지 않는다. 기준일을 0 으로 세는 정수 일수(day index)만 다루고, 화면의 월·일 숫자는 아래 월 길이 표로 산수해 만든다
const MONTH_DAYS = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
const YEAR_DAYS = 365;
// 기준일(일수 0) = 한 해의 70번째 날 → 표로 세면 3월 11일
const DAY_ZERO_OF_YEAR = 69;
function calendarOf(day) {
let r = ((day + DAY_ZERO_OF_YEAR) % YEAR_DAYS + YEAR_DAYS) % YEAR_DAYS;
let m = 0;
while (r >= MONTH_DAYS[m]) {
r -= MONTH_DAYS[m];
m += 1;
}
return [m + 1, r + 1];
}
const DAY_FMT = '{n}일차';
const WEEK_FMT = '{n}주차';
const MONTH_FMT = '{n}월';
const SLACK_FMT = '{n}일';
function dayText(day) { return DAY_FMT.replace('{n}', String(day + 1)); }
// 하루 차 계산의 함정 — 시각(시간 단위)끼리 빼서 24 로 나누면, 시계가 한 시간 당겨진 날이 끼었을 때 정수가 아니다(23시간짜리 하루).
// 그래서 바깥에서 들어온 시각은 반올림으로 정수 일수에 붙이고, 그다음부터는 정수끼리만 더하고 뺀다
const HOURS_PER_DAY = 24;
const CLOCK_SHIFT_HOURS = 1;
function hoursToDayIndex(hours) { return Math.round(hours / HOURS_PER_DAY); }
const BASE_TASKS = [
{ name: '요구 정리', dur: 3, deps: [], load: 2 },
{ name: '디자인 시안', dur: 4, deps: [0], load: 1 },
{ name: '데이터 설계', dur: 3, deps: [0], load: 1 },
{ name: '화면 개발', dur: 5, deps: [1], load: 2 },
{ name: '서버 개발', dur: 7, deps: [2], load: 1 },
{ name: '기능 연동', dur: 3, deps: [3, 4], load: 2 },
{ name: '문서 작성', dur: 3, deps: [1], load: 1 },
{ name: '통합 검수', dur: 3, deps: [5], load: 2 },
{ name: '오류 수정', dur: 2, deps: [7], load: 1 },
{ name: '출시', dur: 0, deps: [8, 6], load: 0 }
];
function copyTasks(ts) { return ts.map(function (t) { return { name: t.name, dur: t.dur, deps: t.deps.slice(), load: t.load }; }); }
function successorsOf(tasks) {
const out = tasks.map(function () { return []; });
tasks.forEach(function (t, i) { t.deps.forEach(function (p) { out[p].push(i); }); });
return out;
}
function sortByDeps(tasks) {
const succ = successorsOf(tasks);
const waiting = tasks.map(function (t) { return t.deps.length; });
const ready = [];
waiting.forEach(function (w, i) { if (w === 0) {
ready.push(i);
} });
const order = [];
while (ready.length) {
ready.sort(function (a, b) { return a - b; });
const i = ready.shift();
order.push(i);
succ[i].forEach(function (j) { waiting[j] -= 1; if (waiting[j] === 0) {
ready.push(j);
} });
}
const stuck = [];
waiting.forEach(function (w, i) { if (w > 0) {
stuck.push(i);
} });
return { order: order, stuck: stuck };
}
function sweepBoth(tasks, order, minStart) {
const n = tasks.length;
const es = new Array(n).fill(0);
const ef = new Array(n).fill(0);
for (const i of order) {
let s = minStart ? minStart[i] : 0;
for (const p of tasks[i].deps) {
s = Math.max(s, ef[p]);
}
es[i] = s;
ef[i] = s + tasks[i].dur;
}
let end = 0;
for (const i of order) {
end = Math.max(end, ef[i]);
}
const succ = successorsOf(tasks);
const ls = new Array(n).fill(0);
const lf = new Array(n).fill(0);
for (let k = order.length - 1; k >= 0; k -= 1) {
const i = order[k];
let f = end;
for (const j of succ[i]) {
f = Math.min(f, ls[j]);
}
lf[i] = f;
ls[i] = f - tasks[i].dur;
}
const slack = es.map(function (s, i) { return ls[i] - s; });
return { es: es, ef: ef, ls: ls, lf: lf, slack: slack, end: end };
}
function pushFrom(tasks, starts, from) {
const succ = successorsOf(tasks);
const out = starts.slice();
const depth = tasks.map(function () { return -1; });
depth[from] = 0;
const queue = [from];
let head = 0;
// 고리가 있어도 끝나도록 꺼내는 횟수에 상한을 둔다
const limit = tasks.length * tasks.length + 1;
while (head < queue.length && head < limit) {
const i = queue[head];
head += 1;
for (const j of succ[i]) {
let need = 0;
for (const p of tasks[j].deps) {
need = Math.max(need, out[p] + tasks[p].dur);
}
if (need > out[j]) {
out[j] = need;
depth[j] = Math.max(depth[j], depth[i] + 1);
queue.push(j);
}
}
}
return { starts: out, depth: depth };
}
// 날마다 걸린 일감 — 작업이 덮는 날(시작 ≤ 날 < 시작 + 일수)마다 사람 수를 더한다
function dailyLoad(tasks, starts, days) {
const load = new Array(days).fill(0);
tasks.forEach(function (t, i) {
for (let d = starts[i]; d < starts[i] + t.dur; d += 1) {
if (d >= 0 && d < days) {
load[d] += t.load;
}
}
});
return load;
}
// ── 직각 경로 ───────────────────────────────────────────────
// 앞 끝(ex, ey)에서 오른쪽으로 ROUTE_STUB 나간 뒤 아래로 꺾는다. 뒤 시작(sx)이 그 꺾인 자리 + ROUTE_STUB 보다 오른쪽이면 곧장 내려가 오른쪽으로 들어가고,
// 아니면 앞 행 바로 아래 통로(gapY)까지 내려가 왼쪽으로 돌아간 다음 뒤 시작보다 ROUTE_STUB 앞에서 내려가 오른쪽으로 들어간다
const ROUTE_STUB = 7;
function elbowRoute(ex, ey, sx, sy, gapY) {
const x1 = ex + ROUTE_STUB;
if (sx - ROUTE_STUB >= x1) {
return [ex, ey, x1, ey, x1, sy, sx, sy];
}
const x2 = sx - ROUTE_STUB;
return [ex, ey, x1, ey, x1, gapY, x2, gapY, x2, sy, sx, sy];
}
function routeLength(pts) {
let s = 0;
for (let i = 2; i < pts.length; i += 2) {
s += Math.hypot(pts[i] - pts[i - 2], pts[i + 1] - pts[i - 1]);
}
return s;
}
// 경로 위 거리 dist 자리의 점과 그 마디의 방향
function pointOnRoute(pts, dist) {
let left = Math.max(0, dist);
for (let i = 2; i < pts.length; i += 2) {
const dx = pts[i] - pts[i - 2];
const dy = pts[i + 1] - pts[i - 1];
const len = Math.hypot(dx, dy);
if (left <= len || i === pts.length - 2) {
const k = len > 0 ? Math.min(1, left / len) : 0;
return [pts[i - 2] + dx * k, pts[i - 1] + dy * k, len > 0 ? dx / len : 1, len > 0 ? dy / len : 0];
}
left -= len;
}
return [pts[0], pts[1], 1, 0];
}
// 경로를 앞에서부터 dist 만큼만 자른 점 목록 — 화살표가 그려져 나가는 장면에 쓴다
function cutRoute(pts, dist) {
const out = [pts[0], pts[1]];
let left = Math.max(0, dist);
for (let i = 2; i < pts.length; i += 2) {
const len = Math.hypot(pts[i] - pts[i - 2], pts[i + 1] - pts[i - 1]);
if (left >= len) {
out.push(pts[i], pts[i + 1]);
left -= len;
continue;
}
const k = len > 0 ? left / len : 0;
out.push(lerp(pts[i - 2], pts[i], k), lerp(pts[i - 1], pts[i + 1], k));
break;
}
return out;
}
// 장면 시계(초)와 상태 줄 글자 — 장면 계산이 고치고, 화면 쪽이 읽어 그린다
let sceneT = 0;
let simStatus = '';
// 파스텔 팔레트 — 크림 바탕 위 라벤더·민트·버터·산호 막대. 가장 긴 경로·오늘 선·글자는 진한 쪽으로 명도 차를 둔다
const PAPER = [251, 247, 238];
const PAPER_DIM = [243, 237, 224];
const GRID = [226, 218, 204];
const INK = [44, 38, 58];
const INK_SOFT = [112, 104, 126];
const CREAM = [255, 251, 242];
const LAVENDER = [190, 174, 236];
const MINT = [158, 214, 190];
const BUTTER = [244, 222, 146];
const CORAL = [244, 170, 150];
const SKY = [160, 204, 236];
const PINK = [242, 182, 206];
const PATH_DEEP = [86, 56, 168];
const TODAY_DEEP = [190, 72, 60];
const BAR_COLORS = [LAVENDER, MINT, BUTTER, CORAL, SKY, PINK];
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// 표 한 줄 — 작업·시작·끝·여유. 끝은 그 작업이 덮는 마지막 날(일수 0 짜리는 시작과 같은 날)
function tableRow(name, start, dur, slack) {
return [name, dayText(start), dayText(start + Math.max(0, dur - 1)), SLACK_FMT.replace('{n}', String(slack))];
}
const canvasEl = root.querySelector('[data-role="canvas"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const tableEl = root.querySelector('[data-role="table"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function clearStage() {
ctx.fillStyle = rgba(PAPER, 1);
ctx.fillRect(0, 0, W, H);
}
function label(s, x, y, c, a, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
function roundRectPath(x, y, w, h, r) {
const rr = Math.max(0, Math.min(r, w / 2, h / 2));
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.lineTo(x + w - rr, y);
ctx.arcTo(x + w, y, x + w, y + rr, rr);
ctx.lineTo(x + w, y + h - rr);
ctx.arcTo(x + w, y + h, x + w - rr, y + h, rr);
ctx.lineTo(x + rr, y + h);
ctx.arcTo(x, y + h, x, y + h - rr, rr);
ctx.lineTo(x, y + rr);
ctx.arcTo(x, y, x + rr, y, rr);
ctx.closePath();
}
// 막대 하나 — 둥근 사각형 채움 + 진한 테두리. 폭이 0 이하이면 그리지 않는다
function bar(x, y, w, h, fill, a, line) {
if (w <= 0.2 || a <= 0) {
return;
}
roundRectPath(x, y, w, h, 4);
ctx.fillStyle = rgba(fill, a);
ctx.fill();
if (line > 0) {
ctx.lineWidth = line;
ctx.strokeStyle = rgba(INK, 0.55 * a);
ctx.stroke();
}
}
// 마일스톤 마름모
function diamond(cx, cy, r, fill, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.moveTo(cx, cy - r);
ctx.lineTo(cx + r, cy);
ctx.lineTo(cx, cy + r);
ctx.lineTo(cx - r, cy);
ctx.closePath();
ctx.fillStyle = rgba(fill, a);
ctx.fill();
ctx.lineWidth = 1.5;
ctx.strokeStyle = rgba(INK, 0.8 * a);
ctx.stroke();
}
function polyline(pts, c, a, width) {
if (pts.length < 4) {
return;
}
ctx.beginPath();
ctx.moveTo(pts[0], pts[1]);
for (let i = 2; i < pts.length; i += 2) {
ctx.lineTo(pts[i], pts[i + 1]);
}
ctx.lineWidth = width;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function arrowHead(x, y, dx, dy, size, c, a) {
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x - dx * size - dy * size * 0.6, y - dy * size + dx * size * 0.6);
ctx.lineTo(x - dx * size + dy * size * 0.6, y - dy * size - dx * size * 0.6);
ctx.closePath();
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
// 빗금 — (x, y, w, h) 안에 45° 줄을 spacing 간격으로. 간격이 좁을수록 짙어 보인다
function hatch(x, y, w, h, spacing, c, a) {
if (w <= 0.5 || a <= 0) {
return;
}
ctx.save();
roundRectPath(x, y, w, h, 3);
ctx.clip();
ctx.beginPath();
for (let s = x - h; s < x + w; s += Math.max(2, spacing)) {
ctx.moveTo(s, y + h);
ctx.lineTo(s + h, y);
}
ctx.lineWidth = 1.6;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
ctx.restore();
}
// 시간 축 — 위 띠는 월 이름, 아래 띠는 일(월 안의 날 숫자)·주(주차) 층. 층마다 불투명도를 따로 받아 겹쳐 흐려지며 바뀌게 한다.
// 세로 격자도 같은 불투명도로 행 영역(top~bottom)에 긋는다. xOf 는 일수 → 화면 x
function timeAxis(xOf, dayW, d0, d1, left, right, top, bottom, ly) {
ctx.save();
ctx.beginPath();
ctx.rect(left, 0, right - left, H);
ctx.clip();
ctx.fillStyle = rgba(PAPER_DIM, 1);
ctx.fillRect(left, 0, right - left, top - 4);
for (let d = Math.floor(d0); d <= Math.ceil(d1); d += 1) {
const x = xOf(d);
const cal = calendarOf(d);
if (ly.day > 0.01) {
ctx.fillStyle = rgba(GRID, 0.7 * ly.day);
ctx.fillRect(x, top, 1, bottom - top);
if (dayW >= 13) {
label(String(cal[1]), x + dayW / 2, top - 11, INK_SOFT, ly.day, 9, 600, 'center');
}
}
// 주 층 — 글자가 겹치지 않을 만큼(36px) 주를 건너뛰며 적는다
const weekStep = 7 * dayW >= 36 ? 1 : (14 * dayW >= 36 ? 2 : 4);
if (ly.week > 0.01 && d % 7 === 0) {
ctx.fillStyle = rgba(GRID, ly.week);
ctx.fillRect(x, top, 1, bottom - top);
if ((d / 7) % weekStep === 0) {
label(WEEK_FMT.replace('{n}', String(d / 7 + 1)), x + 3, top - 11, INK_SOFT, ly.week, 9, 600, 'left');
}
}
if (cal[1] === 1) {
ctx.fillStyle = rgba(INK_SOFT, 0.18 + 0.4 * ly.month);
ctx.fillRect(x, 4, 1, bottom - 4);
label(MONTH_FMT.replace('{n}', String(cal[0])), x + 4, 11, INK, 0.85, 10, 700, 'left');
// 월 층 — 그달 칸 가운데에 그달 일수(월 길이 표에서)를 적는다
const len = MONTH_DAYS[cal[0] - 1];
if (ly.month > 0.01 && len * dayW >= 30) {
label(SLACK_FMT.replace('{n}', String(len)), x + len * dayW / 2, top - 11, INK_SOFT, ly.month, 9, 700, 'center');
}
}
else if (d === Math.floor(d0)) {
// 왼쪽 끝 월 이름 — 바로 오른쪽에 새 달이 시작하면 겹치므로 적지 않는다
const toNext = MONTH_DAYS[cal[0] - 1] - cal[1] + 1;
if (toNext * dayW >= 44) {
label(MONTH_FMT.replace('{n}', String(cal[0])), Math.max(x, left) + 4, 11, INK, 0.85, 10, 700, 'left');
}
}
}
ctx.restore();
}
// 왼쪽 작업 이름 칸
function rowLabel(name, y, a, bold) {
label(name, 10, y, INK, a, 11, bold ? 800 : 600, 'left');
}
function rowStripe(y, h, i, a) {
if (i % 2 === 1) {
ctx.fillStyle = rgba(PAPER_DIM, 0.75 * a);
ctx.fillRect(0, y, W, h);
}
}
// 움직이는 행 빛 — 그 행 막대의 스프링 속도(k = 0~1)만큼 행 전체가 옅은 라벤더로 밝아진다. 막대가 서면 사라진다
function rowGlow(y, h, k) {
if (k <= 0.01) {
return;
}
ctx.fillStyle = rgba(LAVENDER, 0.42 * clamp(k, 0, 1));
ctx.fillRect(0, y, W, h);
}
// 상태 줄 — 바뀔 때만 글자를 고친다
let shownStatus = '';
function pushStatus() {
if (!statusEl || simStatus === shownStatus) {
return;
}
shownStatus = simStatus;
statusEl.textContent = simStatus;
}
// 숨김 표 — 같은 일정을 작업·시작·끝·여유 열로 읽게. 화면에는 안 보이고 화면 읽기 도구가 읽는다
const TABLE_HEAD = ['작업 이름', '시작일', '끝나는 날', '여유 일수'];
function fillTable(caption, rows) {
if (!tableEl) {
return;
}
const tb = tableEl;
while (tb.firstChild) {
tb.removeChild(tb.firstChild);
}
const cap = document.createElement('caption');
cap.textContent = caption;
tb.appendChild(cap);
const thead = document.createElement('thead');
const hr = document.createElement('tr');
TABLE_HEAD.forEach(function (h) {
const th = document.createElement('th');
th.scope = 'col';
th.textContent = h;
hr.appendChild(th);
});
thead.appendChild(hr);
tb.appendChild(thead);
const body = document.createElement('tbody');
rows.forEach(function (r) {
const tr = document.createElement('tr');
r.forEach(function (c, k) {
const cell = document.createElement(k === 0 ? 'th' : 'td');
if (k === 0) {
cell.scope = 'row';
}
cell.textContent = c;
tr.appendChild(cell);
});
body.appendChild(tr);
});
tb.appendChild(body);
}
// 08 — 묶음 접기. 상위 막대 구간 = 하위 작업의 가장 이른 시작 ~ 가장 늦은 끝. 접힘 정도 c(0 펼침 → 1 접힘)로 하위 막대의 y·높이를 상위 막대 쪽으로 보간하고, 아래 행은 빈 높이만큼 스프링으로 올라온다
const LABEL_W = 96;
const PLOT_X = LABEL_W + 6;
const DAY_WIDTH = 16;
const ROWS_TOP = 44;
const ROW_H = 17;
const BAR_H = 10;
const GROUP_BAR_H = 12;
// 접힘·행 이동 스프링
const COLLAPSE_STIFFNESS = 150;
const COLLAPSE_DAMPING = 15;
const ROW_STIFFNESS = 190;
const ROW_DAMPING = 17;
// 묶음마다 접히는·펼쳐지는 시각
// 기획·개발 묶음만 접는다(검수 묶음은 펼친 채 — 위가 접히면 빈 높이만큼 통째로 올라오는 행들)
const COLLAPSE_AT_S = [0.65, 0.3, Infinity];
const EXPAND_AT_S = [4.9, 4.6, Infinity];
const LOOP_S = 8;
const TABLE_CAPTION = '작업 일정표 — 묶음과 하위 작업';
// 출시(마일스톤)는 빼고 아홉 작업을 세 묶음으로
const tasks08 = copyTasks(BASE_TASKS.slice(0, 9)).map(function (t) { return { name: t.name, dur: t.dur, deps: t.deps.filter(function (p) { return p < 9; }), load: t.load }; });
const groups08 = [
{ name: '기획 묶음', kids: [0, 1, 2] },
{ name: '개발 묶음', kids: [3, 4, 5] },
{ name: '검수 묶음', kids: [6, 7, 8] }
];
const sweep08 = sweepBoth(tasks08, sortByDeps(tasks08).order, null);
// 상위 구간 — 하위 시작 최솟값·끝 최댓값
function rollup(g) {
let s = Infinity;
let e = -Infinity;
g.kids.forEach(function (k) { s = Math.min(s, sweep08.es[k]); e = Math.max(e, sweep08.ef[k]); });
return [s, e];
}
const spans08 = groups08.map(rollup);
const rows08 = (function () {
const out = [];
groups08.forEach(function (g, gi) {
out.push({ kind: 0, group: gi, task: -1 });
g.kids.forEach(function (k) { out.push({ kind: 1, group: gi, task: k }); });
});
return out;
})();
const fold08 = groups08.map(function () { return makeSpring(0); });
const rowY08 = rows08.map(function (_, i) { return makeSpring(ROWS_TOP + i * ROW_H); });
// 목표 y — 접히는 묶음의 하위 행은 높이 0 으로 친다(목표 접힘 기준)
function layoutTargets() {
const out = [];
let y = ROWS_TOP;
rows08.forEach(function (r) {
out.push(y);
const h = r.kind === 0 ? ROW_H : ROW_H * (1 - fold08[r.group].to);
y += h;
});
return out;
}
function reset() {
fold08.forEach(function (s) { snapSpring(s, 0); });
rows08.forEach(function (_, i) { snapSpring(rowY08[i], ROWS_TOP + i * ROW_H); });
}
function update(dt) {
fold08.forEach(function (s, gi) {
s.to = sceneT >= COLLAPSE_AT_S[gi] && sceneT < EXPAND_AT_S[gi] ? 1 : 0;
stepSpring(s, COLLAPSE_STIFFNESS, COLLAPSE_DAMPING, dt);
});
const ty = layoutTargets();
rowY08.forEach(function (s, i) { s.to = ty[i]; stepSpring(s, ROW_STIFFNESS, ROW_DAMPING, dt); });
}
function restPose() {
// 최종 배치 — 기획·개발 묶음이 접혀 상위 막대 둘 + 펼친 검수 묶음
fold08.forEach(function (s, gi) { snapSpring(s, isFinite(COLLAPSE_AT_S[gi]) ? 1 : 0); });
const ty = layoutTargets();
rowY08.forEach(function (s, i) { snapSpring(s, ty[i]); });
}
function tableRows() {
const out = [];
groups08.forEach(function (g, gi) {
const sp = spans08[gi];
let slack = Infinity;
g.kids.forEach(function (k) { slack = Math.min(slack, sweep08.slack[k]); });
out.push(tableRow(g.name, sp[0], sp[1] - sp[0], slack));
g.kids.forEach(function (k) { out.push(tableRow(tasks08[k].name, sweep08.es[k], tasks08[k].dur, sweep08.slack[k])); });
});
return out;
}
function dayX(d) { return PLOT_X + d * DAY_WIDTH; }
function draw() {
clearStage();
const ty = rowY08.map(function (s) { return s.x; });
const lastBottom = ty[ty.length - 1] + ROW_H * (1 - fold08[rows08[rows08.length - 1].group].x);
timeAxis(dayX, DAY_WIDTH, 0, 23, PLOT_X, W - 8, ROWS_TOP, Math.max(ROWS_TOP + ROW_H * 3, lastBottom), { day: 1, week: 0, month: 0 });
const groupCy = [];
rows08.forEach(function (r, i) { if (r.kind === 0) {
groupCy[r.group] = ty[i] + ROW_H / 2;
} });
// 묶음 머리 행 — 바탕 띠 + 접힘 표시 삼각형 + 상위 구간 막대
rows08.forEach(function (r, i) {
if (r.kind !== 0) {
return;
}
const c = clamp(fold08[r.group].x, 0, 1.15);
const cy = ty[i] + ROW_H / 2;
ctx.fillStyle = rgba(PAPER_DIM, 1);
ctx.fillRect(0, ty[i], W, ROW_H);
ctx.save();
ctx.translate(14, cy);
ctx.rotate(-c * Math.PI / 2);
ctx.beginPath();
ctx.moveTo(-4, -2.5);
ctx.lineTo(4, -2.5);
ctx.lineTo(0, 3.5);
ctx.closePath();
ctx.fillStyle = rgba(INK, 1);
ctx.fill();
ctx.restore();
label(groups08[r.group].name, 24, cy, INK, 1, 11, 800, 'left');
const sp = spans08[r.group];
roundRectPath(dayX(sp[0]), cy - GROUP_BAR_H / 2, (sp[1] - sp[0]) * DAY_WIDTH, GROUP_BAR_H, 4);
ctx.fillStyle = rgba(CREAM, 1);
ctx.fill();
ctx.lineWidth = 1.8;
ctx.strokeStyle = rgba(INK, 0.85);
ctx.stroke();
label(SLACK_FMT.replace('{n}', String(sp[1] - sp[0])), dayX(sp[1]) + 5, cy, INK, 1, 10, 800, 'left');
});
// 하위 행 — 접힘 정도만큼 막대가 상위 막대 쪽으로 모이고, 이름은 먼저 흐려진다
rows08.forEach(function (r, i) {
if (r.kind !== 1) {
return;
}
const c = clamp(fold08[r.group].x, 0, 1.15);
const t = tasks08[r.task];
const own = ty[i] + ROW_H / 2;
const cy = lerp(own, groupCy[r.group], c);
const h = lerp(BAR_H, GROUP_BAR_H - 4, clamp(c, 0, 1));
const la = 1 - smooth(c / 0.45);
if (la > 0.01) {
if (r.task % 2 === 1) {
ctx.fillStyle = rgba(PAPER_DIM, 0.5 * la);
ctx.fillRect(0, ty[i], W, ROW_H * (1 - clamp(c, 0, 1)));
}
label(t.name, 24, own, INK, la, 11, 600, 'left');
}
bar(dayX(sweep08.es[r.task]), cy - h / 2, t.dur * DAY_WIDTH, h, BAR_COLORS[r.task % BAR_COLORS.length], 1, 1.2 * (1 - 0.6 * clamp(c, 0, 1)));
});
}
// 한 프레임에 넘기는 시간 상한(초) — 탭을 오래 숨겼다 돌아온 첫 프레임에도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 손대면 시연 표시를 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
// 리스너는 한 묶음으로 달고 정리할 때 한 번에 뗀다
const off = new AbortController();
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
if (statusEl) {
statusEl.setAttribute('aria-live', 'polite');
}
}
// 장면 시계를 dt 만큼 — 한 바퀴(LOOP_S)를 넘으면 처음 배치로 되돌려 다시 시연한다
function advance(dt) {
sceneT += dt;
if (sceneT >= LOOP_S) {
sceneT -= LOOP_S;
reset();
}
update(dt);
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
advance(dt);
}
draw();
pushStatus();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 시계 다시 맞추기 — 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 동작 줄이기 — 루프를 돌리지 않고 시연이 끝난 최종 배치 한 장만 그린다
function showRest() {
stopLoop();
sceneT = 0;
reset();
restPose();
draw();
pushStatus();
}
function play() {
sceneT = 0;
reset();
draw();
pushStatus();
if (!paused) {
startLoop();
}
}
pauseBtn.addEventListener('click', function () {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}, { signal: off.signal });
// 탭이 숨으면 루프를 멈추고, 돌아오면 시계를 다시 맞춰 시작한다 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 페이지를 연 채 바뀌면 — 켜지면 최종 배치로 멈추고, 꺼지면 시연을 처음부터
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
play();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
draw();
}, { signal: off.signal });
fitCanvas();
fillTable(TABLE_CAPTION, tableRows());
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// GroupRollupCollapse - 같은 항목 vanilla 판 index.html 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./GroupRollupCollapse.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function GroupRollupCollapse() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
type Pt = [number, number];
// 판 좌표 — 캔버스 안은 늘 480×300. 화면에 보이는 크기와 상관없이 계산은 이 좌표로 한다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
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); }
// 따라가기 비율 — 1 − e^(−rate·dt). 프레임 시간이 달라도 1초에 같은 만큼 다가간다
function follow(rate: number, dt: number): number { return 1 - Math.exp(-rate * dt); }
// 시드 고정 난수 — 같은 시드면 같은 일정이 다시 나온다 (32비트 정수 섞기)
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// ── 감쇠 스프링 ──────────────────────────────────────────────
// 가속 = 단단함 × (목표 − 위치) − 감쇠 × 속도. 속도를 먼저 고친 뒤 위치를 옮기는 적분을 1/240초 잔걸음으로 나눠 돌린다
type Spring = { x: number; v: number; to: number };
function makeSpring(x: number): Spring { return { x: x, v: 0, to: x }; }
const SPRING_SUBSTEP = 1 / 240;
function stepSpring(s: Spring, stiffness: number, damping: number, dt: number): void {
let left = dt;
while (left > 1e-9) {
const h = Math.min(SPRING_SUBSTEP, left);
s.v += (stiffness * (s.to - s.x) - damping * s.v) * h;
s.x += s.v * h;
left -= h;
}
}
function snapSpring(s: Spring, x: number): void { s.x = x; s.v = 0; s.to = x; }
// ── 날짜 = 정수 일수 ─────────────────────────────────────────
// 날짜 객체를 쓰지 않는다. 기준일을 0 으로 세는 정수 일수(day index)만 다루고, 화면의 월·일 숫자는 아래 월 길이 표로 산수해 만든다
const MONTH_DAYS = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
const YEAR_DAYS = 365;
// 기준일(일수 0) = 한 해의 70번째 날 → 표로 세면 3월 11일
const DAY_ZERO_OF_YEAR = 69;
function calendarOf(day: number): [number, number] {
let r = ((day + DAY_ZERO_OF_YEAR) % YEAR_DAYS + YEAR_DAYS) % YEAR_DAYS;
let m = 0;
while (r >= MONTH_DAYS[m]) { r -= MONTH_DAYS[m]; m += 1; }
return [m + 1, r + 1];
}
const DAY_FMT = '{n}일차';
const WEEK_FMT = '{n}주차';
const MONTH_FMT = '{n}월';
const SLACK_FMT = '{n}일';
function dayText(day: number): string { return DAY_FMT.replace('{n}', String(day + 1)); }
// 하루 차 계산의 함정 — 시각(시간 단위)끼리 빼서 24 로 나누면, 시계가 한 시간 당겨진 날이 끼었을 때 정수가 아니다(23시간짜리 하루).
// 그래서 바깥에서 들어온 시각은 반올림으로 정수 일수에 붙이고, 그다음부터는 정수끼리만 더하고 뺀다
const HOURS_PER_DAY = 24;
const CLOCK_SHIFT_HOURS = 1;
function hoursToDayIndex(hours: number): number { return Math.round(hours / HOURS_PER_DAY); }
// ── 작업과 의존 ──────────────────────────────────────────────
// dur 는 일수(0 이면 마일스톤), deps 는 끝나야 시작할 수 있는 앞 작업 번호, load 는 그 작업에 붙는 사람 수
type Task = { name: string; dur: number; deps: number[]; load: number };
const BASE_TASKS: Task[] = [
{ name: '요구 정리', dur: 3, deps: [], load: 2 },
{ name: '디자인 시안', dur: 4, deps: [0], load: 1 },
{ name: '데이터 설계', dur: 3, deps: [0], load: 1 },
{ name: '화면 개발', dur: 5, deps: [1], load: 2 },
{ name: '서버 개발', dur: 7, deps: [2], load: 1 },
{ name: '기능 연동', dur: 3, deps: [3, 4], load: 2 },
{ name: '문서 작성', dur: 3, deps: [1], load: 1 },
{ name: '통합 검수', dur: 3, deps: [5], load: 2 },
{ name: '오류 수정', dur: 2, deps: [7], load: 1 },
{ name: '출시', dur: 0, deps: [8, 6], load: 0 }
];
function copyTasks(ts: Task[]): Task[] { return ts.map(function (t: Task): Task { return { name: t.name, dur: t.dur, deps: t.deps.slice(), load: t.load }; }); }
function successorsOf(tasks: Task[]): number[][] {
const out: number[][] = tasks.map(function (): number[] { return []; });
tasks.forEach(function (t: Task, i: number): void { t.deps.forEach(function (p: number): void { out[p].push(i); }); });
return out;
}
// 선후 순서로 줄 세우기 — 앞 작업이 하나도 안 남은(들어오는 화살표 0) 작업부터 꺼내고, 꺼낼 때마다 뒤 작업의 남은 수를 하나 줄인다.
// 꺼낼 것이 사라졌는데 남은 작업이 있으면 그 작업들은 서로를 기다리는 고리(순환 의존)에 걸렸거나 그 뒤에 매달린 것이다 → stuck
type Order = { order: number[]; stuck: number[] };
function sortByDeps(tasks: Task[]): Order {
const succ = successorsOf(tasks);
const waiting = tasks.map(function (t: Task): number { return t.deps.length; });
const ready: number[] = [];
waiting.forEach(function (w: number, i: number): void { if (w === 0) { ready.push(i); } });
const order: number[] = [];
while (ready.length) {
ready.sort(function (a: number, b: number): number { return a - b; });
const i = ready.shift() as number;
order.push(i);
succ[i].forEach(function (j: number): void { waiting[j] -= 1; if (waiting[j] === 0) { ready.push(j); } });
}
const stuck: number[] = [];
waiting.forEach(function (w: number, i: number): void { if (w > 0) { stuck.push(i); } });
return { order: order, stuck: stuck };
}
// 앞뒤 두 번 훑기 — 앞에서: 가장 이른 시작 = 앞 작업 끝 중 가장 늦은 날(과 정해 둔 시작일 중 늦은 쪽).
// 뒤에서: 가장 늦은 끝 = 뒤 작업의 가장 늦은 시작 중 가장 이른 날(뒤 작업이 없으면 전체 끝). 여유 = 가장 늦은 시작 − 가장 이른 시작
type Layers = { day: number; week: number; month: number };
type Sweep = { es: number[]; ef: number[]; ls: number[]; lf: number[]; slack: number[]; end: number };
function sweepBoth(tasks: Task[], order: number[], minStart: number[] | null): Sweep {
const n = tasks.length;
const es: number[] = new Array(n).fill(0);
const ef: number[] = new Array(n).fill(0);
for (const i of order) {
let s = minStart ? minStart[i] : 0;
for (const p of tasks[i].deps) { s = Math.max(s, ef[p]); }
es[i] = s;
ef[i] = s + tasks[i].dur;
}
let end = 0;
for (const i of order) { end = Math.max(end, ef[i]); }
const succ = successorsOf(tasks);
const ls: number[] = new Array(n).fill(0);
const lf: number[] = new Array(n).fill(0);
for (let k = order.length - 1; k >= 0; k -= 1) {
const i = order[k];
let f = end;
for (const j of succ[i]) { f = Math.min(f, ls[j]); }
lf[i] = f;
ls[i] = f - tasks[i].dur;
}
const slack = es.map(function (s: number, i: number): number { return ls[i] - s; });
return { es: es, ef: ef, ls: ls, lf: lf, slack: slack, end: end };
}
// 밀기 전파 — 밀린 작업에서 가까운 뒤 작업부터 너비 우선으로 훑으며, 새 시작 = 앞 작업 끝 중 가장 늦은 날(지금보다 늦을 때만).
// depth 는 밀린 작업에서 몇 단계 뒤인지(-1 이면 여유가 흡수해 안 밀림). 시작이 바뀐 작업만 다시 큐에 넣는다
type Push = { starts: number[]; depth: number[] };
function pushFrom(tasks: Task[], starts: number[], from: number): Push {
const succ = successorsOf(tasks);
const out = starts.slice();
const depth: number[] = tasks.map(function (): number { return -1; });
depth[from] = 0;
const queue: number[] = [from];
let head = 0;
// 고리가 있어도 끝나도록 꺼내는 횟수에 상한을 둔다
const limit = tasks.length * tasks.length + 1;
while (head < queue.length && head < limit) {
const i = queue[head];
head += 1;
for (const j of succ[i]) {
let need = 0;
for (const p of tasks[j].deps) { need = Math.max(need, out[p] + tasks[p].dur); }
if (need > out[j]) {
out[j] = need;
depth[j] = Math.max(depth[j], depth[i] + 1);
queue.push(j);
}
}
}
return { starts: out, depth: depth };
}
// 날마다 걸린 일감 — 작업이 덮는 날(시작 ≤ 날 < 시작 + 일수)마다 사람 수를 더한다
function dailyLoad(tasks: Task[], starts: number[], days: number): number[] {
const load: number[] = new Array(days).fill(0);
tasks.forEach(function (t: Task, i: number): void {
for (let d = starts[i]; d < starts[i] + t.dur; d += 1) { if (d >= 0 && d < days) { load[d] += t.load; } }
});
return load;
}
// ── 직각 경로 ───────────────────────────────────────────────
// 앞 끝(ex, ey)에서 오른쪽으로 ROUTE_STUB 나간 뒤 아래로 꺾는다. 뒤 시작(sx)이 그 꺾인 자리 + ROUTE_STUB 보다 오른쪽이면 곧장 내려가 오른쪽으로 들어가고,
// 아니면 앞 행 바로 아래 통로(gapY)까지 내려가 왼쪽으로 돌아간 다음 뒤 시작보다 ROUTE_STUB 앞에서 내려가 오른쪽으로 들어간다
const ROUTE_STUB = 7;
function elbowRoute(ex: number, ey: number, sx: number, sy: number, gapY: number): number[] {
const x1 = ex + ROUTE_STUB;
if (sx - ROUTE_STUB >= x1) { return [ex, ey, x1, ey, x1, sy, sx, sy]; }
const x2 = sx - ROUTE_STUB;
return [ex, ey, x1, ey, x1, gapY, x2, gapY, x2, sy, sx, sy];
}
function routeLength(pts: number[]): number {
let s = 0;
for (let i = 2; i < pts.length; i += 2) { s += Math.hypot(pts[i] - pts[i - 2], pts[i + 1] - pts[i - 1]); }
return s;
}
// 경로 위 거리 dist 자리의 점과 그 마디의 방향
function pointOnRoute(pts: number[], dist: number): [number, number, number, number] {
let left = Math.max(0, dist);
for (let i = 2; i < pts.length; i += 2) {
const dx = pts[i] - pts[i - 2];
const dy = pts[i + 1] - pts[i - 1];
const len = Math.hypot(dx, dy);
if (left <= len || i === pts.length - 2) {
const k = len > 0 ? Math.min(1, left / len) : 0;
return [pts[i - 2] + dx * k, pts[i - 1] + dy * k, len > 0 ? dx / len : 1, len > 0 ? dy / len : 0];
}
left -= len;
}
return [pts[0], pts[1], 1, 0];
}
// 경로를 앞에서부터 dist 만큼만 자른 점 목록 — 화살표가 그려져 나가는 장면에 쓴다
function cutRoute(pts: number[], dist: number): number[] {
const out: number[] = [pts[0], pts[1]];
let left = Math.max(0, dist);
for (let i = 2; i < pts.length; i += 2) {
const len = Math.hypot(pts[i] - pts[i - 2], pts[i + 1] - pts[i - 1]);
if (left >= len) { out.push(pts[i], pts[i + 1]); left -= len; continue; }
const k = len > 0 ? left / len : 0;
out.push(lerp(pts[i - 2], pts[i], k), lerp(pts[i - 1], pts[i + 1], k));
break;
}
return out;
}
// 장면 시계(초)와 상태 줄 글자 — 장면 계산이 고치고, 화면 쪽이 읽어 그린다
let sceneT = 0;
let simStatus = '';
// 파스텔 팔레트 — 크림 바탕 위 라벤더·민트·버터·산호 막대. 가장 긴 경로·오늘 선·글자는 진한 쪽으로 명도 차를 둔다
const PAPER: RGB = [251, 247, 238];
const PAPER_DIM: RGB = [243, 237, 224];
const GRID: RGB = [226, 218, 204];
const INK: RGB = [44, 38, 58];
const INK_SOFT: RGB = [112, 104, 126];
const CREAM: RGB = [255, 251, 242];
const LAVENDER: RGB = [190, 174, 236];
const MINT: RGB = [158, 214, 190];
const BUTTER: RGB = [244, 222, 146];
const CORAL: RGB = [244, 170, 150];
const SKY: RGB = [160, 204, 236];
const PINK: RGB = [242, 182, 206];
const PATH_DEEP: RGB = [86, 56, 168];
const TODAY_DEEP: RGB = [190, 72, 60];
const BAR_COLORS: RGB[] = [LAVENDER, MINT, BUTTER, CORAL, SKY, PINK];
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// 표 한 줄 — 작업·시작·끝·여유. 끝은 그 작업이 덮는 마지막 날(일수 0 짜리는 시작과 같은 날)
function tableRow(name: string, start: number, dur: number, slack: number): string[] {
return [name, dayText(start), dayText(start + Math.max(0, dur - 1)), SLACK_FMT.replace('{n}', String(slack))];
}
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const tableEl = root.querySelector('[data-role="table"]') as HTMLTableElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function clearStage(): void {
ctx.fillStyle = rgba(PAPER, 1);
ctx.fillRect(0, 0, W, H);
}
function label(s: string, x: number, y: number, c: RGB, a: number, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
function roundRectPath(x: number, y: number, w: number, h: number, r: number): void {
const rr = Math.max(0, Math.min(r, w / 2, h / 2));
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.lineTo(x + w - rr, y);
ctx.arcTo(x + w, y, x + w, y + rr, rr);
ctx.lineTo(x + w, y + h - rr);
ctx.arcTo(x + w, y + h, x + w - rr, y + h, rr);
ctx.lineTo(x + rr, y + h);
ctx.arcTo(x, y + h, x, y + h - rr, rr);
ctx.lineTo(x, y + rr);
ctx.arcTo(x, y, x + rr, y, rr);
ctx.closePath();
}
// 막대 하나 — 둥근 사각형 채움 + 진한 테두리. 폭이 0 이하이면 그리지 않는다
function bar(x: number, y: number, w: number, h: number, fill: RGB, a: number, line: number): void {
if (w <= 0.2 || a <= 0) { return; }
roundRectPath(x, y, w, h, 4);
ctx.fillStyle = rgba(fill, a);
ctx.fill();
if (line > 0) {
ctx.lineWidth = line;
ctx.strokeStyle = rgba(INK, 0.55 * a);
ctx.stroke();
}
}
// 마일스톤 마름모
function diamond(cx: number, cy: number, r: number, fill: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.moveTo(cx, cy - r);
ctx.lineTo(cx + r, cy);
ctx.lineTo(cx, cy + r);
ctx.lineTo(cx - r, cy);
ctx.closePath();
ctx.fillStyle = rgba(fill, a);
ctx.fill();
ctx.lineWidth = 1.5;
ctx.strokeStyle = rgba(INK, 0.8 * a);
ctx.stroke();
}
function polyline(pts: number[], c: RGB, a: number, width: number): void {
if (pts.length < 4) { return; }
ctx.beginPath();
ctx.moveTo(pts[0], pts[1]);
for (let i = 2; i < pts.length; i += 2) { ctx.lineTo(pts[i], pts[i + 1]); }
ctx.lineWidth = width;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function arrowHead(x: number, y: number, dx: number, dy: number, size: number, c: RGB, a: number): void {
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x - dx * size - dy * size * 0.6, y - dy * size + dx * size * 0.6);
ctx.lineTo(x - dx * size + dy * size * 0.6, y - dy * size - dx * size * 0.6);
ctx.closePath();
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
// 빗금 — (x, y, w, h) 안에 45° 줄을 spacing 간격으로. 간격이 좁을수록 짙어 보인다
function hatch(x: number, y: number, w: number, h: number, spacing: number, c: RGB, a: number): void {
if (w <= 0.5 || a <= 0) { return; }
ctx.save();
roundRectPath(x, y, w, h, 3);
ctx.clip();
ctx.beginPath();
for (let s = x - h; s < x + w; s += Math.max(2, spacing)) {
ctx.moveTo(s, y + h);
ctx.lineTo(s + h, y);
}
ctx.lineWidth = 1.6;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
ctx.restore();
}
// 시간 축 — 위 띠는 월 이름, 아래 띠는 일(월 안의 날 숫자)·주(주차) 층. 층마다 불투명도를 따로 받아 겹쳐 흐려지며 바뀌게 한다.
// 세로 격자도 같은 불투명도로 행 영역(top~bottom)에 긋는다. xOf 는 일수 → 화면 x
function timeAxis(xOf: (d: number) => number, dayW: number, d0: number, d1: number, left: number, right: number, top: number, bottom: number, ly: Layers): void {
ctx.save();
ctx.beginPath();
ctx.rect(left, 0, right - left, H);
ctx.clip();
ctx.fillStyle = rgba(PAPER_DIM, 1);
ctx.fillRect(left, 0, right - left, top - 4);
for (let d = Math.floor(d0); d <= Math.ceil(d1); d += 1) {
const x = xOf(d);
const cal = calendarOf(d);
if (ly.day > 0.01) {
ctx.fillStyle = rgba(GRID, 0.7 * ly.day);
ctx.fillRect(x, top, 1, bottom - top);
if (dayW >= 13) { label(String(cal[1]), x + dayW / 2, top - 11, INK_SOFT, ly.day, 9, 600, 'center'); }
}
// 주 층 — 글자가 겹치지 않을 만큼(36px) 주를 건너뛰며 적는다
const weekStep = 7 * dayW >= 36 ? 1 : (14 * dayW >= 36 ? 2 : 4);
if (ly.week > 0.01 && d % 7 === 0) {
ctx.fillStyle = rgba(GRID, ly.week);
ctx.fillRect(x, top, 1, bottom - top);
if ((d / 7) % weekStep === 0) { label(WEEK_FMT.replace('{n}', String(d / 7 + 1)), x + 3, top - 11, INK_SOFT, ly.week, 9, 600, 'left'); }
}
if (cal[1] === 1) {
ctx.fillStyle = rgba(INK_SOFT, 0.18 + 0.4 * ly.month);
ctx.fillRect(x, 4, 1, bottom - 4);
label(MONTH_FMT.replace('{n}', String(cal[0])), x + 4, 11, INK, 0.85, 10, 700, 'left');
// 월 층 — 그달 칸 가운데에 그달 일수(월 길이 표에서)를 적는다
const len = MONTH_DAYS[cal[0] - 1];
if (ly.month > 0.01 && len * dayW >= 30) { label(SLACK_FMT.replace('{n}', String(len)), x + len * dayW / 2, top - 11, INK_SOFT, ly.month, 9, 700, 'center'); }
} else if (d === Math.floor(d0)) {
// 왼쪽 끝 월 이름 — 바로 오른쪽에 새 달이 시작하면 겹치므로 적지 않는다
const toNext = MONTH_DAYS[cal[0] - 1] - cal[1] + 1;
if (toNext * dayW >= 44) { label(MONTH_FMT.replace('{n}', String(cal[0])), Math.max(x, left) + 4, 11, INK, 0.85, 10, 700, 'left'); }
}
}
ctx.restore();
}
// 왼쪽 작업 이름 칸
function rowLabel(name: string, y: number, a: number, bold: boolean): void {
label(name, 10, y, INK, a, 11, bold ? 800 : 600, 'left');
}
function rowStripe(y: number, h: number, i: number, a: number): void {
if (i % 2 === 1) {
ctx.fillStyle = rgba(PAPER_DIM, 0.75 * a);
ctx.fillRect(0, y, W, h);
}
}
// 움직이는 행 빛 — 그 행 막대의 스프링 속도(k = 0~1)만큼 행 전체가 옅은 라벤더로 밝아진다. 막대가 서면 사라진다
function rowGlow(y: number, h: number, k: number): void {
if (k <= 0.01) { return; }
ctx.fillStyle = rgba(LAVENDER, 0.42 * clamp(k, 0, 1));
ctx.fillRect(0, y, W, h);
}
// 상태 줄 — 바뀔 때만 글자를 고친다
let shownStatus = '';
function pushStatus(): void {
if (!statusEl || simStatus === shownStatus) { return; }
shownStatus = simStatus;
statusEl.textContent = simStatus;
}
// 숨김 표 — 같은 일정을 작업·시작·끝·여유 열로 읽게. 화면에는 안 보이고 화면 읽기 도구가 읽는다
const TABLE_HEAD = ['작업 이름', '시작일', '끝나는 날', '여유 일수'];
function fillTable(caption: string, rows: string[][]): void {
if (!tableEl) { return; }
const tb = tableEl;
while (tb.firstChild) { tb.removeChild(tb.firstChild); }
const cap = document.createElement('caption');
cap.textContent = caption;
tb.appendChild(cap);
const thead = document.createElement('thead');
const hr = document.createElement('tr');
TABLE_HEAD.forEach(function (h: string): void {
const th = document.createElement('th');
th.scope = 'col';
th.textContent = h;
hr.appendChild(th);
});
thead.appendChild(hr);
tb.appendChild(thead);
const body = document.createElement('tbody');
rows.forEach(function (r: string[]): void {
const tr = document.createElement('tr');
r.forEach(function (c: string, k: number): void {
const cell = document.createElement(k === 0 ? 'th' : 'td');
if (k === 0) { (cell as HTMLTableCellElement).scope = 'row'; }
cell.textContent = c;
tr.appendChild(cell);
});
body.appendChild(tr);
});
tb.appendChild(body);
}
// 08 — 묶음 접기. 상위 막대 구간 = 하위 작업의 가장 이른 시작 ~ 가장 늦은 끝. 접힘 정도 c(0 펼침 → 1 접힘)로 하위 막대의 y·높이를 상위 막대 쪽으로 보간하고, 아래 행은 빈 높이만큼 스프링으로 올라온다
const LABEL_W = 96;
const PLOT_X = LABEL_W + 6;
const DAY_WIDTH = 16;
const ROWS_TOP = 44;
const ROW_H = 17;
const BAR_H = 10;
const GROUP_BAR_H = 12;
// 접힘·행 이동 스프링
const COLLAPSE_STIFFNESS = 150;
const COLLAPSE_DAMPING = 15;
const ROW_STIFFNESS = 190;
const ROW_DAMPING = 17;
// 묶음마다 접히는·펼쳐지는 시각
// 기획·개발 묶음만 접는다(검수 묶음은 펼친 채 — 위가 접히면 빈 높이만큼 통째로 올라오는 행들)
const COLLAPSE_AT_S = [0.65, 0.3, Infinity];
const EXPAND_AT_S = [4.9, 4.6, Infinity];
const LOOP_S = 8;
const TABLE_CAPTION = '작업 일정표 — 묶음과 하위 작업';
type Group = { name: string; kids: number[] };
// 출시(마일스톤)는 빼고 아홉 작업을 세 묶음으로
const tasks08: Task[] = copyTasks(BASE_TASKS.slice(0, 9)).map(function (t: Task): Task { return { name: t.name, dur: t.dur, deps: t.deps.filter(function (p: number): boolean { return p < 9; }), load: t.load }; });
const groups08: Group[] = [
{ name: '기획 묶음', kids: [0, 1, 2] },
{ name: '개발 묶음', kids: [3, 4, 5] },
{ name: '검수 묶음', kids: [6, 7, 8] }
];
const sweep08 = sweepBoth(tasks08, sortByDeps(tasks08).order, null);
// 상위 구간 — 하위 시작 최솟값·끝 최댓값
function rollup(g: Group): [number, number] {
let s = Infinity;
let e = -Infinity;
g.kids.forEach(function (k: number): void { s = Math.min(s, sweep08.es[k]); e = Math.max(e, sweep08.ef[k]); });
return [s, e];
}
const spans08: [number, number][] = groups08.map(rollup);
// 행 목록 — 묶음 머리 행 다음에 하위 행. kind 0 = 묶음, 1 = 하위
type Row = { kind: number; group: number; task: number };
const rows08: Row[] = (function (): Row[] {
const out: Row[] = [];
groups08.forEach(function (g: Group, gi: number): void {
out.push({ kind: 0, group: gi, task: -1 });
g.kids.forEach(function (k: number): void { out.push({ kind: 1, group: gi, task: k }); });
});
return out;
})();
const fold08: Spring[] = groups08.map(function (): Spring { return makeSpring(0); });
const rowY08: Spring[] = rows08.map(function (_: Row, i: number): Spring { return makeSpring(ROWS_TOP + i * ROW_H); });
// 목표 y — 접히는 묶음의 하위 행은 높이 0 으로 친다(목표 접힘 기준)
function layoutTargets(): number[] {
const out: number[] = [];
let y = ROWS_TOP;
rows08.forEach(function (r: Row): void {
out.push(y);
const h = r.kind === 0 ? ROW_H : ROW_H * (1 - fold08[r.group].to);
y += h;
});
return out;
}
function reset(): void {
fold08.forEach(function (s: Spring): void { snapSpring(s, 0); });
rows08.forEach(function (_: Row, i: number): void { snapSpring(rowY08[i], ROWS_TOP + i * ROW_H); });
}
function update(dt: number): void {
fold08.forEach(function (s: Spring, gi: number): void {
s.to = sceneT >= COLLAPSE_AT_S[gi] && sceneT < EXPAND_AT_S[gi] ? 1 : 0;
stepSpring(s, COLLAPSE_STIFFNESS, COLLAPSE_DAMPING, dt);
});
const ty = layoutTargets();
rowY08.forEach(function (s: Spring, i: number): void { s.to = ty[i]; stepSpring(s, ROW_STIFFNESS, ROW_DAMPING, dt); });
}
function restPose(): void {
// 최종 배치 — 기획·개발 묶음이 접혀 상위 막대 둘 + 펼친 검수 묶음
fold08.forEach(function (s: Spring, gi: number): void { snapSpring(s, isFinite(COLLAPSE_AT_S[gi]) ? 1 : 0); });
const ty = layoutTargets();
rowY08.forEach(function (s: Spring, i: number): void { snapSpring(s, ty[i]); });
}
function tableRows(): string[][] {
const out: string[][] = [];
groups08.forEach(function (g: Group, gi: number): void {
const sp = spans08[gi];
let slack = Infinity;
g.kids.forEach(function (k: number): void { slack = Math.min(slack, sweep08.slack[k]); });
out.push(tableRow(g.name, sp[0], sp[1] - sp[0], slack));
g.kids.forEach(function (k: number): void { out.push(tableRow(tasks08[k].name, sweep08.es[k], tasks08[k].dur, sweep08.slack[k])); });
});
return out;
}
function dayX(d: number): number { return PLOT_X + d * DAY_WIDTH; }
function draw(): void {
clearStage();
const ty = rowY08.map(function (s: Spring): number { return s.x; });
const lastBottom = ty[ty.length - 1] + ROW_H * (1 - fold08[rows08[rows08.length - 1].group].x);
timeAxis(dayX, DAY_WIDTH, 0, 23, PLOT_X, W - 8, ROWS_TOP, Math.max(ROWS_TOP + ROW_H * 3, lastBottom), { day: 1, week: 0, month: 0 });
const groupCy: number[] = [];
rows08.forEach(function (r: Row, i: number): void { if (r.kind === 0) { groupCy[r.group] = ty[i] + ROW_H / 2; } });
// 묶음 머리 행 — 바탕 띠 + 접힘 표시 삼각형 + 상위 구간 막대
rows08.forEach(function (r: Row, i: number): void {
if (r.kind !== 0) { return; }
const c = clamp(fold08[r.group].x, 0, 1.15);
const cy = ty[i] + ROW_H / 2;
ctx.fillStyle = rgba(PAPER_DIM, 1);
ctx.fillRect(0, ty[i], W, ROW_H);
ctx.save();
ctx.translate(14, cy);
ctx.rotate(-c * Math.PI / 2);
ctx.beginPath();
ctx.moveTo(-4, -2.5);
ctx.lineTo(4, -2.5);
ctx.lineTo(0, 3.5);
ctx.closePath();
ctx.fillStyle = rgba(INK, 1);
ctx.fill();
ctx.restore();
label(groups08[r.group].name, 24, cy, INK, 1, 11, 800, 'left');
const sp = spans08[r.group];
roundRectPath(dayX(sp[0]), cy - GROUP_BAR_H / 2, (sp[1] - sp[0]) * DAY_WIDTH, GROUP_BAR_H, 4);
ctx.fillStyle = rgba(CREAM, 1);
ctx.fill();
ctx.lineWidth = 1.8;
ctx.strokeStyle = rgba(INK, 0.85);
ctx.stroke();
label(SLACK_FMT.replace('{n}', String(sp[1] - sp[0])), dayX(sp[1]) + 5, cy, INK, 1, 10, 800, 'left');
});
// 하위 행 — 접힘 정도만큼 막대가 상위 막대 쪽으로 모이고, 이름은 먼저 흐려진다
rows08.forEach(function (r: Row, i: number): void {
if (r.kind !== 1) { return; }
const c = clamp(fold08[r.group].x, 0, 1.15);
const t = tasks08[r.task];
const own = ty[i] + ROW_H / 2;
const cy = lerp(own, groupCy[r.group], c);
const h = lerp(BAR_H, GROUP_BAR_H - 4, clamp(c, 0, 1));
const la = 1 - smooth(c / 0.45);
if (la > 0.01) {
if (r.task % 2 === 1) {
ctx.fillStyle = rgba(PAPER_DIM, 0.5 * la);
ctx.fillRect(0, ty[i], W, ROW_H * (1 - clamp(c, 0, 1)));
}
label(t.name, 24, own, INK, la, 11, 600, 'left');
}
bar(dayX(sweep08.es[r.task]), cy - h / 2, t.dur * DAY_WIDTH, h, BAR_COLORS[r.task % BAR_COLORS.length], 1, 1.2 * (1 - 0.6 * clamp(c, 0, 1)));
});
}
// 한 프레임에 넘기는 시간 상한(초) — 탭을 오래 숨겼다 돌아온 첫 프레임에도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 손대면 시연 표시를 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
// 리스너는 한 묶음으로 달고 정리할 때 한 번에 뗀다
const off = new AbortController();
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
if (statusEl) { statusEl.setAttribute('aria-live', 'polite'); }
}
// 장면 시계를 dt 만큼 — 한 바퀴(LOOP_S)를 넘으면 처음 배치로 되돌려 다시 시연한다
function advance(dt: number): void {
sceneT += dt;
if (sceneT >= LOOP_S) {
sceneT -= LOOP_S;
reset();
}
update(dt);
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) { advance(dt); }
draw();
pushStatus();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 시계 다시 맞추기 — 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 동작 줄이기 — 루프를 돌리지 않고 시연이 끝난 최종 배치 한 장만 그린다
function showRest(): void {
stopLoop();
sceneT = 0;
reset();
restPose();
draw();
pushStatus();
}
function play(): void {
sceneT = 0;
reset();
draw();
pushStatus();
if (!paused) { startLoop(); }
}
pauseBtn.addEventListener('click', function (): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}, { signal: off.signal });
// 탭이 숨으면 루프를 멈추고, 돌아오면 시계를 다시 맞춰 시작한다 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 페이지를 연 채 바뀌면 — 켜지면 최종 배치로 멈추고, 꺼지면 시연을 처음부터
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { play(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
draw();
}, { signal: off.signal });
fitCanvas();
fillTable(TABLE_CAPTION, tableRows());
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("cf") + " is-demo"} ref={rootRef}>
<canvas className={cx("cf__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label="묶음이 접힐 때 하위 막대가 상위 막대 하나로 모여들고 아래 행이 올라오는 일정표."></canvas>
<table className={cx("cf__sr")} data-role="table"></table>
<button className={cx("cf__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
</div>
);
}08 · 하위 작업이 상위 막대 하나로 모여드는 묶음 접기 — 측정 누적면적 26.79% · 움직인 프레임 14/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 목표 y — 접히는 묶음의 하위 행은 높이 0 으로 친다(목표 접힘 기준)
function layoutTargets() {
const out = [];
let y = ROWS_TOP;
rows08.forEach(function (r) {
out.push(y);
const h = r.kind === 0 ? ROW_H : ROW_H * (1 - fold08[r.group].to);
y += h;
});
return out;
}
function reset() {
fold08.forEach(function (s) { snapSpring(s, 0); });
rows08.forEach(function (_, i) { snapSpring(rowY08[i], ROWS_TOP + i * ROW_H); });
}
function update(dt) {
fold08.forEach(function (s, gi) {
s.to = sceneT >= COLLAPSE_AT_S[gi] && sceneT < EXPAND_AT_S[gi] ? 1 : 0;
stepSpring(s, COLLAPSE_STIFFNESS, COLLAPSE_DAMPING, dt);
});
const ty = layoutTargets();
rowY08.forEach(function (s, i) { s.to = ty[i]; stepSpring(s, ROW_STIFFNESS, ROW_DAMPING, dt); });
}
09오늘 선을 카메라가 따라가며 마일스톤에 닿는 스프린트 첫 화면
시드 481 로 만든 작업 240개(스프린트와 마일스톤 각 10개)를 오늘 선이 초당 3.2일로 지나가고, 카메라는 오늘 선과 지금 일하는 행을 목표로 비율 2.2 의 지연 추종으로 따라가(63% 따라가는 데 0.455초) 한 박자 늦게 화면을 흘린다. 시작한 뒤에야 드러나는 추가 일수(작업 75개, 합 147일)가 들어올 때마다 recompute 가 앞뒤 훑기를 다시 돌려 진한 테두리의 여유 0 경로가 옮겨 가고, 마일스톤 마름모에 닿으면 고리 두 겹이 초당 120px 로 퍼진다. 협업 도구 랜딩 첫 화면에서 일정이 저절로 굴러가는 모습을 영상 없이 보여 주는 장면이다.

<main class="stage">
<div class="cf is-demo" id="cf">
<canvas class="cf__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="오늘 선을 카메라가 늦게 따라가며 지난 작업이 채워지고 마일스톤에 닿으면 물결 고리가 퍼지는 긴 프로젝트 일정표."></canvas>
<table class="cf__sr" data-role="table"></table>
<button class="cf__pause" data-role="pause" type="button" aria-pressed="false">일시정지</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.cf {
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-cream;
}
.cf__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.cf__pause {
position: absolute;
right: $sp-3;
bottom: $sp-3;
padding: $sp-1 $sp-3;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.cf__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.cf__pause:hover {
transform: translateY(-2px);
}
.cf__pause:active {
transform: translateY(1px);
}
.cf__pause:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 일정에 무슨 일이 일어났는지 글자로. 시연 중에는 aria-live off, 사람이 처음 손대면 polite
.cf__status {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: calc(100% - 120px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
// 숨김 표 — 화면에서는 1px 로 접어 두고 화면 읽기 도구만 읽는다
.cf__sr {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
@media (max-width: 340px) {
.cf__pause,
.cf__status { font-size: 10px; padding: $sp-1 $sp-2; }
}
// 동작 줄이기 — 장면은 스크립트가 최종 배치 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.cf__pause { transition: none !important; }
.cf__pause:hover,
.cf__pause:active { transform: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cf {
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: #fff7e6;
}
.cf__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.cf__pause {
position: absolute;
right: 12px;
bottom: 12px;
padding: 4px 12px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.cf__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.cf__pause:hover {
transform: translateY(-2px);
}
.cf__pause:active {
transform: translateY(1px);
}
.cf__pause:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.cf__status {
position: absolute;
left: 12px;
bottom: 12px;
max-width: calc(100% - 120px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
.cf__sr {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
@media (max-width: 340px) {
.cf__pause,
.cf__status {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.cf__pause {
transition: none !important;
}
.cf__pause:hover,
.cf__pause:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("cf");
if (!root) return;
// 판 좌표 — 캔버스 안은 늘 480×300. 화면에 보이는 크기와 상관없이 계산은 이 좌표로 한다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
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); }
// 따라가기 비율 — 1 − e^(−rate·dt). 프레임 시간이 달라도 1초에 같은 만큼 다가간다
function follow(rate, dt) { return 1 - Math.exp(-rate * dt); }
// 시드 고정 난수 — 같은 시드면 같은 일정이 다시 나온다 (32비트 정수 섞기)
function makeRng(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function makeSpring(x) { return { x: x, v: 0, to: x }; }
const SPRING_SUBSTEP = 1 / 240;
function stepSpring(s, stiffness, damping, dt) {
let left = dt;
while (left > 1e-9) {
const h = Math.min(SPRING_SUBSTEP, left);
s.v += (stiffness * (s.to - s.x) - damping * s.v) * h;
s.x += s.v * h;
left -= h;
}
}
function snapSpring(s, x) { s.x = x; s.v = 0; s.to = x; }
// ── 날짜 = 정수 일수 ─────────────────────────────────────────
// 날짜 객체를 쓰지 않는다. 기준일을 0 으로 세는 정수 일수(day index)만 다루고, 화면의 월·일 숫자는 아래 월 길이 표로 산수해 만든다
const MONTH_DAYS = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
const YEAR_DAYS = 365;
// 기준일(일수 0) = 한 해의 70번째 날 → 표로 세면 3월 11일
const DAY_ZERO_OF_YEAR = 69;
function calendarOf(day) {
let r = ((day + DAY_ZERO_OF_YEAR) % YEAR_DAYS + YEAR_DAYS) % YEAR_DAYS;
let m = 0;
while (r >= MONTH_DAYS[m]) {
r -= MONTH_DAYS[m];
m += 1;
}
return [m + 1, r + 1];
}
const DAY_FMT = '{n}일차';
const WEEK_FMT = '{n}주차';
const MONTH_FMT = '{n}월';
const SLACK_FMT = '{n}일';
function dayText(day) { return DAY_FMT.replace('{n}', String(day + 1)); }
// 하루 차 계산의 함정 — 시각(시간 단위)끼리 빼서 24 로 나누면, 시계가 한 시간 당겨진 날이 끼었을 때 정수가 아니다(23시간짜리 하루).
// 그래서 바깥에서 들어온 시각은 반올림으로 정수 일수에 붙이고, 그다음부터는 정수끼리만 더하고 뺀다
const HOURS_PER_DAY = 24;
const CLOCK_SHIFT_HOURS = 1;
function hoursToDayIndex(hours) { return Math.round(hours / HOURS_PER_DAY); }
const BASE_TASKS = [
{ name: '요구 정리', dur: 3, deps: [], load: 2 },
{ name: '디자인 시안', dur: 4, deps: [0], load: 1 },
{ name: '데이터 설계', dur: 3, deps: [0], load: 1 },
{ name: '화면 개발', dur: 5, deps: [1], load: 2 },
{ name: '서버 개발', dur: 7, deps: [2], load: 1 },
{ name: '기능 연동', dur: 3, deps: [3, 4], load: 2 },
{ name: '문서 작성', dur: 3, deps: [1], load: 1 },
{ name: '통합 검수', dur: 3, deps: [5], load: 2 },
{ name: '오류 수정', dur: 2, deps: [7], load: 1 },
{ name: '출시', dur: 0, deps: [8, 6], load: 0 }
];
function copyTasks(ts) { return ts.map(function (t) { return { name: t.name, dur: t.dur, deps: t.deps.slice(), load: t.load }; }); }
function successorsOf(tasks) {
const out = tasks.map(function () { return []; });
tasks.forEach(function (t, i) { t.deps.forEach(function (p) { out[p].push(i); }); });
return out;
}
function sortByDeps(tasks) {
const succ = successorsOf(tasks);
const waiting = tasks.map(function (t) { return t.deps.length; });
const ready = [];
waiting.forEach(function (w, i) { if (w === 0) {
ready.push(i);
} });
const order = [];
while (ready.length) {
ready.sort(function (a, b) { return a - b; });
const i = ready.shift();
order.push(i);
succ[i].forEach(function (j) { waiting[j] -= 1; if (waiting[j] === 0) {
ready.push(j);
} });
}
const stuck = [];
waiting.forEach(function (w, i) { if (w > 0) {
stuck.push(i);
} });
return { order: order, stuck: stuck };
}
function sweepBoth(tasks, order, minStart) {
const n = tasks.length;
const es = new Array(n).fill(0);
const ef = new Array(n).fill(0);
for (const i of order) {
let s = minStart ? minStart[i] : 0;
for (const p of tasks[i].deps) {
s = Math.max(s, ef[p]);
}
es[i] = s;
ef[i] = s + tasks[i].dur;
}
let end = 0;
for (const i of order) {
end = Math.max(end, ef[i]);
}
const succ = successorsOf(tasks);
const ls = new Array(n).fill(0);
const lf = new Array(n).fill(0);
for (let k = order.length - 1; k >= 0; k -= 1) {
const i = order[k];
let f = end;
for (const j of succ[i]) {
f = Math.min(f, ls[j]);
}
lf[i] = f;
ls[i] = f - tasks[i].dur;
}
const slack = es.map(function (s, i) { return ls[i] - s; });
return { es: es, ef: ef, ls: ls, lf: lf, slack: slack, end: end };
}
function pushFrom(tasks, starts, from) {
const succ = successorsOf(tasks);
const out = starts.slice();
const depth = tasks.map(function () { return -1; });
depth[from] = 0;
const queue = [from];
let head = 0;
// 고리가 있어도 끝나도록 꺼내는 횟수에 상한을 둔다
const limit = tasks.length * tasks.length + 1;
while (head < queue.length && head < limit) {
const i = queue[head];
head += 1;
for (const j of succ[i]) {
let need = 0;
for (const p of tasks[j].deps) {
need = Math.max(need, out[p] + tasks[p].dur);
}
if (need > out[j]) {
out[j] = need;
depth[j] = Math.max(depth[j], depth[i] + 1);
queue.push(j);
}
}
}
return { starts: out, depth: depth };
}
// 날마다 걸린 일감 — 작업이 덮는 날(시작 ≤ 날 < 시작 + 일수)마다 사람 수를 더한다
function dailyLoad(tasks, starts, days) {
const load = new Array(days).fill(0);
tasks.forEach(function (t, i) {
for (let d = starts[i]; d < starts[i] + t.dur; d += 1) {
if (d >= 0 && d < days) {
load[d] += t.load;
}
}
});
return load;
}
// ── 직각 경로 ───────────────────────────────────────────────
// 앞 끝(ex, ey)에서 오른쪽으로 ROUTE_STUB 나간 뒤 아래로 꺾는다. 뒤 시작(sx)이 그 꺾인 자리 + ROUTE_STUB 보다 오른쪽이면 곧장 내려가 오른쪽으로 들어가고,
// 아니면 앞 행 바로 아래 통로(gapY)까지 내려가 왼쪽으로 돌아간 다음 뒤 시작보다 ROUTE_STUB 앞에서 내려가 오른쪽으로 들어간다
const ROUTE_STUB = 7;
function elbowRoute(ex, ey, sx, sy, gapY) {
const x1 = ex + ROUTE_STUB;
if (sx - ROUTE_STUB >= x1) {
return [ex, ey, x1, ey, x1, sy, sx, sy];
}
const x2 = sx - ROUTE_STUB;
return [ex, ey, x1, ey, x1, gapY, x2, gapY, x2, sy, sx, sy];
}
function routeLength(pts) {
let s = 0;
for (let i = 2; i < pts.length; i += 2) {
s += Math.hypot(pts[i] - pts[i - 2], pts[i + 1] - pts[i - 1]);
}
return s;
}
// 경로 위 거리 dist 자리의 점과 그 마디의 방향
function pointOnRoute(pts, dist) {
let left = Math.max(0, dist);
for (let i = 2; i < pts.length; i += 2) {
const dx = pts[i] - pts[i - 2];
const dy = pts[i + 1] - pts[i - 1];
const len = Math.hypot(dx, dy);
if (left <= len || i === pts.length - 2) {
const k = len > 0 ? Math.min(1, left / len) : 0;
return [pts[i - 2] + dx * k, pts[i - 1] + dy * k, len > 0 ? dx / len : 1, len > 0 ? dy / len : 0];
}
left -= len;
}
return [pts[0], pts[1], 1, 0];
}
// 경로를 앞에서부터 dist 만큼만 자른 점 목록 — 화살표가 그려져 나가는 장면에 쓴다
function cutRoute(pts, dist) {
const out = [pts[0], pts[1]];
let left = Math.max(0, dist);
for (let i = 2; i < pts.length; i += 2) {
const len = Math.hypot(pts[i] - pts[i - 2], pts[i + 1] - pts[i - 1]);
if (left >= len) {
out.push(pts[i], pts[i + 1]);
left -= len;
continue;
}
const k = len > 0 ? left / len : 0;
out.push(lerp(pts[i - 2], pts[i], k), lerp(pts[i - 1], pts[i + 1], k));
break;
}
return out;
}
// 장면 시계(초)와 상태 줄 글자 — 장면 계산이 고치고, 화면 쪽이 읽어 그린다
let sceneT = 0;
let simStatus = '';
// 파스텔 팔레트 — 크림 바탕 위 라벤더·민트·버터·산호 막대. 가장 긴 경로·오늘 선·글자는 진한 쪽으로 명도 차를 둔다
const PAPER = [251, 247, 238];
const PAPER_DIM = [243, 237, 224];
const GRID = [226, 218, 204];
const INK = [44, 38, 58];
const INK_SOFT = [112, 104, 126];
const CREAM = [255, 251, 242];
const LAVENDER = [190, 174, 236];
const MINT = [158, 214, 190];
const BUTTER = [244, 222, 146];
const CORAL = [244, 170, 150];
const SKY = [160, 204, 236];
const PINK = [242, 182, 206];
const PATH_DEEP = [86, 56, 168];
const TODAY_DEEP = [190, 72, 60];
const BAR_COLORS = [LAVENDER, MINT, BUTTER, CORAL, SKY, PINK];
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// 표 한 줄 — 작업·시작·끝·여유. 끝은 그 작업이 덮는 마지막 날(일수 0 짜리는 시작과 같은 날)
function tableRow(name, start, dur, slack) {
return [name, dayText(start), dayText(start + Math.max(0, dur - 1)), SLACK_FMT.replace('{n}', String(slack))];
}
const canvasEl = root.querySelector('[data-role="canvas"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const tableEl = root.querySelector('[data-role="table"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function clearStage() {
ctx.fillStyle = rgba(PAPER, 1);
ctx.fillRect(0, 0, W, H);
}
function label(s, x, y, c, a, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
function roundRectPath(x, y, w, h, r) {
const rr = Math.max(0, Math.min(r, w / 2, h / 2));
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.lineTo(x + w - rr, y);
ctx.arcTo(x + w, y, x + w, y + rr, rr);
ctx.lineTo(x + w, y + h - rr);
ctx.arcTo(x + w, y + h, x + w - rr, y + h, rr);
ctx.lineTo(x + rr, y + h);
ctx.arcTo(x, y + h, x, y + h - rr, rr);
ctx.lineTo(x, y + rr);
ctx.arcTo(x, y, x + rr, y, rr);
ctx.closePath();
}
// 막대 하나 — 둥근 사각형 채움 + 진한 테두리. 폭이 0 이하이면 그리지 않는다
function bar(x, y, w, h, fill, a, line) {
if (w <= 0.2 || a <= 0) {
return;
}
roundRectPath(x, y, w, h, 4);
ctx.fillStyle = rgba(fill, a);
ctx.fill();
if (line > 0) {
ctx.lineWidth = line;
ctx.strokeStyle = rgba(INK, 0.55 * a);
ctx.stroke();
}
}
// 마일스톤 마름모
function diamond(cx, cy, r, fill, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.moveTo(cx, cy - r);
ctx.lineTo(cx + r, cy);
ctx.lineTo(cx, cy + r);
ctx.lineTo(cx - r, cy);
ctx.closePath();
ctx.fillStyle = rgba(fill, a);
ctx.fill();
ctx.lineWidth = 1.5;
ctx.strokeStyle = rgba(INK, 0.8 * a);
ctx.stroke();
}
function polyline(pts, c, a, width) {
if (pts.length < 4) {
return;
}
ctx.beginPath();
ctx.moveTo(pts[0], pts[1]);
for (let i = 2; i < pts.length; i += 2) {
ctx.lineTo(pts[i], pts[i + 1]);
}
ctx.lineWidth = width;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function arrowHead(x, y, dx, dy, size, c, a) {
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x - dx * size - dy * size * 0.6, y - dy * size + dx * size * 0.6);
ctx.lineTo(x - dx * size + dy * size * 0.6, y - dy * size - dx * size * 0.6);
ctx.closePath();
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
// 빗금 — (x, y, w, h) 안에 45° 줄을 spacing 간격으로. 간격이 좁을수록 짙어 보인다
function hatch(x, y, w, h, spacing, c, a) {
if (w <= 0.5 || a <= 0) {
return;
}
ctx.save();
roundRectPath(x, y, w, h, 3);
ctx.clip();
ctx.beginPath();
for (let s = x - h; s < x + w; s += Math.max(2, spacing)) {
ctx.moveTo(s, y + h);
ctx.lineTo(s + h, y);
}
ctx.lineWidth = 1.6;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
ctx.restore();
}
// 시간 축 — 위 띠는 월 이름, 아래 띠는 일(월 안의 날 숫자)·주(주차) 층. 층마다 불투명도를 따로 받아 겹쳐 흐려지며 바뀌게 한다.
// 세로 격자도 같은 불투명도로 행 영역(top~bottom)에 긋는다. xOf 는 일수 → 화면 x
function timeAxis(xOf, dayW, d0, d1, left, right, top, bottom, ly) {
ctx.save();
ctx.beginPath();
ctx.rect(left, 0, right - left, H);
ctx.clip();
ctx.fillStyle = rgba(PAPER_DIM, 1);
ctx.fillRect(left, 0, right - left, top - 4);
for (let d = Math.floor(d0); d <= Math.ceil(d1); d += 1) {
const x = xOf(d);
const cal = calendarOf(d);
if (ly.day > 0.01) {
ctx.fillStyle = rgba(GRID, 0.7 * ly.day);
ctx.fillRect(x, top, 1, bottom - top);
if (dayW >= 13) {
label(String(cal[1]), x + dayW / 2, top - 11, INK_SOFT, ly.day, 9, 600, 'center');
}
}
// 주 층 — 글자가 겹치지 않을 만큼(36px) 주를 건너뛰며 적는다
const weekStep = 7 * dayW >= 36 ? 1 : (14 * dayW >= 36 ? 2 : 4);
if (ly.week > 0.01 && d % 7 === 0) {
ctx.fillStyle = rgba(GRID, ly.week);
ctx.fillRect(x, top, 1, bottom - top);
if ((d / 7) % weekStep === 0) {
label(WEEK_FMT.replace('{n}', String(d / 7 + 1)), x + 3, top - 11, INK_SOFT, ly.week, 9, 600, 'left');
}
}
if (cal[1] === 1) {
ctx.fillStyle = rgba(INK_SOFT, 0.18 + 0.4 * ly.month);
ctx.fillRect(x, 4, 1, bottom - 4);
label(MONTH_FMT.replace('{n}', String(cal[0])), x + 4, 11, INK, 0.85, 10, 700, 'left');
// 월 층 — 그달 칸 가운데에 그달 일수(월 길이 표에서)를 적는다
const len = MONTH_DAYS[cal[0] - 1];
if (ly.month > 0.01 && len * dayW >= 30) {
label(SLACK_FMT.replace('{n}', String(len)), x + len * dayW / 2, top - 11, INK_SOFT, ly.month, 9, 700, 'center');
}
}
else if (d === Math.floor(d0)) {
// 왼쪽 끝 월 이름 — 바로 오른쪽에 새 달이 시작하면 겹치므로 적지 않는다
const toNext = MONTH_DAYS[cal[0] - 1] - cal[1] + 1;
if (toNext * dayW >= 44) {
label(MONTH_FMT.replace('{n}', String(cal[0])), Math.max(x, left) + 4, 11, INK, 0.85, 10, 700, 'left');
}
}
}
ctx.restore();
}
// 왼쪽 작업 이름 칸
function rowLabel(name, y, a, bold) {
label(name, 10, y, INK, a, 11, bold ? 800 : 600, 'left');
}
function rowStripe(y, h, i, a) {
if (i % 2 === 1) {
ctx.fillStyle = rgba(PAPER_DIM, 0.75 * a);
ctx.fillRect(0, y, W, h);
}
}
// 움직이는 행 빛 — 그 행 막대의 스프링 속도(k = 0~1)만큼 행 전체가 옅은 라벤더로 밝아진다. 막대가 서면 사라진다
function rowGlow(y, h, k) {
if (k <= 0.01) {
return;
}
ctx.fillStyle = rgba(LAVENDER, 0.42 * clamp(k, 0, 1));
ctx.fillRect(0, y, W, h);
}
// 상태 줄 — 바뀔 때만 글자를 고친다
let shownStatus = '';
function pushStatus() {
if (!statusEl || simStatus === shownStatus) {
return;
}
shownStatus = simStatus;
statusEl.textContent = simStatus;
}
// 숨김 표 — 같은 일정을 작업·시작·끝·여유 열로 읽게. 화면에는 안 보이고 화면 읽기 도구가 읽는다
const TABLE_HEAD = ['작업 이름', '시작일', '끝나는 날', '여유 일수'];
function fillTable(caption, rows) {
if (!tableEl) {
return;
}
const tb = tableEl;
while (tb.firstChild) {
tb.removeChild(tb.firstChild);
}
const cap = document.createElement('caption');
cap.textContent = caption;
tb.appendChild(cap);
const thead = document.createElement('thead');
const hr = document.createElement('tr');
TABLE_HEAD.forEach(function (h) {
const th = document.createElement('th');
th.scope = 'col';
th.textContent = h;
hr.appendChild(th);
});
thead.appendChild(hr);
tb.appendChild(thead);
const body = document.createElement('tbody');
rows.forEach(function (r) {
const tr = document.createElement('tr');
r.forEach(function (c, k) {
const cell = document.createElement(k === 0 ? 'th' : 'td');
if (k === 0) {
cell.scope = 'row';
}
cell.textContent = c;
tr.appendChild(cell);
});
body.appendChild(tr);
});
tb.appendChild(body);
}
// 09 — 스프린트 첫 화면. 시드 고정으로 만든 긴 프로젝트(HERO_TASKS 개)를 오늘 선이 스스로 지나간다. 시작한 작업은 실제 일수로 바꿔 앞뒤 두 번 훑기를 다시 돌리고(가장 긴 경로가 옮겨 감),
// 카메라는 오늘 선과 지금 일하는 행을 한 박자 늦게 따라가며, 화면에 들어온 행만 그린다
const HERO_TASKS = 240;
const HERO_SEED = 481;
const MILESTONE_EVERY = 24;
const DAY_WIDTH = 18;
const ROW_H = 20;
const BAR_H = 12;
const HEAD_H = 30;
// 오늘 선 속도(일수/초)와 화면 안 오늘 선 자리(왼쪽에서 비율)
const TODAY_DAYS_PER_S = 3.2;
const TODAY_SCREEN_AT = 0.42;
const FOCUS_SCREEN_AT = 0.36;
// 카메라 따라가기 비율(1/초) — 작을수록 더 늦게 따라온다
const CAMERA_FOLLOW_RATE = 2.2;
// 막대 자리 스프링 — 실제 일수가 들어와 뒤 작업이 밀릴 때
const HERO_STIFFNESS = 120;
const HERO_DAMPING = 16;
// 물결 고리 — 마일스톤에 닿으면 반지름이 RING_SPEED px/초로 커지고 RING_LIFE_S 동안 흐려진다
const RING_SPEED = 120;
const RING_LIFE_S = 1.2;
const HERO_START_DAY = 40;
const LOOP_S = 60;
const TABLE_CAPTION = '작업 일정표 — 긴 프로젝트의 처음 계획';
const NAME_POOL = ['요구 정리', '디자인 시안', '데이터 설계', '화면 개발', '서버 개발', '기능 연동', '문서 작성', '통합 검수', '오류 수정'];
const MILESTONE_NAME = '스프린트 {n} 마감';
const hero09 = (function () {
const rng = makeRng(HERO_SEED);
const out = [];
// 스프린트 하나 = 두 갈래(짝수 자리·홀수 자리)로 나란히 가는 작업들 + 끝의 마일스톤. 갈래 안에서는 바로 앞 작업을, 갈래 첫 작업은 앞 마일스톤을 기다린다.
// 두 갈래 중 더 오래 걸리는 쪽이 가장 긴 경로가 되고, 시작하고서야 드러나는 추가 일수가 다른 갈래를 더 길게 만들면 빛이 그쪽으로 옮겨 간다
for (let i = 0; i < HERO_TASKS; i += 1) {
const pos = i % MILESTONE_EVERY;
const isMs = pos === MILESTONE_EVERY - 1;
const deps = [];
if (isMs) {
deps.push(i - 1, i - 2);
}
else if (pos >= 2) {
deps.push(i - 2);
if (rng() < 0.15) {
deps.push(i - 1);
}
}
else if (i >= MILESTONE_EVERY) {
deps.push(i - pos - 1);
}
const dur = isMs ? 0 : 2 + Math.floor(rng() * 4);
// 실제로는 더 걸리는 일수(시작한 뒤에야 알 수 있다)
const extra = isMs ? 0 : (rng() < 0.35 ? 1 + Math.floor(rng() * 3) : 0);
const name = isMs ? MILESTONE_NAME.replace('{n}', String((i + 1) / MILESTONE_EVERY)) : NAME_POOL[i % NAME_POOL.length] + ' ' + (Math.floor(i / NAME_POOL.length) + 1);
out.push({ name: name, dur: dur, deps: deps.filter(function (d, k, a) { return a.indexOf(d) === k; }), load: 1, extra: extra });
}
return out;
})();
const order09 = sortByDeps(hero09).order;
const plan09 = sweepBoth(hero09, order09, null);
// 오늘까지 시작한 작업은 실제 일수로 — 그 일정으로 다시 앞뒤 두 번 훑는다
function knownTasks(today, base) {
return hero09.map(function (t, i) {
return { name: t.name, dur: base.es[i] <= today ? t.dur + t.extra : t.dur, deps: t.deps, load: t.load };
});
}
let today09 = HERO_START_DAY;
let lastDay09 = -1;
let now09 = plan09;
let dur09 = hero09.map(function (t) { return t.dur; });
const startS09 = hero09.map(function (_, i) { return makeSpring(plan09.es[i]); });
let camX = 0;
let camY = 0;
let rings09 = [];
let passed09 = hero09.map(function () { return false; });
// 하루가 바뀔 때마다 실제 일수를 반영해 다시 훑는다 — 정수 일수가 바뀔 때만이라 프레임마다 240개를 다시 세지 않는다
function recompute(day) {
let base = plan09;
// 시작 여부가 다시 계산한 시작일에 따라 바뀌므로 두 번 돌려 안정시킨다
for (let k = 0; k < 2; k += 1) {
const ts = knownTasks(day, base);
base = sweepBoth(ts, order09, null);
dur09 = ts.map(function (t) { return t.dur; });
}
now09 = base;
}
// 지금 일하는 행 — 오늘을 덮는 가장 긴 경로 작업 중 맨 위(없으면 오늘 이후 가장 먼저 시작하는 작업)
function focusRow() {
let best = -1;
for (let i = 0; i < HERO_TASKS; i += 1) {
if (now09.es[i] <= today09 && now09.ef[i] > today09) {
if (best < 0 || (now09.slack[i] === 0 && now09.slack[best] !== 0)) {
best = i;
}
}
}
if (best >= 0) {
return best;
}
for (let i = 0; i < HERO_TASKS; i += 1) {
if (now09.es[i] >= today09) {
return i;
}
}
return HERO_TASKS - 1;
}
function cameraTarget() {
const r = focusRow();
return [today09 * DAY_WIDTH - W * TODAY_SCREEN_AT, r * ROW_H + ROW_H / 2 - HEAD_H - (H - HEAD_H) * FOCUS_SCREEN_AT];
}
function reset() {
today09 = HERO_START_DAY;
lastDay09 = -1;
rings09 = [];
passed09 = hero09.map(function () { return false; });
recompute(Math.floor(today09));
lastDay09 = Math.floor(today09);
hero09.forEach(function (_, i) { snapSpring(startS09[i], now09.es[i]); passed09[i] = now09.es[i] <= today09; });
const c = cameraTarget();
camX = c[0];
camY = c[1];
}
function update(dt) {
today09 += TODAY_DAYS_PER_S * dt;
if (today09 >= plan09.end + 30) {
reset();
return;
}
const d = Math.floor(today09);
if (d !== lastDay09) {
lastDay09 = d;
recompute(d);
}
hero09.forEach(function (t, i) {
startS09[i].to = now09.es[i];
stepSpring(startS09[i], HERO_STIFFNESS, HERO_DAMPING, dt);
// 마일스톤 도달 — 물결 고리 하나
if (t.dur === 0 && !passed09[i] && today09 >= now09.es[i]) {
passed09[i] = true;
rings09.push({ x: now09.es[i] * DAY_WIDTH, y: i * ROW_H + ROW_H / 2, age: 0 });
}
});
rings09.forEach(function (r) { r.age += dt; });
rings09 = rings09.filter(function (r) { return r.age < RING_LIFE_S; });
const c = cameraTarget();
camX = lerp(camX, c[0], follow(CAMERA_FOLLOW_RATE, dt));
camY = lerp(camY, c[1], follow(CAMERA_FOLLOW_RATE, dt));
}
function restPose() {
// 최종 배치 — 시작한 지 열이틀 째의 한 장면(카메라는 제자리)
today09 = HERO_START_DAY + 12;
recompute(Math.floor(today09));
hero09.forEach(function (_, i) { snapSpring(startS09[i], now09.es[i]); passed09[i] = now09.es[i] <= today09; });
const c = cameraTarget();
camX = c[0];
camY = c[1];
}
function tableRows() {
return hero09.map(function (t, i) { return tableRow(t.name, plan09.es[i], t.dur, plan09.slack[i]); });
}
const DRAWN_FMT = '그린 행 {a} / 전체 {b}';
// 마지막으로 그린 행 수 — 화면 밖 행은 막대·화살표·이름을 하나도 그리지 않는다
let drawnRows09 = 0;
function draw() {
clearStage();
const xOf = function (d) { return d * DAY_WIDTH - camX; };
const first = Math.max(0, Math.floor((camY + HEAD_H) / ROW_H));
const last = Math.min(HERO_TASKS - 1, Math.floor((camY + H) / ROW_H));
// 행 띠
ctx.save();
ctx.beginPath();
ctx.rect(0, HEAD_H + 4, W, H - HEAD_H - 4);
ctx.clip();
for (let i = first; i <= last; i += 1) {
rowStripe(i * ROW_H - camY, ROW_H, i, 1);
}
ctx.restore();
timeAxis(xOf, DAY_WIDTH, camX / DAY_WIDTH - 1, (camX + W) / DAY_WIDTH + 1, 0, W, HEAD_H + 4, H, { day: 1, week: 0, month: 0 });
const tx = xOf(today09);
ctx.save();
ctx.beginPath();
ctx.rect(0, HEAD_H + 4, W, H - HEAD_H - 4);
ctx.clip();
// 지난 날 — 오늘 선 왼쪽을 옅게
ctx.fillStyle = rgba(INK, 0.04);
ctx.fillRect(0, HEAD_H + 4, Math.max(0, tx), H);
// 화살표 — 보이는 행끼리만
for (let j = first; j <= last; j += 1) {
hero09[j].deps.forEach(function (p) {
if (p < first - 3) {
return;
}
const crit = now09.slack[j] === 0 && now09.slack[p] === 0;
const pts = elbowRoute(xOf(startS09[p].x + dur09[p]), p * ROW_H + ROW_H / 2 - camY, xOf(startS09[j].x) - (hero09[j].dur === 0 ? 7 : 0), j * ROW_H + ROW_H / 2 - camY, (p + 1) * ROW_H - camY);
polyline(pts, crit ? PATH_DEEP : INK_SOFT, crit ? 0.8 : 0.35, crit ? 1.8 : 1.1);
});
}
let drawn = 0;
for (let i = first; i <= last; i += 1) {
const t = hero09[i];
const cy = i * ROW_H + ROW_H / 2 - camY;
const x0 = xOf(startS09[i].x);
const crit = now09.slack[i] === 0;
drawn += 1;
if (t.dur === 0) {
diamond(x0, cy, 7, today09 >= now09.es[i] ? PATH_DEEP : CREAM, 1);
label(t.name, x0 - 12, cy, INK, 0.9, 9, 800, 'right');
continue;
}
const w = dur09[i] * DAY_WIDTH;
const c = BAR_COLORS[i % BAR_COLORS.length];
bar(x0, cy - BAR_H / 2, w, BAR_H, c, 0.42, 0);
const done = clamp((today09 - startS09[i].x) / Math.max(1, dur09[i]), 0, 1);
bar(x0, cy - BAR_H / 2, w * done, BAR_H, c, 1, 0);
// 늘어난 날 — 시작하고 나서야 드러난 실제 추가 일수
if (dur09[i] > t.dur) {
hatch(x0 + t.dur * DAY_WIDTH, cy - BAR_H / 2, (dur09[i] - t.dur) * DAY_WIDTH, BAR_H, 4, TODAY_DEEP, 0.7);
}
roundRectPath(x0, cy - BAR_H / 2, w, BAR_H, 4);
ctx.lineWidth = crit ? 2.2 : 1;
ctx.strokeStyle = crit ? rgba(PATH_DEEP, 1) : rgba(INK, 0.45);
ctx.stroke();
label(t.name, x0 - 5, cy, crit ? INK : INK_SOFT, 1, 9, crit ? 800 : 600, 'right');
}
drawnRows09 = drawn;
// 물결 고리
rings09.forEach(function (r) {
const a = 1 - r.age / RING_LIFE_S;
for (let k = 0; k < 2; k += 1) {
const rad = Math.max(0, r.age * RING_SPEED - k * 16);
if (rad <= 0) {
continue;
}
ctx.beginPath();
ctx.arc(r.x - camX, r.y - camY, rad, 0, TAU);
ctx.lineWidth = 2.4 - k;
ctx.strokeStyle = rgba(PATH_DEEP, a * (0.9 - k * 0.35));
ctx.stroke();
}
});
ctx.restore();
// 오늘 선
ctx.fillStyle = rgba(TODAY_DEEP, 1);
ctx.fillRect(tx - 1, HEAD_H - 2, 2, H);
const tag = dayText(Math.floor(today09));
ctx.font = '800 10px ' + FONT_STACK;
const tw = ctx.measureText(tag).width + 12;
bar(tx - tw / 2, HEAD_H - 12, tw, 16, TODAY_DEEP, 1, 0);
label(tag, tx, HEAD_H - 4, CREAM, 1, 10, 800, 'center');
const info = DRAWN_FMT.replace('{a}', String(drawn)).replace('{b}', String(HERO_TASKS));
ctx.font = '800 10px ' + FONT_STACK;
const iw = ctx.measureText(info).width + 14;
bar(W - 8 - iw, 4, iw, 16, INK, 0.88, 0);
label(info, W - 8 - iw / 2, 12, CREAM, 1, 10, 800, 'center');
}
// 한 프레임에 넘기는 시간 상한(초) — 탭을 오래 숨겼다 돌아온 첫 프레임에도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 손대면 시연 표시를 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
// 리스너는 한 묶음으로 달고 정리할 때 한 번에 뗀다
const off = new AbortController();
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
if (statusEl) {
statusEl.setAttribute('aria-live', 'polite');
}
}
// 장면 시계를 dt 만큼 — 한 바퀴(LOOP_S)를 넘으면 처음 배치로 되돌려 다시 시연한다
function advance(dt) {
sceneT += dt;
if (sceneT >= LOOP_S) {
sceneT -= LOOP_S;
reset();
}
update(dt);
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
advance(dt);
}
draw();
pushStatus();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 시계 다시 맞추기 — 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 동작 줄이기 — 루프를 돌리지 않고 시연이 끝난 최종 배치 한 장만 그린다
function showRest() {
stopLoop();
sceneT = 0;
reset();
restPose();
draw();
pushStatus();
}
function play() {
sceneT = 0;
reset();
draw();
pushStatus();
if (!paused) {
startLoop();
}
}
pauseBtn.addEventListener('click', function () {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}, { signal: off.signal });
// 탭이 숨으면 루프를 멈추고, 돌아오면 시계를 다시 맞춰 시작한다 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 페이지를 연 채 바뀌면 — 켜지면 최종 배치로 멈추고, 꺼지면 시연을 처음부터
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
play();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
draw();
}, { signal: off.signal });
fitCanvas();
fillTable(TABLE_CAPTION, tableRows());
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// SprintCameraMilestone - 같은 항목 vanilla 판 index.html 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./SprintCameraMilestone.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function SprintCameraMilestone() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
type Pt = [number, number];
// 판 좌표 — 캔버스 안은 늘 480×300. 화면에 보이는 크기와 상관없이 계산은 이 좌표로 한다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
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); }
// 따라가기 비율 — 1 − e^(−rate·dt). 프레임 시간이 달라도 1초에 같은 만큼 다가간다
function follow(rate: number, dt: number): number { return 1 - Math.exp(-rate * dt); }
// 시드 고정 난수 — 같은 시드면 같은 일정이 다시 나온다 (32비트 정수 섞기)
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// ── 감쇠 스프링 ──────────────────────────────────────────────
// 가속 = 단단함 × (목표 − 위치) − 감쇠 × 속도. 속도를 먼저 고친 뒤 위치를 옮기는 적분을 1/240초 잔걸음으로 나눠 돌린다
type Spring = { x: number; v: number; to: number };
function makeSpring(x: number): Spring { return { x: x, v: 0, to: x }; }
const SPRING_SUBSTEP = 1 / 240;
function stepSpring(s: Spring, stiffness: number, damping: number, dt: number): void {
let left = dt;
while (left > 1e-9) {
const h = Math.min(SPRING_SUBSTEP, left);
s.v += (stiffness * (s.to - s.x) - damping * s.v) * h;
s.x += s.v * h;
left -= h;
}
}
function snapSpring(s: Spring, x: number): void { s.x = x; s.v = 0; s.to = x; }
// ── 날짜 = 정수 일수 ─────────────────────────────────────────
// 날짜 객체를 쓰지 않는다. 기준일을 0 으로 세는 정수 일수(day index)만 다루고, 화면의 월·일 숫자는 아래 월 길이 표로 산수해 만든다
const MONTH_DAYS = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
const YEAR_DAYS = 365;
// 기준일(일수 0) = 한 해의 70번째 날 → 표로 세면 3월 11일
const DAY_ZERO_OF_YEAR = 69;
function calendarOf(day: number): [number, number] {
let r = ((day + DAY_ZERO_OF_YEAR) % YEAR_DAYS + YEAR_DAYS) % YEAR_DAYS;
let m = 0;
while (r >= MONTH_DAYS[m]) { r -= MONTH_DAYS[m]; m += 1; }
return [m + 1, r + 1];
}
const DAY_FMT = '{n}일차';
const WEEK_FMT = '{n}주차';
const MONTH_FMT = '{n}월';
const SLACK_FMT = '{n}일';
function dayText(day: number): string { return DAY_FMT.replace('{n}', String(day + 1)); }
// 하루 차 계산의 함정 — 시각(시간 단위)끼리 빼서 24 로 나누면, 시계가 한 시간 당겨진 날이 끼었을 때 정수가 아니다(23시간짜리 하루).
// 그래서 바깥에서 들어온 시각은 반올림으로 정수 일수에 붙이고, 그다음부터는 정수끼리만 더하고 뺀다
const HOURS_PER_DAY = 24;
const CLOCK_SHIFT_HOURS = 1;
function hoursToDayIndex(hours: number): number { return Math.round(hours / HOURS_PER_DAY); }
// ── 작업과 의존 ──────────────────────────────────────────────
// dur 는 일수(0 이면 마일스톤), deps 는 끝나야 시작할 수 있는 앞 작업 번호, load 는 그 작업에 붙는 사람 수
type Task = { name: string; dur: number; deps: number[]; load: number };
const BASE_TASKS: Task[] = [
{ name: '요구 정리', dur: 3, deps: [], load: 2 },
{ name: '디자인 시안', dur: 4, deps: [0], load: 1 },
{ name: '데이터 설계', dur: 3, deps: [0], load: 1 },
{ name: '화면 개발', dur: 5, deps: [1], load: 2 },
{ name: '서버 개발', dur: 7, deps: [2], load: 1 },
{ name: '기능 연동', dur: 3, deps: [3, 4], load: 2 },
{ name: '문서 작성', dur: 3, deps: [1], load: 1 },
{ name: '통합 검수', dur: 3, deps: [5], load: 2 },
{ name: '오류 수정', dur: 2, deps: [7], load: 1 },
{ name: '출시', dur: 0, deps: [8, 6], load: 0 }
];
function copyTasks(ts: Task[]): Task[] { return ts.map(function (t: Task): Task { return { name: t.name, dur: t.dur, deps: t.deps.slice(), load: t.load }; }); }
function successorsOf(tasks: Task[]): number[][] {
const out: number[][] = tasks.map(function (): number[] { return []; });
tasks.forEach(function (t: Task, i: number): void { t.deps.forEach(function (p: number): void { out[p].push(i); }); });
return out;
}
// 선후 순서로 줄 세우기 — 앞 작업이 하나도 안 남은(들어오는 화살표 0) 작업부터 꺼내고, 꺼낼 때마다 뒤 작업의 남은 수를 하나 줄인다.
// 꺼낼 것이 사라졌는데 남은 작업이 있으면 그 작업들은 서로를 기다리는 고리(순환 의존)에 걸렸거나 그 뒤에 매달린 것이다 → stuck
type Order = { order: number[]; stuck: number[] };
function sortByDeps(tasks: Task[]): Order {
const succ = successorsOf(tasks);
const waiting = tasks.map(function (t: Task): number { return t.deps.length; });
const ready: number[] = [];
waiting.forEach(function (w: number, i: number): void { if (w === 0) { ready.push(i); } });
const order: number[] = [];
while (ready.length) {
ready.sort(function (a: number, b: number): number { return a - b; });
const i = ready.shift() as number;
order.push(i);
succ[i].forEach(function (j: number): void { waiting[j] -= 1; if (waiting[j] === 0) { ready.push(j); } });
}
const stuck: number[] = [];
waiting.forEach(function (w: number, i: number): void { if (w > 0) { stuck.push(i); } });
return { order: order, stuck: stuck };
}
// 앞뒤 두 번 훑기 — 앞에서: 가장 이른 시작 = 앞 작업 끝 중 가장 늦은 날(과 정해 둔 시작일 중 늦은 쪽).
// 뒤에서: 가장 늦은 끝 = 뒤 작업의 가장 늦은 시작 중 가장 이른 날(뒤 작업이 없으면 전체 끝). 여유 = 가장 늦은 시작 − 가장 이른 시작
type Layers = { day: number; week: number; month: number };
type Sweep = { es: number[]; ef: number[]; ls: number[]; lf: number[]; slack: number[]; end: number };
function sweepBoth(tasks: Task[], order: number[], minStart: number[] | null): Sweep {
const n = tasks.length;
const es: number[] = new Array(n).fill(0);
const ef: number[] = new Array(n).fill(0);
for (const i of order) {
let s = minStart ? minStart[i] : 0;
for (const p of tasks[i].deps) { s = Math.max(s, ef[p]); }
es[i] = s;
ef[i] = s + tasks[i].dur;
}
let end = 0;
for (const i of order) { end = Math.max(end, ef[i]); }
const succ = successorsOf(tasks);
const ls: number[] = new Array(n).fill(0);
const lf: number[] = new Array(n).fill(0);
for (let k = order.length - 1; k >= 0; k -= 1) {
const i = order[k];
let f = end;
for (const j of succ[i]) { f = Math.min(f, ls[j]); }
lf[i] = f;
ls[i] = f - tasks[i].dur;
}
const slack = es.map(function (s: number, i: number): number { return ls[i] - s; });
return { es: es, ef: ef, ls: ls, lf: lf, slack: slack, end: end };
}
// 밀기 전파 — 밀린 작업에서 가까운 뒤 작업부터 너비 우선으로 훑으며, 새 시작 = 앞 작업 끝 중 가장 늦은 날(지금보다 늦을 때만).
// depth 는 밀린 작업에서 몇 단계 뒤인지(-1 이면 여유가 흡수해 안 밀림). 시작이 바뀐 작업만 다시 큐에 넣는다
type Push = { starts: number[]; depth: number[] };
function pushFrom(tasks: Task[], starts: number[], from: number): Push {
const succ = successorsOf(tasks);
const out = starts.slice();
const depth: number[] = tasks.map(function (): number { return -1; });
depth[from] = 0;
const queue: number[] = [from];
let head = 0;
// 고리가 있어도 끝나도록 꺼내는 횟수에 상한을 둔다
const limit = tasks.length * tasks.length + 1;
while (head < queue.length && head < limit) {
const i = queue[head];
head += 1;
for (const j of succ[i]) {
let need = 0;
for (const p of tasks[j].deps) { need = Math.max(need, out[p] + tasks[p].dur); }
if (need > out[j]) {
out[j] = need;
depth[j] = Math.max(depth[j], depth[i] + 1);
queue.push(j);
}
}
}
return { starts: out, depth: depth };
}
// 날마다 걸린 일감 — 작업이 덮는 날(시작 ≤ 날 < 시작 + 일수)마다 사람 수를 더한다
function dailyLoad(tasks: Task[], starts: number[], days: number): number[] {
const load: number[] = new Array(days).fill(0);
tasks.forEach(function (t: Task, i: number): void {
for (let d = starts[i]; d < starts[i] + t.dur; d += 1) { if (d >= 0 && d < days) { load[d] += t.load; } }
});
return load;
}
// ── 직각 경로 ───────────────────────────────────────────────
// 앞 끝(ex, ey)에서 오른쪽으로 ROUTE_STUB 나간 뒤 아래로 꺾는다. 뒤 시작(sx)이 그 꺾인 자리 + ROUTE_STUB 보다 오른쪽이면 곧장 내려가 오른쪽으로 들어가고,
// 아니면 앞 행 바로 아래 통로(gapY)까지 내려가 왼쪽으로 돌아간 다음 뒤 시작보다 ROUTE_STUB 앞에서 내려가 오른쪽으로 들어간다
const ROUTE_STUB = 7;
function elbowRoute(ex: number, ey: number, sx: number, sy: number, gapY: number): number[] {
const x1 = ex + ROUTE_STUB;
if (sx - ROUTE_STUB >= x1) { return [ex, ey, x1, ey, x1, sy, sx, sy]; }
const x2 = sx - ROUTE_STUB;
return [ex, ey, x1, ey, x1, gapY, x2, gapY, x2, sy, sx, sy];
}
function routeLength(pts: number[]): number {
let s = 0;
for (let i = 2; i < pts.length; i += 2) { s += Math.hypot(pts[i] - pts[i - 2], pts[i + 1] - pts[i - 1]); }
return s;
}
// 경로 위 거리 dist 자리의 점과 그 마디의 방향
function pointOnRoute(pts: number[], dist: number): [number, number, number, number] {
let left = Math.max(0, dist);
for (let i = 2; i < pts.length; i += 2) {
const dx = pts[i] - pts[i - 2];
const dy = pts[i + 1] - pts[i - 1];
const len = Math.hypot(dx, dy);
if (left <= len || i === pts.length - 2) {
const k = len > 0 ? Math.min(1, left / len) : 0;
return [pts[i - 2] + dx * k, pts[i - 1] + dy * k, len > 0 ? dx / len : 1, len > 0 ? dy / len : 0];
}
left -= len;
}
return [pts[0], pts[1], 1, 0];
}
// 경로를 앞에서부터 dist 만큼만 자른 점 목록 — 화살표가 그려져 나가는 장면에 쓴다
function cutRoute(pts: number[], dist: number): number[] {
const out: number[] = [pts[0], pts[1]];
let left = Math.max(0, dist);
for (let i = 2; i < pts.length; i += 2) {
const len = Math.hypot(pts[i] - pts[i - 2], pts[i + 1] - pts[i - 1]);
if (left >= len) { out.push(pts[i], pts[i + 1]); left -= len; continue; }
const k = len > 0 ? left / len : 0;
out.push(lerp(pts[i - 2], pts[i], k), lerp(pts[i - 1], pts[i + 1], k));
break;
}
return out;
}
// 장면 시계(초)와 상태 줄 글자 — 장면 계산이 고치고, 화면 쪽이 읽어 그린다
let sceneT = 0;
let simStatus = '';
// 파스텔 팔레트 — 크림 바탕 위 라벤더·민트·버터·산호 막대. 가장 긴 경로·오늘 선·글자는 진한 쪽으로 명도 차를 둔다
const PAPER: RGB = [251, 247, 238];
const PAPER_DIM: RGB = [243, 237, 224];
const GRID: RGB = [226, 218, 204];
const INK: RGB = [44, 38, 58];
const INK_SOFT: RGB = [112, 104, 126];
const CREAM: RGB = [255, 251, 242];
const LAVENDER: RGB = [190, 174, 236];
const MINT: RGB = [158, 214, 190];
const BUTTER: RGB = [244, 222, 146];
const CORAL: RGB = [244, 170, 150];
const SKY: RGB = [160, 204, 236];
const PINK: RGB = [242, 182, 206];
const PATH_DEEP: RGB = [86, 56, 168];
const TODAY_DEEP: RGB = [190, 72, 60];
const BAR_COLORS: RGB[] = [LAVENDER, MINT, BUTTER, CORAL, SKY, PINK];
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// 표 한 줄 — 작업·시작·끝·여유. 끝은 그 작업이 덮는 마지막 날(일수 0 짜리는 시작과 같은 날)
function tableRow(name: string, start: number, dur: number, slack: number): string[] {
return [name, dayText(start), dayText(start + Math.max(0, dur - 1)), SLACK_FMT.replace('{n}', String(slack))];
}
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const tableEl = root.querySelector('[data-role="table"]') as HTMLTableElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function clearStage(): void {
ctx.fillStyle = rgba(PAPER, 1);
ctx.fillRect(0, 0, W, H);
}
function label(s: string, x: number, y: number, c: RGB, a: number, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
function roundRectPath(x: number, y: number, w: number, h: number, r: number): void {
const rr = Math.max(0, Math.min(r, w / 2, h / 2));
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.lineTo(x + w - rr, y);
ctx.arcTo(x + w, y, x + w, y + rr, rr);
ctx.lineTo(x + w, y + h - rr);
ctx.arcTo(x + w, y + h, x + w - rr, y + h, rr);
ctx.lineTo(x + rr, y + h);
ctx.arcTo(x, y + h, x, y + h - rr, rr);
ctx.lineTo(x, y + rr);
ctx.arcTo(x, y, x + rr, y, rr);
ctx.closePath();
}
// 막대 하나 — 둥근 사각형 채움 + 진한 테두리. 폭이 0 이하이면 그리지 않는다
function bar(x: number, y: number, w: number, h: number, fill: RGB, a: number, line: number): void {
if (w <= 0.2 || a <= 0) { return; }
roundRectPath(x, y, w, h, 4);
ctx.fillStyle = rgba(fill, a);
ctx.fill();
if (line > 0) {
ctx.lineWidth = line;
ctx.strokeStyle = rgba(INK, 0.55 * a);
ctx.stroke();
}
}
// 마일스톤 마름모
function diamond(cx: number, cy: number, r: number, fill: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.moveTo(cx, cy - r);
ctx.lineTo(cx + r, cy);
ctx.lineTo(cx, cy + r);
ctx.lineTo(cx - r, cy);
ctx.closePath();
ctx.fillStyle = rgba(fill, a);
ctx.fill();
ctx.lineWidth = 1.5;
ctx.strokeStyle = rgba(INK, 0.8 * a);
ctx.stroke();
}
function polyline(pts: number[], c: RGB, a: number, width: number): void {
if (pts.length < 4) { return; }
ctx.beginPath();
ctx.moveTo(pts[0], pts[1]);
for (let i = 2; i < pts.length; i += 2) { ctx.lineTo(pts[i], pts[i + 1]); }
ctx.lineWidth = width;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function arrowHead(x: number, y: number, dx: number, dy: number, size: number, c: RGB, a: number): void {
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x - dx * size - dy * size * 0.6, y - dy * size + dx * size * 0.6);
ctx.lineTo(x - dx * size + dy * size * 0.6, y - dy * size - dx * size * 0.6);
ctx.closePath();
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
// 빗금 — (x, y, w, h) 안에 45° 줄을 spacing 간격으로. 간격이 좁을수록 짙어 보인다
function hatch(x: number, y: number, w: number, h: number, spacing: number, c: RGB, a: number): void {
if (w <= 0.5 || a <= 0) { return; }
ctx.save();
roundRectPath(x, y, w, h, 3);
ctx.clip();
ctx.beginPath();
for (let s = x - h; s < x + w; s += Math.max(2, spacing)) {
ctx.moveTo(s, y + h);
ctx.lineTo(s + h, y);
}
ctx.lineWidth = 1.6;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
ctx.restore();
}
// 시간 축 — 위 띠는 월 이름, 아래 띠는 일(월 안의 날 숫자)·주(주차) 층. 층마다 불투명도를 따로 받아 겹쳐 흐려지며 바뀌게 한다.
// 세로 격자도 같은 불투명도로 행 영역(top~bottom)에 긋는다. xOf 는 일수 → 화면 x
function timeAxis(xOf: (d: number) => number, dayW: number, d0: number, d1: number, left: number, right: number, top: number, bottom: number, ly: Layers): void {
ctx.save();
ctx.beginPath();
ctx.rect(left, 0, right - left, H);
ctx.clip();
ctx.fillStyle = rgba(PAPER_DIM, 1);
ctx.fillRect(left, 0, right - left, top - 4);
for (let d = Math.floor(d0); d <= Math.ceil(d1); d += 1) {
const x = xOf(d);
const cal = calendarOf(d);
if (ly.day > 0.01) {
ctx.fillStyle = rgba(GRID, 0.7 * ly.day);
ctx.fillRect(x, top, 1, bottom - top);
if (dayW >= 13) { label(String(cal[1]), x + dayW / 2, top - 11, INK_SOFT, ly.day, 9, 600, 'center'); }
}
// 주 층 — 글자가 겹치지 않을 만큼(36px) 주를 건너뛰며 적는다
const weekStep = 7 * dayW >= 36 ? 1 : (14 * dayW >= 36 ? 2 : 4);
if (ly.week > 0.01 && d % 7 === 0) {
ctx.fillStyle = rgba(GRID, ly.week);
ctx.fillRect(x, top, 1, bottom - top);
if ((d / 7) % weekStep === 0) { label(WEEK_FMT.replace('{n}', String(d / 7 + 1)), x + 3, top - 11, INK_SOFT, ly.week, 9, 600, 'left'); }
}
if (cal[1] === 1) {
ctx.fillStyle = rgba(INK_SOFT, 0.18 + 0.4 * ly.month);
ctx.fillRect(x, 4, 1, bottom - 4);
label(MONTH_FMT.replace('{n}', String(cal[0])), x + 4, 11, INK, 0.85, 10, 700, 'left');
// 월 층 — 그달 칸 가운데에 그달 일수(월 길이 표에서)를 적는다
const len = MONTH_DAYS[cal[0] - 1];
if (ly.month > 0.01 && len * dayW >= 30) { label(SLACK_FMT.replace('{n}', String(len)), x + len * dayW / 2, top - 11, INK_SOFT, ly.month, 9, 700, 'center'); }
} else if (d === Math.floor(d0)) {
// 왼쪽 끝 월 이름 — 바로 오른쪽에 새 달이 시작하면 겹치므로 적지 않는다
const toNext = MONTH_DAYS[cal[0] - 1] - cal[1] + 1;
if (toNext * dayW >= 44) { label(MONTH_FMT.replace('{n}', String(cal[0])), Math.max(x, left) + 4, 11, INK, 0.85, 10, 700, 'left'); }
}
}
ctx.restore();
}
// 왼쪽 작업 이름 칸
function rowLabel(name: string, y: number, a: number, bold: boolean): void {
label(name, 10, y, INK, a, 11, bold ? 800 : 600, 'left');
}
function rowStripe(y: number, h: number, i: number, a: number): void {
if (i % 2 === 1) {
ctx.fillStyle = rgba(PAPER_DIM, 0.75 * a);
ctx.fillRect(0, y, W, h);
}
}
// 움직이는 행 빛 — 그 행 막대의 스프링 속도(k = 0~1)만큼 행 전체가 옅은 라벤더로 밝아진다. 막대가 서면 사라진다
function rowGlow(y: number, h: number, k: number): void {
if (k <= 0.01) { return; }
ctx.fillStyle = rgba(LAVENDER, 0.42 * clamp(k, 0, 1));
ctx.fillRect(0, y, W, h);
}
// 상태 줄 — 바뀔 때만 글자를 고친다
let shownStatus = '';
function pushStatus(): void {
if (!statusEl || simStatus === shownStatus) { return; }
shownStatus = simStatus;
statusEl.textContent = simStatus;
}
// 숨김 표 — 같은 일정을 작업·시작·끝·여유 열로 읽게. 화면에는 안 보이고 화면 읽기 도구가 읽는다
const TABLE_HEAD = ['작업 이름', '시작일', '끝나는 날', '여유 일수'];
function fillTable(caption: string, rows: string[][]): void {
if (!tableEl) { return; }
const tb = tableEl;
while (tb.firstChild) { tb.removeChild(tb.firstChild); }
const cap = document.createElement('caption');
cap.textContent = caption;
tb.appendChild(cap);
const thead = document.createElement('thead');
const hr = document.createElement('tr');
TABLE_HEAD.forEach(function (h: string): void {
const th = document.createElement('th');
th.scope = 'col';
th.textContent = h;
hr.appendChild(th);
});
thead.appendChild(hr);
tb.appendChild(thead);
const body = document.createElement('tbody');
rows.forEach(function (r: string[]): void {
const tr = document.createElement('tr');
r.forEach(function (c: string, k: number): void {
const cell = document.createElement(k === 0 ? 'th' : 'td');
if (k === 0) { (cell as HTMLTableCellElement).scope = 'row'; }
cell.textContent = c;
tr.appendChild(cell);
});
body.appendChild(tr);
});
tb.appendChild(body);
}
// 09 — 스프린트 첫 화면. 시드 고정으로 만든 긴 프로젝트(HERO_TASKS 개)를 오늘 선이 스스로 지나간다. 시작한 작업은 실제 일수로 바꿔 앞뒤 두 번 훑기를 다시 돌리고(가장 긴 경로가 옮겨 감),
// 카메라는 오늘 선과 지금 일하는 행을 한 박자 늦게 따라가며, 화면에 들어온 행만 그린다
const HERO_TASKS = 240;
const HERO_SEED = 481;
const MILESTONE_EVERY = 24;
const DAY_WIDTH = 18;
const ROW_H = 20;
const BAR_H = 12;
const HEAD_H = 30;
// 오늘 선 속도(일수/초)와 화면 안 오늘 선 자리(왼쪽에서 비율)
const TODAY_DAYS_PER_S = 3.2;
const TODAY_SCREEN_AT = 0.42;
const FOCUS_SCREEN_AT = 0.36;
// 카메라 따라가기 비율(1/초) — 작을수록 더 늦게 따라온다
const CAMERA_FOLLOW_RATE = 2.2;
// 막대 자리 스프링 — 실제 일수가 들어와 뒤 작업이 밀릴 때
const HERO_STIFFNESS = 120;
const HERO_DAMPING = 16;
// 물결 고리 — 마일스톤에 닿으면 반지름이 RING_SPEED px/초로 커지고 RING_LIFE_S 동안 흐려진다
const RING_SPEED = 120;
const RING_LIFE_S = 1.2;
const HERO_START_DAY = 40;
const LOOP_S = 60;
const TABLE_CAPTION = '작업 일정표 — 긴 프로젝트의 처음 계획';
const NAME_POOL = ['요구 정리', '디자인 시안', '데이터 설계', '화면 개발', '서버 개발', '기능 연동', '문서 작성', '통합 검수', '오류 수정'];
const MILESTONE_NAME = '스프린트 {n} 마감';
type HeroTask = Task & { extra: number };
const hero09: HeroTask[] = (function (): HeroTask[] {
const rng = makeRng(HERO_SEED);
const out: HeroTask[] = [];
// 스프린트 하나 = 두 갈래(짝수 자리·홀수 자리)로 나란히 가는 작업들 + 끝의 마일스톤. 갈래 안에서는 바로 앞 작업을, 갈래 첫 작업은 앞 마일스톤을 기다린다.
// 두 갈래 중 더 오래 걸리는 쪽이 가장 긴 경로가 되고, 시작하고서야 드러나는 추가 일수가 다른 갈래를 더 길게 만들면 빛이 그쪽으로 옮겨 간다
for (let i = 0; i < HERO_TASKS; i += 1) {
const pos = i % MILESTONE_EVERY;
const isMs = pos === MILESTONE_EVERY - 1;
const deps: number[] = [];
if (isMs) {
deps.push(i - 1, i - 2);
} else if (pos >= 2) {
deps.push(i - 2);
if (rng() < 0.15) { deps.push(i - 1); }
} else if (i >= MILESTONE_EVERY) {
deps.push(i - pos - 1);
}
const dur = isMs ? 0 : 2 + Math.floor(rng() * 4);
// 실제로는 더 걸리는 일수(시작한 뒤에야 알 수 있다)
const extra = isMs ? 0 : (rng() < 0.35 ? 1 + Math.floor(rng() * 3) : 0);
const name = isMs ? MILESTONE_NAME.replace('{n}', String((i + 1) / MILESTONE_EVERY)) : NAME_POOL[i % NAME_POOL.length] + ' ' + (Math.floor(i / NAME_POOL.length) + 1);
out.push({ name: name, dur: dur, deps: deps.filter(function (d: number, k: number, a: number[]): boolean { return a.indexOf(d) === k; }), load: 1, extra: extra });
}
return out;
})();
const order09 = sortByDeps(hero09).order;
const plan09 = sweepBoth(hero09, order09, null);
// 오늘까지 시작한 작업은 실제 일수로 — 그 일정으로 다시 앞뒤 두 번 훑는다
function knownTasks(today: number, base: Sweep): Task[] {
return hero09.map(function (t: HeroTask, i: number): Task {
return { name: t.name, dur: base.es[i] <= today ? t.dur + t.extra : t.dur, deps: t.deps, load: t.load };
});
}
let today09 = HERO_START_DAY;
let lastDay09 = -1;
let now09: Sweep = plan09;
let dur09: number[] = hero09.map(function (t: HeroTask): number { return t.dur; });
const startS09: Spring[] = hero09.map(function (_: HeroTask, i: number): Spring { return makeSpring(plan09.es[i]); });
let camX = 0;
let camY = 0;
type Ring = { x: number; y: number; age: number };
let rings09: Ring[] = [];
let passed09: boolean[] = hero09.map(function (): boolean { return false; });
// 하루가 바뀔 때마다 실제 일수를 반영해 다시 훑는다 — 정수 일수가 바뀔 때만이라 프레임마다 240개를 다시 세지 않는다
function recompute(day: number): void {
let base = plan09;
// 시작 여부가 다시 계산한 시작일에 따라 바뀌므로 두 번 돌려 안정시킨다
for (let k = 0; k < 2; k += 1) {
const ts = knownTasks(day, base);
base = sweepBoth(ts, order09, null);
dur09 = ts.map(function (t: Task): number { return t.dur; });
}
now09 = base;
}
// 지금 일하는 행 — 오늘을 덮는 가장 긴 경로 작업 중 맨 위(없으면 오늘 이후 가장 먼저 시작하는 작업)
function focusRow(): number {
let best = -1;
for (let i = 0; i < HERO_TASKS; i += 1) {
if (now09.es[i] <= today09 && now09.ef[i] > today09) {
if (best < 0 || (now09.slack[i] === 0 && now09.slack[best] !== 0)) { best = i; }
}
}
if (best >= 0) { return best; }
for (let i = 0; i < HERO_TASKS; i += 1) { if (now09.es[i] >= today09) { return i; } }
return HERO_TASKS - 1;
}
function cameraTarget(): Pt {
const r = focusRow();
return [today09 * DAY_WIDTH - W * TODAY_SCREEN_AT, r * ROW_H + ROW_H / 2 - HEAD_H - (H - HEAD_H) * FOCUS_SCREEN_AT];
}
function reset(): void {
today09 = HERO_START_DAY;
lastDay09 = -1;
rings09 = [];
passed09 = hero09.map(function (): boolean { return false; });
recompute(Math.floor(today09));
lastDay09 = Math.floor(today09);
hero09.forEach(function (_: HeroTask, i: number): void { snapSpring(startS09[i], now09.es[i]); passed09[i] = now09.es[i] <= today09; });
const c = cameraTarget();
camX = c[0];
camY = c[1];
}
function update(dt: number): void {
today09 += TODAY_DAYS_PER_S * dt;
if (today09 >= plan09.end + 30) { reset(); return; }
const d = Math.floor(today09);
if (d !== lastDay09) { lastDay09 = d; recompute(d); }
hero09.forEach(function (t: HeroTask, i: number): void {
startS09[i].to = now09.es[i];
stepSpring(startS09[i], HERO_STIFFNESS, HERO_DAMPING, dt);
// 마일스톤 도달 — 물결 고리 하나
if (t.dur === 0 && !passed09[i] && today09 >= now09.es[i]) {
passed09[i] = true;
rings09.push({ x: now09.es[i] * DAY_WIDTH, y: i * ROW_H + ROW_H / 2, age: 0 });
}
});
rings09.forEach(function (r: Ring): void { r.age += dt; });
rings09 = rings09.filter(function (r: Ring): boolean { return r.age < RING_LIFE_S; });
const c = cameraTarget();
camX = lerp(camX, c[0], follow(CAMERA_FOLLOW_RATE, dt));
camY = lerp(camY, c[1], follow(CAMERA_FOLLOW_RATE, dt));
}
function restPose(): void {
// 최종 배치 — 시작한 지 열이틀 째의 한 장면(카메라는 제자리)
today09 = HERO_START_DAY + 12;
recompute(Math.floor(today09));
hero09.forEach(function (_: HeroTask, i: number): void { snapSpring(startS09[i], now09.es[i]); passed09[i] = now09.es[i] <= today09; });
const c = cameraTarget();
camX = c[0];
camY = c[1];
}
function tableRows(): string[][] {
return hero09.map(function (t: HeroTask, i: number): string[] { return tableRow(t.name, plan09.es[i], t.dur, plan09.slack[i]); });
}
const DRAWN_FMT = '그린 행 {a} / 전체 {b}';
// 마지막으로 그린 행 수 — 화면 밖 행은 막대·화살표·이름을 하나도 그리지 않는다
let drawnRows09 = 0;
function draw(): void {
clearStage();
const xOf = function (d: number): number { return d * DAY_WIDTH - camX; };
const first = Math.max(0, Math.floor((camY + HEAD_H) / ROW_H));
const last = Math.min(HERO_TASKS - 1, Math.floor((camY + H) / ROW_H));
// 행 띠
ctx.save();
ctx.beginPath();
ctx.rect(0, HEAD_H + 4, W, H - HEAD_H - 4);
ctx.clip();
for (let i = first; i <= last; i += 1) { rowStripe(i * ROW_H - camY, ROW_H, i, 1); }
ctx.restore();
timeAxis(xOf, DAY_WIDTH, camX / DAY_WIDTH - 1, (camX + W) / DAY_WIDTH + 1, 0, W, HEAD_H + 4, H, { day: 1, week: 0, month: 0 });
const tx = xOf(today09);
ctx.save();
ctx.beginPath();
ctx.rect(0, HEAD_H + 4, W, H - HEAD_H - 4);
ctx.clip();
// 지난 날 — 오늘 선 왼쪽을 옅게
ctx.fillStyle = rgba(INK, 0.04);
ctx.fillRect(0, HEAD_H + 4, Math.max(0, tx), H);
// 화살표 — 보이는 행끼리만
for (let j = first; j <= last; j += 1) {
hero09[j].deps.forEach(function (p: number): void {
if (p < first - 3) { return; }
const crit = now09.slack[j] === 0 && now09.slack[p] === 0;
const pts = elbowRoute(xOf(startS09[p].x + dur09[p]), p * ROW_H + ROW_H / 2 - camY, xOf(startS09[j].x) - (hero09[j].dur === 0 ? 7 : 0), j * ROW_H + ROW_H / 2 - camY, (p + 1) * ROW_H - camY);
polyline(pts, crit ? PATH_DEEP : INK_SOFT, crit ? 0.8 : 0.35, crit ? 1.8 : 1.1);
});
}
let drawn = 0;
for (let i = first; i <= last; i += 1) {
const t = hero09[i];
const cy = i * ROW_H + ROW_H / 2 - camY;
const x0 = xOf(startS09[i].x);
const crit = now09.slack[i] === 0;
drawn += 1;
if (t.dur === 0) {
diamond(x0, cy, 7, today09 >= now09.es[i] ? PATH_DEEP : CREAM, 1);
label(t.name, x0 - 12, cy, INK, 0.9, 9, 800, 'right');
continue;
}
const w = dur09[i] * DAY_WIDTH;
const c = BAR_COLORS[i % BAR_COLORS.length];
bar(x0, cy - BAR_H / 2, w, BAR_H, c, 0.42, 0);
const done = clamp((today09 - startS09[i].x) / Math.max(1, dur09[i]), 0, 1);
bar(x0, cy - BAR_H / 2, w * done, BAR_H, c, 1, 0);
// 늘어난 날 — 시작하고 나서야 드러난 실제 추가 일수
if (dur09[i] > t.dur) { hatch(x0 + t.dur * DAY_WIDTH, cy - BAR_H / 2, (dur09[i] - t.dur) * DAY_WIDTH, BAR_H, 4, TODAY_DEEP, 0.7); }
roundRectPath(x0, cy - BAR_H / 2, w, BAR_H, 4);
ctx.lineWidth = crit ? 2.2 : 1;
ctx.strokeStyle = crit ? rgba(PATH_DEEP, 1) : rgba(INK, 0.45);
ctx.stroke();
label(t.name, x0 - 5, cy, crit ? INK : INK_SOFT, 1, 9, crit ? 800 : 600, 'right');
}
drawnRows09 = drawn;
// 물결 고리
rings09.forEach(function (r: Ring): void {
const a = 1 - r.age / RING_LIFE_S;
for (let k = 0; k < 2; k += 1) {
const rad = Math.max(0, r.age * RING_SPEED - k * 16);
if (rad <= 0) { continue; }
ctx.beginPath();
ctx.arc(r.x - camX, r.y - camY, rad, 0, TAU);
ctx.lineWidth = 2.4 - k;
ctx.strokeStyle = rgba(PATH_DEEP, a * (0.9 - k * 0.35));
ctx.stroke();
}
});
ctx.restore();
// 오늘 선
ctx.fillStyle = rgba(TODAY_DEEP, 1);
ctx.fillRect(tx - 1, HEAD_H - 2, 2, H);
const tag = dayText(Math.floor(today09));
ctx.font = '800 10px ' + FONT_STACK;
const tw = ctx.measureText(tag).width + 12;
bar(tx - tw / 2, HEAD_H - 12, tw, 16, TODAY_DEEP, 1, 0);
label(tag, tx, HEAD_H - 4, CREAM, 1, 10, 800, 'center');
const info = DRAWN_FMT.replace('{a}', String(drawn)).replace('{b}', String(HERO_TASKS));
ctx.font = '800 10px ' + FONT_STACK;
const iw = ctx.measureText(info).width + 14;
bar(W - 8 - iw, 4, iw, 16, INK, 0.88, 0);
label(info, W - 8 - iw / 2, 12, CREAM, 1, 10, 800, 'center');
}
// 한 프레임에 넘기는 시간 상한(초) — 탭을 오래 숨겼다 돌아온 첫 프레임에도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 손대면 시연 표시를 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
// 리스너는 한 묶음으로 달고 정리할 때 한 번에 뗀다
const off = new AbortController();
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
if (statusEl) { statusEl.setAttribute('aria-live', 'polite'); }
}
// 장면 시계를 dt 만큼 — 한 바퀴(LOOP_S)를 넘으면 처음 배치로 되돌려 다시 시연한다
function advance(dt: number): void {
sceneT += dt;
if (sceneT >= LOOP_S) {
sceneT -= LOOP_S;
reset();
}
update(dt);
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) { advance(dt); }
draw();
pushStatus();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 시계 다시 맞추기 — 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 동작 줄이기 — 루프를 돌리지 않고 시연이 끝난 최종 배치 한 장만 그린다
function showRest(): void {
stopLoop();
sceneT = 0;
reset();
restPose();
draw();
pushStatus();
}
function play(): void {
sceneT = 0;
reset();
draw();
pushStatus();
if (!paused) { startLoop(); }
}
pauseBtn.addEventListener('click', function (): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}, { signal: off.signal });
// 탭이 숨으면 루프를 멈추고, 돌아오면 시계를 다시 맞춰 시작한다 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 페이지를 연 채 바뀌면 — 켜지면 최종 배치로 멈추고, 꺼지면 시연을 처음부터
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { play(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
draw();
}, { signal: off.signal });
fitCanvas();
fillTable(TABLE_CAPTION, tableRows());
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("cf") + " is-demo"} ref={rootRef}>
<canvas className={cx("cf__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label="오늘 선을 카메라가 늦게 따라가며 지난 작업이 채워지고 마일스톤에 닿으면 물결 고리가 퍼지는 긴 프로젝트 일정표."></canvas>
<table className={cx("cf__sr")} data-role="table"></table>
<button className={cx("cf__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
</div>
);
}09 · 오늘 선을 카메라가 따라가며 마일스톤에 닿는 스프린트 첫 화면 — 측정 누적면적 52.882% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 하루가 바뀔 때마다 실제 일수를 반영해 다시 훑는다 — 정수 일수가 바뀔 때만이라 프레임마다 240개를 다시 세지 않는다
function recompute(day) {
let base = plan09;
// 시작 여부가 다시 계산한 시작일에 따라 바뀌므로 두 번 돌려 안정시킨다
for (let k = 0; k < 2; k += 1) {
const ts = knownTasks(day, base);
base = sweepBoth(ts, order09, null);
dur09 = ts.map(function (t) { return t.dur; });
}
now09 = base;
}
날짜는 픽셀로, 화살표는 직각 경로로, 여유는 앞뒤 두 번의 훑기로 — 일정표 한 장을 세우는 계산
날짜에서 픽셀까지 곱셈 한 번
화면 x 는 그래프 왼쪽 끝에 일수 × 하루 폭을 더한 값이다. 01 은 102 + 일수 × 16 이라 출시(기준일로부터 21일 뒤, 화면 표기 22일차)가 438px 에 오고, 05 는 왼쪽 끝 대신 기준 날짜를 원점으로 삼아 300 + (일수 − 52) × 폭 으로 바꿨을 뿐이다. 이 곱셈이 단순하게 남는 이유는 일수가 정수라서다. 시작일·기간·여유가 모두 정수 일수라 막대 끝이 하루 칸 경계에 정확히 맞고, 표의 '{n}일차' 글자와 막대 위치가 어긋날 틈이 없다.
줄 세우기가 먼저 오는 이유
앞 작업이 아직 계산되지 않았는데 뒤 작업의 시작일을 구할 수는 없다. 그래서 모든 장면은 먼저 sortByDeps 로 작업을 선후 순서로 줄 세운다. 들어오는 화살표 수(waiting)가 0 인 작업부터 꺼내고, 하나를 꺼낼 때마다 그 뒤 작업의 수를 1 씩 줄이며, 끝까지 못 꺼낸 작업은 stuck 에 따로 담는다.
function sortByDeps(tasks) {
const succ = successorsOf(tasks);
const waiting = tasks.map(function (t) { return t.deps.length; });
const ready = [];
waiting.forEach(function (w, i) { if (w === 0) {
ready.push(i);
} });
const order = [];
while (ready.length) {
ready.sort(function (a, b) { return a - b; });
const i = ready.shift();
order.push(i);
succ[i].forEach(function (j) { waiting[j] -= 1; if (waiting[j] === 0) {
ready.push(j);
} });
}
const stuck = [];
waiting.forEach(function (w, i) { if (w > 0) {
stuck.push(i);
} });
return { order: order, stuck: stuck };
}
앞 훑기와 뒤 훑기의 차이가 여유
간트 차트 js 로 일정표를 직접 짤 때 가장 값진 줄은 위 03 의 sweepBoth 두 반복문이다. 줄 선 순서대로 가며 가장 이른 시작 = 앞 작업 끝 중 가장 늦은 날을, 거꾸로 돌아오며 가장 늦은 시작 = 뒤 작업 시작 중 가장 이른 날 − 일수를 구하면 열 작업의 가장 이른 시작은 [0, 3, 3, 7, 6, 13, 7, 16, 19, 21], 가장 늦은 시작은 [0, 4, 3, 8, 6, 13, 18, 16, 19, 21]이 되고, 두 줄의 차이 [0, 1, 0, 1, 0, 0, 11, 0, 0, 0]이 여유다. 디자인 시안과 화면 개발은 하루, 문서 작성은 11일까지 늦어도 21일짜리 전체 일정이 그대로다.
밀기와 평준화는 같은 계산을 다시 부른다
04 의 연쇄 밀기와 07 의 평준화는 새 계산을 만들지 않는다. 둘 다 pushFrom 으로 뒤 작업을 밀고, 07 은 어떤 작업을 미룰지 고를 때 sweepBoth 에 지금 시작일을 넘겨 그 배치 기준의 여유를 다시 구한다. 09 역시 하루가 지날 때마다 같은 sweepBoth 를 두 번 돌릴 뿐이다. 앞뒤 훑기 하나가 아홉 장면의 공용 엔진인 셈이다.
스프링을 단단하게, 밀림 간격을 느리게, 하루 정원을 줄여서 — 값 하나로 바뀌는 일정표의 표정
| 변형 | 바꾼 값 | 느낌 |
|---|---|---|
| 스프링을 단단하게 | 01 BAR_STIFFNESS 170 → 320 |
감쇠비 0.537 → 0.391, 넘침 13.0% → 25.7%, 처음 닿는 시각 187ms → 112ms, 끝까지 ±1% 650ms → 621ms |
| 밀림 간격을 느리게 | 04 PUSH_STAGGER_MS 60 → 160 |
마지막 출발 0.6초 → 1.1초, 출시 마름모가 자리 잡는 시각 1.037초 → 1.537초, 밀린 6개·출시 24일차는 그대로 |
| 하루 정원을 줄여서 | 07 DAY_CAPACITY 3 → 2 |
넘친 날 3 → 5, 늦춘 횟수 5 → 23, 전체 21일 → 27일 |
단단하게 했는데 더 튀는 막대
감쇠비는 14 ÷ (2√단단함)이라 감쇠 14 를 그대로 둔 채 단단함만 320 으로 올리면 0.391 로 내려간다. 막대가 제 길이에 처음 닿는 시각은 75ms 빨라지지만 넘침이 두 배 가까이 커져, 차분해지기는커녕 더 튀다가 거의 같은 시각에 자리를 잡는다. 덜 튀게 하려면 감쇠를 함께 올려야 한다.
한 막대씩 따로 보이는 밀림
간격을 160ms 로 늘리면 화면 개발 → 기능 연동 → 통합 검수 → 오류 수정 → 출시가 한 막대씩 따로 움직여 밀림이 번지는 길이 또렷해진다. 대신 새 일정이 다 서기까지 0.5초가 더 걸리니, 회의 화면처럼 설명이 필요한 자리에서만 느린 쪽이 낫다.
여유가 바닥나는 정원
정원을 2명으로 줄이면 문서 작성의 여유 11일만으로는 넘침을 다 못 지운다. 23번을 늦추는 동안 화면 개발까지 밀려 최종 시작일이 [0, 3, 3, 13, 6, 18, 24, 21, 24, 27]이 되고 전체가 6일 길어진다. 평준화가 출시일을 지키는 것은 여유가 남아 있을 때까지다. 그나마 23번에서 멈추는 것은 후보를 그날 시작하는 작업으로 좁혀 두었기 때문이고, 07 소스 주석에 따르면 이미 시작한 작업까지 후보로 두면 겹친 작업들이 여유를 주고받으며 번갈아 밀려 40번을 다 쓰고도 넘친 날이 5일 남는다.
일정표가 깨지는 순간 — 서로를 기다리는 순환 의존·시간대가 바뀌는 날의 하루 차 계산·수백 줄로 늘어난 작업
디자인 시안이 통합 검수를 기다리게 되면
02 는 4.4초부터 6.6초까지 디자인 시안이 통합 검수를 기다리게 하는 의존(통합 검수 → 디자인 시안) 하나를 더한 사본을 sortByDeps 에 넣는다. 그러면 요구 정리·데이터 설계·서버 개발 3개만 줄을 서고 7개가 stuck 에 남는데, 고리 자체는 디자인 시안 → 화면 개발 → 기능 연동 → 통합 검수 → 디자인 시안으로 도는 넷이고 나머지 문서 작성·오류 수정·출시는 그 고리 뒤에 매달려 줄을 못 선 작업이다. 남은 작업을 무시하고 정렬 결과만 앞뒤 훑기에 넘기면 그 일곱 작업의 시작일은 0 인 채로 지나가 버린다. 02 는 stuck 이 비어 있지 않으면 그 작업에 경고 빗금과 끊긴 점선·✕ 를 그리고 상태 줄에 「순환 의존 — 줄 세우지 못한 작업 7개」 를 띄우며, pushFrom 은 큐에서 꺼내는 횟수를 n² + 1 로 묶어 고리가 있어도 끝난다. 다른 고리를 일부러 만들어 보려면 받은 파일 02 의 LOOP_EDGE 두 번호를 바꿔 보면 되고, 내려받은 zip 의 암호는 dvx72mdg 이다.
시계가 한 시간 당겨진 날이 낀 열흘
바깥에서 받은 시각을 시간 단위로 빼서 24 로 나누면, 10일차에서 20일차 사이에 시계가 한 시간 당겨진 날이 끼었을 때 흐른 시간은 239시간이고 239 ÷ 24 = 9.958 이 된다. 이를 버림하면 9일로 하루가 모자라고, 반대로 한 시간 늦춰진 날이면 10.042 가 된다. 반올림하는 hoursToDayIndex 로 붙이면 둘 다 10 이 된다. 데모는 처음부터 정수 일수만 더하고 빼기 때문에 이 함정이 들어올 자리가 없고, hoursToDayIndex 는 바깥 시각을 받을 때를 위해 정의만 해 둔 함수로 데모 안에서는 부르지 않는다.
240행 가운데 14~15행만 그린다
09 는 카메라 위치에서 첫 행 = floor((camY + 머리 높이) ÷ 행 높이), 끝 행 = floor((camY + 화면 높이) ÷ 행 높이)를 구해 그 사이 행만 막대·이름·화살표를 그린다. 20초 동안 실제로 그린 행은 14~15 / 240 이고, 화면 오른쪽 위 「그린 행 a / 전체 b」 글자가 그 수를 보여 준다. 화살표도 앞 작업이 화면 위로 3행 넘게 벗어나면 건너뛴다. 다시 훑기는 프레임마다가 아니라 정수 일수가 바뀔 때만 돌아 20초에 64번이고, 프레임마다 돌렸다면 약 1,200번이었다. 행 수를 더 늘려 보려면 받은 파일 09 의 HERO_TASKS 값을 키워 보면 된다.
막대 대신 표로도 읽히게 — 작업 목록 표·일시정지 버튼·움직임 줄이기 접근성
캔버스 뒤에 숨긴 작업 표
아홉 캔버스 모두 role="img" 와 장면 설명 aria-label 을 달고, 바로 곁의 숨긴 <table> 을 fillTable 이 caption·열 머리(작업 이름·시작일·끝나는 날·여유 일수)·행 머리로 채운다. 09 는 240행 전부가 표에 들어가므로 화면에 14~15행만 그려져도 화면 읽기 프로그램은 일정 전체를 읽는다.
일시정지 버튼과 포커스 링
장면마다 aria-pressed 를 단 <button> 하나가 있어 클릭·Enter·Space 로 멈추고 다시 돌리며, 키보드로 왔을 때는 :focus-visible 에서 3px 진한 외곽선이 생긴다. 상태 줄이 있는 02·04·07 은 시연 중에는 aria-live="off" 로 조용하다가 버튼을 처음 누른 뒤에야 polite 로 바뀌어, 자동 시연이 몇 초마다 읽기를 끊지 않는다.
움직임 줄이기에서 남는 최종 배치
prefers-reduced-motion: reduce 이면 아홉 장면 모두 루프를 세우고 restPose 로 최종 배치 한 장만 그린다. 01 은 다 자란 막대, 04 는 밀린 뒤의 일정, 07 은 평준화가 끝난 그래프, 08 은 기획·개발이 접히고 검수만 펼친 요약이 남는다. 설정을 도중에 바꿔도 change 리스너가 바로 따르고, 탭이 숨으면 visibilitychange 로 requestAnimationFrame 루프를 멈췄다가 돌아오면 한 프레임 시간을 0.05초에서 잘라 막대가 한꺼번에 튀지 않는다.
웹에 간트 차트를 직접 넣기 전에 자주 묻는 질문
간트차트 만들기 사이트를 쓰지 않고 내 웹페이지에 바로 넣어도 되나요?
된다. 각 장면의 스크립트는 index.html 한 파일 안에 다 들어 있고 라이브러리·CDN·이미지 파일을 하나도 부르지 않으므로, 압축을 풀어 원하는 페이지에 붙이면 그 자리에서 돈다.
우리 팀 작업 이름과 기간으로 바꾸려면 어디를 고치나요?
BASE_TASKS 의 줄마다 name(작업 이름)·dur(일수)·deps(앞 작업 번호)·load(사람 수)를 고치면 된다. 시작일·여유·화면 위치는 이 값에서 다시 계산되니 좌표를 손댈 필요가 없다.
휴대폰처럼 좁은 화면에서도 글자가 흐려지지 않나요?
무대 폭은 화면 폭의 92% 를 넘지 않고 480 : 300 비율을 지키며, 캔버스는 기기 화소 배율(최대 2)만큼 실제 화소를 늘려 그리므로 좁은 화면에서도 글자와 선이 또렷하다. 다만 작업이 많은 일정이라면 09 처럼 보이는 행만 그리는 방식을 같이 쓰는 편이 낫다.








