05칸이 순서대로 물드는 히트맵
요일×시간대 밀도를 한 색의 농도 함수로 칠하고 animation-delay 로 좌측부터 순서대로 물든다
쓰임방문 시간대 밀도 패널, 활동 리포트
animation-delayanimation-fill-modefill

<main class="stage">
<div class="ch is-demo" id="ch">
<div class="ch__card">
<div class="ch__head">
<p class="ch__title">요일×시간대 방문 밀도</p>
<span class="ch__chip">한 가지 색의 농도</span>
</div>
<div class="ch__plot">
<svg class="ch__svg" id="chart" viewBox="0 0 440 178" role="img" aria-label="요일과 시간대별 방문 밀도 히트맵, 일요일 낮 2-6시가 92로 가장 높음">
<!-- 시간대 라벨 6 — 열 위 -->
<g class="ch__bands" id="bands"></g>
<!-- 요일 라벨 7 — 왼쪽 -->
<g class="ch__days" id="days"></g>
<!-- 칸 42 + 가장 짙은 칸의 숫자 라벨. 미리보기 퇴장 와이프가 이 그룹 전체를 친다 -->
<g class="ch__grid" id="grid"></g>
</svg>
<!-- 툴팁 — 칸 위(위 두 줄은 아래로)에 뜬다 -->
<div class="ch__tip" id="tip" aria-hidden="true"><span class="ch__tipDay" id="tipDay"></span><span class="ch__tipBand" id="tipBand"></span><strong class="ch__tipVal" id="tipVal"></strong></div>
<!-- 스크린리더용 데이터 표 — 화면에 안 보인다 -->
<table class="ch__sr">
<caption>요일×시간대 방문 밀도</caption>
<thead><tr><th>요일</th><th>0-6시</th><th>6-10시</th><th>10-14시</th><th>14-18시</th><th>18-22시</th><th>22-24시</th></tr></thead>
<tbody id="srBody"></tbody>
</table>
</div>
<!-- 범례 — 옅음에서 짙음. 오른쪽은 이 데모의 주제인 농도 함수식 -->
<div class="ch__legend">
<span class="ch__lgWord">낮음</span>
<span class="ch__lgBar" aria-hidden="true"></span>
<span class="ch__lgWord">높음</span>
<span class="ch__lgExp">0.12 + 0.88 × v / 100</span>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 칸이 순서대로 물드는 히트맵 — 실제 사용에선 JS 가 fillOpacity() 로 42칸의 농도를 정해 완성 형태가 곧 뜨고,
// 칸에 올리면 요일·시간대·밀도 툴팁이 따라온다. 격자 미리보기(.is-demo)는 같은 칸들에 animation-delay 지연을
// 얹어 좌에서 우로, 위에서 아래로 순서대로 물드는 모양을 2초 루프로 보여 준다.
$duration: $dur-loop;
$easing: $ease-out;
$color: $chart-1;
@include stage($stage-ink);
.ch { display: flex; justify-content: center; width: 100%; padding-bottom: $sp-2; }
.ch__card {
width: min(100%, 420px); background: $subject-cream; border-radius: $r-card;
box-shadow: $shadow-raised; padding: $sp-3 $sp-4 $sp-2;
}
.ch__head { display: flex; align-items: center; justify-content: space-between; gap: $sp-2; margin-bottom: $sp-1; }
.ch__title { margin: 0; font-weight: 800; font-size: clamp(12px, 3vw, 14px); color: $subject-ink; }
.ch__chip {
font-weight: 700; font-size: clamp(9px, 2.4vw, 10px); color: rgba(23, 20, 26, .66);
border: 1px solid rgba(23, 20, 26, .28); border-radius: $r-pill; padding: $sp-1 $sp-2;
}
.ch__plot { position: relative; }
.ch__svg { display: block; width: 100%; height: auto; touch-action: none; }
.ch__cell { fill: $color; cursor: pointer; } // 농도는 fill-opacity(JS) — 색은 이 한 가지로 고정이다
.ch__band, .ch__day {
fill: rgba(23, 20, 26, .72); font-weight: 700; font-size: 9px;
dominant-baseline: middle;
}
.ch__day { text-anchor: end; font-size: 9.5px; }
// 가장 짙은 칸의 값 알약 — 칸 농도가 .86~.93 까지 올라가면 크림 글자도(3.4:1) 잉크 글자도(4.44:1)
// AA 를 못 넘는다. 그래서 숫자 밑에 얕은 페이퍼 면을 깔고 그 위에 잉크로 쓴다 — 칸 농도와 무관하게 18.24:1
.ch__numBg {
fill: $stage-paper;
transform-box: fill-box; transform-origin: center;
}
.ch__num {
fill: $subject-ink;
font-weight: 800; font-size: 10px; dominant-baseline: middle;
// SVG 요소의 CSS transform 기본 기준점은 viewBox 원점이라 중심 확대가 아니다 — fill-box + center 로
// 숫자 각자의 중심에서 팝한다
transform-box: fill-box; transform-origin: center;
}
// 툴팁 — 절대 배치라 루트에 padding-bottom 이 있어야 높이가 무대를 넘기지 않는다
.ch__tip {
position: absolute; transform: translate(-50%, -118%);
display: flex; align-items: baseline; gap: $sp-1; pointer-events: none;
background: $subject-ink; color: $stage-paper; border-radius: $r-xs;
padding: $sp-1 $sp-2; font-weight: 700; font-size: 10px; white-space: nowrap;
opacity: 0;
}
.ch__tip.is-below { transform: translate(-50%, 18%); }
.ch__tip.is-on { opacity: 1; transition: opacity $dur-quick $ease-out; }
.ch__tipDay { opacity: .72; }
.ch__tipVal { font-size: 12px; }
// 범례 — 같은 농도 함수를 그라디언트로 한 줄에 보여 준다
.ch__legend { display: flex; align-items: center; gap: $sp-2; margin-top: $sp-2; }
.ch__lgWord { font-weight: 700; font-size: 9px; color: rgba(23, 20, 26, .6); }
.ch__lgBar {
width: 72px; height: 8px; border-radius: $r-xs;
background: linear-gradient(90deg, rgba($color, .12), rgba($color, 1));
}
.ch__lgExp {
margin-left: auto; font-weight: 700; font-size: 9px; color: rgba(23, 20, 26, .72);
font-family: ui-monospace, "Cascadia Mono", Consolas, monospace;
}
// 스크린리더용 표 — fixed 로 캔버스 밖에 둔다
.ch__sr {
position: fixed; top: 0; left: -640px; width: 320px; margin: 0;
}
// ── 미리보기 루프: 칸마다 --d 지연으로 좌에서 우로, 한 줄이 끝나면 다음 줄로 순서대로 나타난다(42칸 × 28ms).
// 낱개 속성 + animation-fill-mode: backwards — animation 단축은 delay 를 0 으로 리셋하고,
// fill-mode 가 없으면 지연 구간에 완성 상태가 첫 프레임에 번쩍 지나간다.
.ch.is-demo .ch__cell {
animation-name: chCell;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-delay: var(--d);
animation-fill-mode: backwards;
}
@keyframes chCell {
0% { opacity: 0; }
10% { opacity: 1; }
100% { opacity: 1; }
}
// 가장 짙은 칸의 숫자 — 칸이 물든 뒤 살짝 늦게 팝한다. 값 알약(면)도 같은 지연으로 같이 팝한다
.ch.is-demo .ch__num,
.ch.is-demo .ch__numBg {
animation-name: chNum;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-delay: var(--dn);
animation-fill-mode: backwards;
}
@keyframes chNum {
0% { transform: scale(0); }
16% { transform: scale(1); }
100% { transform: scale(1); }
}
// 칸 지연의 폭이 1.15s 라 개별 칸 키프레임만으로는 루프 끝을 비울 수 없다 —
// 그룹 전체를 아래에서 위로 와이프해 94% 안에 전부 지운다(칸 지연은 그 아래에서 그대로 돈다).
// clip-path 와이프는 이징 짝으로만 — steps 와 함께 쓰면 컷으로 죽는다
.ch.is-demo .ch__grid {
animation-name: chGrid;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes chGrid {
0%, 86% { clip-path: inset(0 0 0 0); }
94%, 100% { clip-path: inset(0 0 100% 0); }
}
// 미리보기 툴팁은 가장 짙은 칸에 미리 놓아 둔다 (JS 가 일요일 14-18시로 세팅) — 그룹 와이프와 함께 컷으로 사라진다
.ch.is-demo .ch__tip {
animation-name: chTip;
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes chTip {
0%, 70% { opacity: 0; }
72%, 84% { opacity: 1; }
86%, 100% { opacity: 0; }
}
@media (max-width: 340px) {
.ch__card { padding: $sp-2 $sp-3 $sp-1; }
.ch__band { font-size: 8px; }
.ch__day { font-size: 8.5px; }
.ch__num { font-size: 9px; }
.ch__legend { margin-top: $sp-1; }
// 좁은 폼에선 식을 감추고 범례 막대만 남긴다
.ch__lgExp { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.ch.is-demo .ch__cell,
.ch.is-demo .ch__num,
.ch.is-demo .ch__numBg,
.ch.is-demo .ch__grid,
.ch.is-demo .ch__tip { animation: none !important; }
// 42칸이 전부 채워진 최종 상태로 점프한다 — 지연과 backwards 가 무효가 되어도 칸이 사라지지 않는다
.ch.is-demo .ch__num,
.ch.is-demo .ch__numBg { transform: none !important; }
.ch.is-demo .ch__grid { clip-path: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ch {
display: flex;
justify-content: center;
width: 100%;
padding-bottom: 8px;
}
.ch__card {
width: min(100%, 420px);
background: #fff7e6;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
padding: 12px 16px 8px;
}
.ch__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 4px;
}
.ch__title {
margin: 0;
font-weight: 800;
font-size: clamp(12px, 3vw, 14px);
color: #17141a;
}
.ch__chip {
font-weight: 700;
font-size: clamp(9px, 2.4vw, 10px);
color: rgba(23, 20, 26, 0.66);
border: 1px solid rgba(23, 20, 26, 0.28);
border-radius: 999px;
padding: 4px 8px;
}
.ch__plot {
position: relative;
}
.ch__svg {
display: block;
width: 100%;
height: auto;
touch-action: none;
}
.ch__cell {
fill: #2f6df6;
cursor: pointer;
}
.ch__band, .ch__day {
fill: rgba(23, 20, 26, 0.72);
font-weight: 700;
font-size: 9px;
dominant-baseline: middle;
}
.ch__day {
text-anchor: end;
font-size: 9.5px;
}
.ch__numBg {
fill: #ffffff;
transform-box: fill-box;
transform-origin: center;
}
.ch__num {
fill: #17141a;
font-weight: 800;
font-size: 10px;
dominant-baseline: middle;
transform-box: fill-box;
transform-origin: center;
}
.ch__tip {
position: absolute;
transform: translate(-50%, -118%);
display: flex;
align-items: baseline;
gap: 4px;
pointer-events: none;
background: #17141a;
color: #ffffff;
border-radius: 4px;
padding: 4px 8px;
font-weight: 700;
font-size: 10px;
white-space: nowrap;
opacity: 0;
}
.ch__tip.is-below {
transform: translate(-50%, 18%);
}
.ch__tip.is-on {
opacity: 1;
transition: opacity 220ms ease-out;
}
.ch__tipDay {
opacity: 0.72;
}
.ch__tipVal {
font-size: 12px;
}
.ch__legend {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
}
.ch__lgWord {
font-weight: 700;
font-size: 9px;
color: rgba(23, 20, 26, 0.6);
}
.ch__lgBar {
width: 72px;
height: 8px;
border-radius: 4px;
background: linear-gradient(90deg, rgba(47, 109, 246, 0.12), #2f6df6);
}
.ch__lgExp {
margin-left: auto;
font-weight: 700;
font-size: 9px;
color: rgba(23, 20, 26, 0.72);
font-family: ui-monospace, "Cascadia Mono", Consolas, monospace;
}
.ch__sr {
position: fixed;
top: 0;
left: -640px;
width: 320px;
margin: 0;
}
.ch.is-demo .ch__cell {
animation-name: chCell;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
animation-delay: var(--d);
animation-fill-mode: backwards;
}
@keyframes chCell {
0% {
opacity: 0;
}
10% {
opacity: 1;
}
100% {
opacity: 1;
}
}
.ch.is-demo .ch__num,
.ch.is-demo .ch__numBg {
animation-name: chNum;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
animation-delay: var(--dn);
animation-fill-mode: backwards;
}
@keyframes chNum {
0% {
transform: scale(0);
}
16% {
transform: scale(1);
}
100% {
transform: scale(1);
}
}
.ch.is-demo .ch__grid {
animation-name: chGrid;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
@keyframes chGrid {
0%, 86% {
clip-path: inset(0 0 0 0);
}
94%, 100% {
clip-path: inset(0 0 100% 0);
}
}
.ch.is-demo .ch__tip {
animation-name: chTip;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes chTip {
0%, 70% {
opacity: 0;
}
72%, 84% {
opacity: 1;
}
86%, 100% {
opacity: 0;
}
}
@media (max-width: 340px) {
.ch__card {
padding: 8px 12px 4px;
}
.ch__band {
font-size: 8px;
}
.ch__day {
font-size: 8.5px;
}
.ch__num {
font-size: 9px;
}
.ch__legend {
margin-top: 4px;
}
.ch__lgExp {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.ch.is-demo .ch__cell,
.ch.is-demo .ch__num,
.ch.is-demo .ch__numBg,
.ch.is-demo .ch__grid,
.ch.is-demo .ch__tip {
animation: none !important;
}
.ch.is-demo .ch__num,
.ch.is-demo .ch__numBg {
transform: none !important;
}
.ch.is-demo .ch__grid {
clip-path: none !important;
}
}var DAYS = ['월요일', '화요일', '수요일', '목요일', '금요일', '토요일', '일요일'];
var DAY_S = ['월', '화', '수', '목', '금', '토', '일'];
var BANDS = ['0-6시', '6-10시', '10-14시', '14-18시', '18-22시', '22-24시'];
// 밀도 0-100 — 평일 새벽은 비고 주말 오후로 갈수록 짙어진다
var DENS = [
[8, 22, 38, 42, 30, 12],
[7, 25, 41, 45, 33, 13],
[9, 24, 40, 44, 31, 11],
[8, 26, 43, 47, 35, 14],
[10, 28, 46, 58, 62, 30],
[14, 20, 52, 78, 88, 46],
[12, 18, 48, 92, 84, 38]
];
var X0 = 46, CW = 60, GX = 64; // 열: 칸 폭 60, 간격 4
var Y0 = 20, CH = 18, GY = 21; // 줄: 칸 높이 18, 간격 3
var NS = 'http://www.w3.org/2000/svg';
var ch = document.getElementById('ch');
var svg = document.getElementById('chart');
var tip = document.getElementById('tip');
var tipDay = document.getElementById('tipDay');
var tipBand = document.getElementById('tipBand');
var tipVal = document.getElementById('tipVal');
function mk(tag) { return document.createElementNS(NS, tag); }
// 농도 함수 — 이 데모의 주제. 색은 한 가지로 고정하고 투명도만 값에 비례해 움직인다
function fillOpacity(v) { return 0.12 + 0.88 * v / 100; }
// 시간대·요일 라벨
var bands = document.getElementById('bands');
BANDS.forEach(function (b, c) {
var t = mk('text');
t.setAttribute('class', 'ch__band');
t.setAttribute('x', X0 + c * GX + CW / 2); t.setAttribute('y', 10); t.setAttribute('text-anchor', 'middle');
t.textContent = b; bands.appendChild(t);
});
var days = document.getElementById('days');
DAYS.forEach(function (d, r) {
var t = mk('text');
t.setAttribute('class', 'ch__day');
t.setAttribute('x', 38); t.setAttribute('y', Y0 + r * GY + CH / 2); t.setAttribute('text-anchor', 'end');
t.textContent = DAY_S[r]; days.appendChild(t);
});
// 칸 42개 — 읽는 순서(좌에서 우, 위에서 아래)로 --d 지연을 단다
var grid = document.getElementById('grid');
var flat = 0;
DENS.forEach(function (row, r) {
row.forEach(function (v, c) {
var rect = mk('rect');
rect.setAttribute('class', 'ch__cell');
rect.setAttribute('x', X0 + c * GX); rect.setAttribute('y', Y0 + r * GY);
rect.setAttribute('width', CW); rect.setAttribute('height', CH); rect.setAttribute('rx', 4);
rect.setAttribute('fill-opacity', fillOpacity(v).toFixed(3));
rect.setAttribute('data-r', r); rect.setAttribute('data-c', c);
rect.style.setProperty('--d', (flat * 0.028).toFixed(3) + 's');
grid.appendChild(rect);
if (v >= 84) { // 가장 짙은 칸에는 숫자를 직접 새긴다 — 색만으론 정확한 값을 읽을 수 없다
// 짙은 칸(농도 .86~.93) 위에서는 크림도 잉크도 AA 를 못 넘는다 — 얕은 페이퍼 알약을
// 숫자 밑에 깔고 그 위에 잉크로 쓴다. 알약은 숫자와 같은 지연으로 함께 팝한다
var cx = X0 + c * GX + CW / 2, cy = Y0 + r * GY + CH / 2;
var dn = (flat * 0.028 + 0.12).toFixed(3) + 's';
var pad = mk('rect');
pad.setAttribute('class', 'ch__numBg');
pad.setAttribute('x', cx - 12); pad.setAttribute('y', cy - 7);
pad.setAttribute('width', 24); pad.setAttribute('height', 14); pad.setAttribute('rx', 4);
pad.style.setProperty('--dn', dn);
grid.appendChild(pad);
var tx = mk('text');
tx.setAttribute('class', 'ch__num');
tx.setAttribute('x', cx); tx.setAttribute('y', cy);
tx.setAttribute('text-anchor', 'middle');
tx.textContent = v;
tx.style.setProperty('--dn', dn);
grid.appendChild(tx);
}
flat++;
});
});
// 스크린리더 표 채우기
var srBody = document.getElementById('srBody');
DENS.forEach(function (row, r) {
var tr = document.createElement('tr');
var th = document.createElement('th'); th.setAttribute('scope', 'row'); th.textContent = DAYS[r];
tr.appendChild(th);
row.forEach(function (v) { var td = document.createElement('td'); td.textContent = v; tr.appendChild(td); });
srBody.appendChild(tr);
});
// 툴팁 — 칸 윗변에 맞춰 위로 뜨고, 위 두 줄은 아래로 띄운다. 처음 값은 가장 짙은 칸(일요일 14-18시)
function placeTip(r, c) {
var below = r < 2;
tip.style.left = ((X0 + c * GX + CW / 2) / 440 * 100).toFixed(2) + '%';
tip.style.top = ((Y0 + r * GY + (below ? CH : 0)) / 178 * 100).toFixed(2) + '%';
tip.classList.toggle('is-below', below);
tipDay.textContent = DAYS[r];
tipBand.textContent = BANDS[c];
tipVal.textContent = DENS[r][c];
}
placeTip(6, 3);
// 칸에 포인터가 오면 요일·시간대·밀도를 보여 준다
grid.addEventListener('pointermove', function (e) {
if (ch.classList.contains('is-demo')) { ch.classList.remove('is-demo'); }
var t = e.target;
if (t.classList && t.classList.contains('ch__cell')) {
placeTip(+t.getAttribute('data-r'), +t.getAttribute('data-c'));
tip.classList.add('is-on');
}
});
grid.addEventListener('pointerleave', function () { tip.classList.remove('is-on'); });// ChartHeatmapGrid — vanilla 판(run/289/demo/05-chart-draw-heatmap-grid/index.html) 의 React 포트.
// 요일×시간대 밀도 42칸을 한 가지 색의 농도 함수로 칠하는 히트맵.
// 사람이 포인터를 올리면 미리보기 루프(.isDemo)가 떨어지고 칸 툴팁이 따라온다.
import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ChartHeatmapGrid.module.scss";
export type ChartHeatmapGridProps = {
/** 격자 미리보기용 2초 자동 루프. 사람이 포인터를 올리면 꺼진다 */
demo?: boolean;
};
type PtrEvt = { clientX: number; clientY: number; target: Element | null };
type Vars = CSSProperties & { "--d"?: string; "--dn"?: string };
const DAYS = ["월요일", "화요일", "수요일", "목요일", "금요일", "토요일", "일요일"];
const DAY_S = ["월", "화", "수", "목", "금", "토", "일"];
const BANDS = ["0-6시", "6-10시", "10-14시", "14-18시", "18-22시", "22-24시"];
// 밀도 0-100 — 평일 새벽은 비고 주말 오후로 갈수록 짙어진다
const DENS = [
[8, 22, 38, 42, 30, 12],
[7, 25, 41, 45, 33, 13],
[9, 24, 40, 44, 31, 11],
[8, 26, 43, 47, 35, 14],
[10, 28, 46, 58, 62, 30],
[14, 20, 52, 78, 88, 46],
[12, 18, 48, 92, 84, 38]
];
const VW = 440, VH = 178;
const X0 = 46, CW = 60, GX = 64; // 열: 칸 폭 60, 간격 4
const Y0 = 20, CH = 18, GY = 21; // 줄: 칸 높이 18, 간격 3
const HOT_R = 6, HOT_C = 3; // 가장 짙은 칸 — 일요일 14-18시
/** 농도 함수 — 이 데모의 주제. 색은 한 가지로 고정하고 투명도만 값에 비례해 움직인다 */
const fillOpacity = (v: number) => 0.12 + 0.88 * v / 100;
/** 칸 지연(초) — 읽는 순서(좌에서 우, 위에서 아래)로 flat × 28ms.
* 숫자 라벨은 칸이 물든 뒤 살짝 늦게(+0.12s) 팝한다 */
const delaySec = (r: number, c: number) => (r * BANDS.length + c) * 0.028;
/** 툴팁 위치 — 칸 윗변에 맞춰 위로 뜨고, 위 두 줄은 아래로 띄운다 */
function tipStyle(r: number, c: number): CSSProperties {
const below = r < 2;
return {
left: ((X0 + c * GX + CW / 2) / VW * 100).toFixed(2) + "%",
top: ((Y0 + r * GY + (below ? CH : 0)) / VH * 100).toFixed(2) + "%",
};
}
export default function ChartHeatmapGrid({ demo = true }: ChartHeatmapGridProps) {
const [live, setLive] = useState(!demo);
const [hot, setHot] = useState<{ r: number; c: number } | null>({ r: HOT_R, c: HOT_C });
/** pointermove — 칸 위에 있으면 그 칸의 요일·시간대·밀도를 보여 준다.
* CSS 모듈이라 칸의 클래스 이름은 해시값 — styles.cell 로 비교한다 */
function onMove(e: PtrEvt) {
const t = e.target as Element | null;
if (!t || !t.classList.contains(styles.cell)) return;
setLive(true); // live 를 켜야 .isDemo 루프가 떨어진다 (live=false 가 미리보기 상태다)
setHot({ r: Number(t.getAttribute("data-r")), c: Number(t.getAttribute("data-c")) });
}
const shown = hot == null ? { r: HOT_R, c: HOT_C } : hot;
const below = shown.r < 2;
const rootCls = [styles.root, live ? "" : styles.isDemo].filter(Boolean).join(" ");
return (
<div className={rootCls}>
<div className={styles.card}>
<div className={styles.head}>
<p className={styles.title}>요일×시간대 방문 밀도</p>
<span className={styles.chip}>한 가지 색의 농도</span>
</div>
<div className={styles.plot}>
<svg
className={styles.svg} viewBox={`0 0 ${VW} ${VH}`} role="img"
aria-label="요일과 시간대별 방문 밀도 히트맵, 일요일 낮 2-6시가 92로 가장 높음"
onPointerMove={onMove}
onPointerLeave={() => setHot(null)}
>
{/* 시간대 라벨 6 — 열 위 */}
<g className={styles.bands}>
{BANDS.map((b, c) => (
<text key={b} className={styles.band} x={X0 + c * GX + CW / 2} y={10} textAnchor="middle">{b}</text>
))}
</g>
{/* 요일 라벨 7 — 왼쪽 */}
<g className={styles.days}>
{DAYS.map((_, r) => (
<text key={DAY_S[r]} className={styles.day} x={38} y={Y0 + r * GY + CH / 2} textAnchor="end">{DAY_S[r]}</text>
))}
</g>
{/* 칸 42 + 가장 짙은 칸의 숫자 라벨. 미리보기 퇴장 와이프가 이 그룹 전체를 친다 */}
<g className={styles.grid}>
{DENS.map((row, r) =>
row.map((v, c) => (
<rect
key={r + "-" + c} className={styles.cell}
x={X0 + c * GX} y={Y0 + r * GY} width={CW} height={CH} rx={4}
fillOpacity={fillOpacity(v).toFixed(3)}
data-r={r} data-c={c}
style={{ "--d": delaySec(r, c).toFixed(3) + "s" } as Vars}
/>
))
)}
{DENS.map((row, r) =>
row.map((v, c) => v >= 84 ? (
// 가장 짙은 칸에는 숫자를 직접 새긴다 — 색만으론 정확한 값을 읽을 수 없다.
// 짙은 칸(농도 .86~.93) 위에서는 크림도 잉크도 AA 를 못 넘으므로 얕은 페이퍼
// 알약을 숫자 밑에 깔고 그 위에 잉크로 쓴다 — 알약도 같은 지연으로 함께 팝한다
<g key={"n" + r + "-" + c}>
<rect
className={styles.numBg}
x={X0 + c * GX + CW / 2 - 12} y={Y0 + r * GY + CH / 2 - 7}
width={24} height={14} rx={4}
style={{ "--dn": (delaySec(r, c) + 0.12).toFixed(3) + "s" } as Vars}
/>
<text
className={styles.num}
x={X0 + c * GX + CW / 2} y={Y0 + r * GY + CH / 2} textAnchor="middle"
style={{ "--dn": (delaySec(r, c) + 0.12).toFixed(3) + "s" } as Vars}
>{v}</text>
</g>
) : null)
)}
</g>
</svg>
{/* 툴팁 — 칸 위(위 두 줄은 아래로)에 뜬다 */}
<div
className={[styles.tip, below ? styles.tipBelow : "", hot == null ? "" : styles.tipIsOn].filter(Boolean).join(" ")}
aria-hidden="true"
style={tipStyle(shown.r, shown.c)}
>
<span className={styles.tipDay}>{DAYS[shown.r]}</span>
<span className={styles.tipBand}>{BANDS[shown.c]}</span>
<strong className={styles.tipVal}>{DENS[shown.r][shown.c]}</strong>
</div>
{/* 스크린리더용 데이터 표 — 화면에 안 보인다 */}
<table className={styles.sr}>
<caption>요일×시간대 방문 밀도</caption>
<thead><tr><th scope="col">요일</th>{BANDS.map((b) => <th key={b} scope="col">{b}</th>)}</tr></thead>
<tbody>
{DENS.map((row, r) => (
<tr key={DAYS[r]}><th scope="row">{DAYS[r]}</th>{row.map((v, c) => <td key={c}>{v}</td>)}</tr>
))}
</tbody>
</table>
</div>
{/* 범례 — 옅음에서 짙음. 오른쪽은 이 데모의 주제인 농도 함수식 */}
<div className={styles.legend}>
<span className={styles.lgWord}>낮음</span>
<span className={styles.lgBar} aria-hidden="true"></span>
<span className={styles.lgWord}>높음</span>
<span className={styles.lgExp}>0.12 + 0.88 × v / 100</span>
</div>
</div>
</div>
);
}05 칸이 순서대로 물드는 히트맵 — 측정 누적면적 29.057% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · css 그래프 만들기 SVG로 9종 — 라이브러리 없이
이 데모가 속한 글
css 그래프 만들기 SVG로 9종 — 라이브러리 없이css 그래프는 숫자를 눈에 보이는 선·막대·면으로 바꾸는 가장 가벼운 방법입니다. 여기 아홉 가지는 선·막대·도넛·레이더·히트맵·스파크라인·누적 영역·트리맵·게이지를 라이브러리 없이 SVG 좌표 계산과 CSS 애니 이 글의 데모 전부 보기

