06등락률이 색으로 바뀌는 격자 히트맵
타일마다 등락률을 --chg 커스텀 속성에 넣고 @property 로 등록해 색이 튀지 않고 이어지게 한다
쓰임포트폴리오 전체 한눈에 보기, 섹터 등락 요약 위젯
@propertycolor-mixgrid-template-columns

<main class="stage">
<div class="hm is-demo" id="hm">
<div class="hm__board">
<div class="hm__head">
<p class="hm__title">전 종목 등락률</p>
<span class="hm__live">실시간</span>
</div>
<!-- 균등 격자 4×3. 트리맵이 아니라 칸 크기가 전부 같다 — 크기는 정보를 안 나르고 색만 나른다.
타일마다 자기 --va(지금 값) 와 --vb(다음 값) 를 지니고, 미리보기는 그 둘 사이를 --p 로 오간다. -->
<ul class="hm__grid">
<li class="hm__tile" data-g="0" style="--va: 2.41; --vb: 3.87">
<span class="hm__sym">NOVA</span>
<span class="hm__val">
<span class="hm__chg hm__chg--a" data-a>▲ +2.41%</span>
<span class="hm__chg hm__chg--b" data-b>▲ +3.87%</span>
</span>
</li>
<li class="hm__tile" data-g="1" style="--va: -1.32; --vb: 0.94">
<span class="hm__sym">ORBIT</span>
<span class="hm__val">
<span class="hm__chg hm__chg--a" data-a>▼ −1.32%</span>
<span class="hm__chg hm__chg--b" data-b>▲ +0.94%</span>
</span>
</li>
<li class="hm__tile" data-g="2" style="--va: 4.05; --vb: 2.16">
<span class="hm__sym">LUMEN</span>
<span class="hm__val">
<span class="hm__chg hm__chg--a" data-a>▲ +4.05%</span>
<span class="hm__chg hm__chg--b" data-b>▲ +2.16%</span>
</span>
</li>
<li class="hm__tile" data-g="0" style="--va: -3.28; --vb: -4.41">
<span class="hm__sym">KEEL</span>
<span class="hm__val">
<span class="hm__chg hm__chg--a" data-a>▼ −3.28%</span>
<span class="hm__chg hm__chg--b" data-b>▼ −4.41%</span>
</span>
</li>
<li class="hm__tile" data-g="1" style="--va: 0.62; --vb: -1.85">
<span class="hm__sym">AXIOM</span>
<span class="hm__val">
<span class="hm__chg hm__chg--a" data-a>▲ +0.62%</span>
<span class="hm__chg hm__chg--b" data-b>▼ −1.85%</span>
</span>
</li>
<li class="hm__tile" data-g="2" style="--va: -2.17; --vb: -0.43">
<span class="hm__sym">VERDE</span>
<span class="hm__val">
<span class="hm__chg hm__chg--a" data-a>▼ −2.17%</span>
<span class="hm__chg hm__chg--b" data-b>▼ −0.43%</span>
</span>
</li>
<li class="hm__tile" data-g="0" style="--va: 3.36; --vb: 1.02">
<span class="hm__sym">PIER</span>
<span class="hm__val">
<span class="hm__chg hm__chg--a" data-a>▲ +3.36%</span>
<span class="hm__chg hm__chg--b" data-b>▲ +1.02%</span>
</span>
</li>
<li class="hm__tile" data-g="1" style="--va: -4.12; --vb: -2.58">
<span class="hm__sym">HALON</span>
<span class="hm__val">
<span class="hm__chg hm__chg--a" data-a>▼ −4.12%</span>
<span class="hm__chg hm__chg--b" data-b>▼ −2.58%</span>
</span>
</li>
<li class="hm__tile" data-g="2" style="--va: 1.74; --vb: 4.28">
<span class="hm__sym">CIRRUS</span>
<span class="hm__val">
<span class="hm__chg hm__chg--a" data-a>▲ +1.74%</span>
<span class="hm__chg hm__chg--b" data-b>▲ +4.28%</span>
</span>
</li>
<li class="hm__tile" data-g="0" style="--va: -0.85; --vb: 2.33">
<span class="hm__sym">TALUS</span>
<span class="hm__val">
<span class="hm__chg hm__chg--a" data-a>▼ −0.85%</span>
<span class="hm__chg hm__chg--b" data-b>▲ +2.33%</span>
</span>
</li>
<li class="hm__tile" data-g="1" style="--va: 2.90; --vb: 0.17">
<span class="hm__sym">MERID</span>
<span class="hm__val">
<span class="hm__chg hm__chg--a" data-a>▲ +2.90%</span>
<span class="hm__chg hm__chg--b" data-b>▲ +0.17%</span>
</span>
</li>
<li class="hm__tile" data-g="2" style="--va: -1.96; --vb: -3.74">
<span class="hm__sym">ZENIT</span>
<span class="hm__val">
<span class="hm__chg hm__chg--a" data-a>▼ −1.96%</span>
<span class="hm__chg hm__chg--b" data-b>▼ −3.74%</span>
</span>
</li>
</ul>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 등락률 히트맵 — 균등 격자(4×3) 타일의 배경이 등락률을 따라 색으로 바뀐다.
// 289 의 요일×시간 히트맵과 갈리는 지점: 여기는 축이 없고(시간축 없음) 색이 실시간으로 바뀐다.
// 실사용에서는 시드 PRNG 가 850ms 마다 타일 세 개씩 --chg 를 갈아 끼우고, 등록된 커스텀 속성이라
// 색이 컷으로 튀지 않고 이어서 흐른다. 방향은 색만으로 전하지 않고 ▲▼ 와 +/− 부호를 같이 적는다.
// 미리보기(.is-demo)는 같은 흐름을 CSS 키프레임으로 2초 루프에 눕혀 대각선 세 파도로 갈라 보여 준다.
// 등락률은 숫자라야 보간된다 — 등록하지 않으면 키프레임·transition 이 컷으로 점프한다.
// inherits: false 로 등록했으므로 값은 반드시 타일 자신에 올린다 (부모 격자에 걸면 안 먹는다).
@property --chg { syntax: "<number>"; inherits: false; initial-value: 0; }
@property --va { syntax: "<number>"; inherits: false; initial-value: 0; }
@property --vb { syntax: "<number>"; inherits: false; initial-value: 0; }
@property --p { syntax: "<number>"; inherits: false; initial-value: 0; }
$duration: $dur-loop;
$easing: $ease-out;
// 혼합 상한 50%. 이 위로 올리면 타일이 밝아져 크림 글자가 4.5:1 아래로 떨어진다
// (run/296/_probe/타일대비.py 실측: 상승 50% #3c775e 위 크림 4.93:1 · 하락 50% #8a4c45 위 6.16:1,
// 상승 55% 는 4.35:1 로 미달). 그래서 글자색은 타일 어디서나 크림 한 가지로 간다
$mix-cap: 50%;
@include stage($stage-yellow);
.hm { display: flex; justify-content: center; width: 100%; }
.hm__board {
width: min(100%, 380px); background: $tick-panel; border-radius: $r-card;
box-shadow: $shadow-raised; padding: $sp-2; overflow: hidden;
}
.hm__head { display: flex; align-items: center; justify-content: space-between; padding: 0 $sp-1 $sp-2; }
.hm__title { margin: 0; font-weight: 800; font-size: clamp(11px, 3vw, 13px); color: $subject-cream; }
// 라이브 배지 — 글자로도 상태를 말한다
.hm__live {
font-weight: 700; font-size: clamp(9px, 2.4vw, 10px); color: $tick-up;
border: 1px solid $tick-up; border-radius: $r-pill; padding: 0 $sp-2; line-height: 1.6;
}
// 균등 격자 — 트리맵처럼 칸 크기가 값을 나르지 않는다. 4열 3행 모두 같은 크기다
.hm__grid {
list-style: none; margin: 0; padding: 0;
display: grid; grid-template-columns: repeat(4, 1fr); gap: $sp-1;
}
.hm__tile {
// 타일의 등락률: 미리보기는 --p 로 지금 값(--va)과 다음 값(--vb) 사이를 오가고,
// 실사용에서는 JS 가 이 자리에 인라인으로 --chg 를 써 넣어 이 규칙을 덮는다
--chg: calc(var(--va) + (var(--vb) - var(--va)) * var(--p));
// 0 부근은 판 색 그대로. 위로 갈수록 상승색, 아래로 갈수록 하락색을 섞는다 (1% 당 11%, 상한 50%)
--up-mix: clamp(0%, calc(var(--chg) * 11%), #{$mix-cap});
--down-mix: clamp(0%, calc(var(--chg) * -11%), #{$mix-cap});
background-color: color-mix(in oklab, $tick-down var(--down-mix),
color-mix(in oklab, $tick-up var(--up-mix), $tick-panel));
// 실사용에서 값이 바뀔 때 색이 컷으로 튀지 않고 이어진다 — 등록한 숫자 속성이라 보간된다
transition: --chg $dur-base $easing;
display: grid; align-content: center; justify-items: center; gap: 0;
min-height: 32px; padding: $sp-1 0; border-radius: $r-xs;
}
.hm__sym {
font-weight: 700; font-size: clamp(8px, 2.2vw, 10px); line-height: 1.3;
color: $subject-cream; letter-spacing: .04em; opacity: .78;
}
// 지금 값·다음 값 두 장을 같은 칸에 겹친다 — 컷 교체라 폭이 달라도 자리가 안 흔들린다
.hm__val { display: grid; justify-items: center; }
.hm__chg {
grid-area: 1 / 1; font-variant-numeric: tabular-nums;
font-weight: 800; font-size: clamp(9px, 2.6vw, 11px); line-height: 1.3; color: $subject-cream;
}
// 실사용(정적 경로)에서는 지금 값만 보인다 — 다음 값 장은 자리만 지키고 안 보인다.
// display: none 으로 지우면 칸 폭이 무너진다
.hm:not(.is-demo) .hm__chg--b { opacity: 0; }
// ── 미리보기 루프(2초): 8% 에 숫자가 컷으로 바뀌고 색은 8~20% 에 이어서 따라간다(실사용과 같은 순서 —
// JS 가 숫자를 쓰면 색은 transition 으로 따라온다). 60% 에 원래 값으로 되돌아오고 72% 에 색이 앉는다.
// 되돌림이 96% 안에 끝난다 — 24프레임의 마지막 장이 2초의 95.8% 지점이라 그 뒤는 샘플이 없다.
// 한 타일의 색 변화는 루프 안에서 2회 = 초당 1회로 WCAG 2.3.1(초당 3회 미만) 아래다.
.hm.is-demo .hm__tile {
animation-name: hmWave;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
// 대각선 세 파도. 지연은 낱개 속성으로 준다 — animation 단축은 delay 를 0 으로 리셋한다.
// 음수 지연이라 루프 이음매가 안 생긴다(어느 순간을 찍어도 같은 위상)
.hm.is-demo .hm__tile[data-g="1"] { animation-delay: -667ms; }
.hm.is-demo .hm__tile[data-g="2"] { animation-delay: -1333ms; }
@keyframes hmWave {
0%, 8% { --p: 0; }
20%, 60% { --p: 1; }
72%, 100% { --p: 0; }
}
// 숫자 교체는 steps(1, end) 컷 — 반투명하게 겹친 유령 프레임이 안 생긴다
.hm.is-demo .hm__chg--a, .hm.is-demo .hm__chg--b {
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.hm.is-demo .hm__chg--a { animation-name: hmTextA; }
.hm.is-demo .hm__chg--b { animation-name: hmTextB; }
.hm.is-demo .hm__tile[data-g="1"] .hm__chg--a,
.hm.is-demo .hm__tile[data-g="1"] .hm__chg--b { animation-delay: -667ms; }
.hm.is-demo .hm__tile[data-g="2"] .hm__chg--a,
.hm.is-demo .hm__tile[data-g="2"] .hm__chg--b { animation-delay: -1333ms; }
@keyframes hmTextA {
0%, 6% { opacity: 1; }
8%, 58% { opacity: 0; }
60%, 100% { opacity: 1; }
}
@keyframes hmTextB {
0%, 6% { opacity: 0; }
8%, 58% { opacity: 1; }
60%, 100% { opacity: 0; }
}
@media (max-width: 340px) {
.hm__board { padding: $sp-1; }
.hm__head { padding: 0 $sp-1 $sp-1; }
.hm__tile { min-height: 30px; padding: 0; }
}
@media (prefers-reduced-motion: reduce) {
// 색이 흐르는 것과 숫자가 바뀌는 것만 멈춘다 — 값은 그대로 읽히고 방향은 ▲▼ 와 부호가 말한다
.hm__tile { transition: none !important; }
.hm.is-demo .hm__tile { animation: none !important; }
.hm.is-demo .hm__chg--a { animation: none !important; opacity: 1 !important; }
.hm.is-demo .hm__chg--b { animation: none !important; opacity: 0 !important; }
}@property --chg {
syntax: "<number>";
inherits: false;
initial-value: 0;
}
@property --va {
syntax: "<number>";
inherits: false;
initial-value: 0;
}
@property --vb {
syntax: "<number>";
inherits: false;
initial-value: 0;
}
@property --p {
syntax: "<number>";
inherits: false;
initial-value: 0;
}
html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.hm {
display: flex;
justify-content: center;
width: 100%;
}
.hm__board {
width: min(100%, 380px);
background: #231f28;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
padding: 8px;
overflow: hidden;
}
.hm__head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 4px 8px;
}
.hm__title {
margin: 0;
font-weight: 800;
font-size: clamp(11px, 3vw, 13px);
color: #fff7e6;
}
.hm__live {
font-weight: 700;
font-size: clamp(9px, 2.4vw, 10px);
color: #3ddc97;
border: 1px solid #3ddc97;
border-radius: 999px;
padding: 0 8px;
line-height: 1.6;
}
.hm__grid {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 4px;
}
.hm__tile {
--chg: calc(var(--va) + (var(--vb) - var(--va)) * var(--p));
--up-mix: clamp(0%, calc(var(--chg) * 11%), 50%);
--down-mix: clamp(0%, calc(var(--chg) * -11%), 50%);
background-color: color-mix(in oklab, #ff7a5e var(--down-mix), color-mix(in oklab, #3ddc97 var(--up-mix), #231f28));
transition: --chg 300ms ease-out;
display: grid;
align-content: center;
justify-items: center;
gap: 0;
min-height: 32px;
padding: 4px 0;
border-radius: 4px;
}
.hm__sym {
font-weight: 700;
font-size: clamp(8px, 2.2vw, 10px);
line-height: 1.3;
color: #fff7e6;
letter-spacing: 0.04em;
opacity: 0.78;
}
.hm__val {
display: grid;
justify-items: center;
}
.hm__chg {
grid-area: 1/1;
font-variant-numeric: tabular-nums;
font-weight: 800;
font-size: clamp(9px, 2.6vw, 11px);
line-height: 1.3;
color: #fff7e6;
}
.hm:not(.is-demo) .hm__chg--b {
opacity: 0;
}
.hm.is-demo .hm__tile {
animation-name: hmWave;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.hm.is-demo .hm__tile[data-g="1"] {
animation-delay: -667ms;
}
.hm.is-demo .hm__tile[data-g="2"] {
animation-delay: -1333ms;
}
@keyframes hmWave {
0%, 8% {
--p: 0;
}
20%, 60% {
--p: 1;
}
72%, 100% {
--p: 0;
}
}
.hm.is-demo .hm__chg--a, .hm.is-demo .hm__chg--b {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.hm.is-demo .hm__chg--a {
animation-name: hmTextA;
}
.hm.is-demo .hm__chg--b {
animation-name: hmTextB;
}
.hm.is-demo .hm__tile[data-g="1"] .hm__chg--a,
.hm.is-demo .hm__tile[data-g="1"] .hm__chg--b {
animation-delay: -667ms;
}
.hm.is-demo .hm__tile[data-g="2"] .hm__chg--a,
.hm.is-demo .hm__tile[data-g="2"] .hm__chg--b {
animation-delay: -1333ms;
}
@keyframes hmTextA {
0%, 6% {
opacity: 1;
}
8%, 58% {
opacity: 0;
}
60%, 100% {
opacity: 1;
}
}
@keyframes hmTextB {
0%, 6% {
opacity: 0;
}
8%, 58% {
opacity: 1;
}
60%, 100% {
opacity: 0;
}
}
@media (max-width: 340px) {
.hm__board {
padding: 4px;
}
.hm__head {
padding: 0 4px 4px;
}
.hm__tile {
min-height: 30px;
padding: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.hm__tile {
transition: none !important;
}
.hm.is-demo .hm__tile {
animation: none !important;
}
.hm.is-demo .hm__chg--a {
animation: none !important;
opacity: 1 !important;
}
.hm.is-demo .hm__chg--b {
animation: none !important;
opacity: 0 !important;
}
}// 가짜 시세는 시드 PRNG 로 만든다 — 같은 시드면 누가 열어도 같은 흐름이 재현된다.
// 외부 API·키가 필요 없고, 심볼도 실존 종목이 아니다.
var SEED = 2966;
function mulberry32(a) {
return function () {
a |= 0; a = a + 0x6D2B79F5 | 0;
var t = Math.imul(a ^ a >>> 15, 1 | a);
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
return ((t ^ t >>> 14) >>> 0) / 4294967296;
};
}
var rand = mulberry32(SEED);
var hm = document.getElementById('hm');
var tiles = [].slice.call(hm.querySelectorAll('.hm__tile'));
// 첫 화면의 값은 HTML 의 --va 그대로다 — JS 가 늦게 깨어나도 격자가 비지 않는다
var chg = tiles.map(function (t) { return parseFloat(t.style.getPropertyValue('--va')); });
// 방향을 색만으로 전하지 않는다 — 화살표와 부호를 숫자 옆에 같이 적는다
function arrow(v) { return v >= 0 ? '▲' : '▼'; }
function pct(v) { return (v >= 0 ? '+' : '−') + Math.abs(v).toFixed(2) + '%'; }
// 색은 --chg 를 타일 자신에 올려야 먹는다. @property 를 inherits: false 로 등록했으므로
// 부모(격자)에 걸면 타일이 못 읽는다 — 262-08 에서 같은 함정을 실측했다
function paint(i) {
var t = tiles[i];
t.style.setProperty('--chg', chg[i].toFixed(2));
t.querySelector('[data-a]').textContent = arrow(chg[i]) + ' ' + pct(chg[i]);
}
// 등락률은 -4.6 ~ +4.6 안에서 걷는다. 흔들림 폭(4.2)과 되돌리는 힘(0.22)이 균형을 이뤄
// 값이 0 근처로 주저앉지도, 한쪽 끝에 붙어 살지도 않는다 — 격자가 계속 색으로 갈린다
function roll(i) {
var next = chg[i] + (rand() - 0.5) * 4.2 - chg[i] * 0.22;
if (next > 4.6) { next = 4.6; }
if (next < -4.6) { next = -4.6; }
chg[i] = next;
paint(i);
}
// 격자 미리보기(.is-demo) 동안에는 CSS 키프레임만 돌고 JS 는 DOM 을 안 건드린다 — 24프레임이 같은 순간을
// 재현해야 측정이 안 흔들린다. 포인터가 닿거나 키보드로 들어오면 그때부터 시드 시세가 실제로 갱신된다
function live() { if (hm.classList.contains('is-demo')) { hm.classList.remove('is-demo'); } }
['pointerdown', 'pointermove', 'focusin'].forEach(function (ev) { hm.addEventListener(ev, live); });
// 한 번에 타일 세 개만 굴린다. 850ms 간격이면 한 타일이 초당 0.3회 바뀌어 WCAG 2.3.1(초당 3회 미만) 아래다
var cursor = 0;
var timer = setInterval(function () {
if (hm.classList.contains('is-demo')) { return; }
if (document.visibilityState === 'hidden') { return; }
for (var k = 0; k < 3; k++) {
roll((cursor + k * 5) % tiles.length);
}
cursor = (cursor + 1) % tiles.length;
}, 850);
// 탭을 다시 보면 현재 상태를 그대로 다시 그린다 — 안 보이는 동안 갱신을 쌓아 두지 않는다
document.addEventListener('visibilitychange', function () {
if (document.visibilityState === 'visible') { tiles.forEach(function (t, i) { paint(i); }); }
});
window.addEventListener('pagehide', function () { clearInterval(timer); });// ChangeHeatmap — vanilla 판(run/296/demo/06-ticker-change-heatmap/index.html) 의 React 포트.
// 균등 격자(4×3) 히트맵. 타일마다 --chg 를 자기 자신에 올리고, 등록한 커스텀 속성이라
// 색이 컷으로 튀지 않고 이어서 흐른다. 방향은 색만으로 전하지 않고 ▲▼ 와 +/− 부호를 같이 적는다.
import { useEffect, useRef, useState } from "react";
import styles from "./ChangeHeatmap.module.scss";
export type ChangeHeatmapProps = {
/** 격자 미리보기용 2초 자동 루프. 포인터를 올리거나 키보드로 들어오면 꺼지고 실제 갱신이 시작된다 */
demo?: boolean;
/** 시드 — 같은 값이면 누가 열어도 같은 등락 흐름이 재현된다 */
seed?: number;
/** 갱신 간격(ms). WCAG 2.3.1 은 초당 3회 미만을 요구한다 */
intervalMs?: number;
};
type Box<T> = { current: T };
type Tile = { sym: string; va: number; vb: number; group: number; chg: number };
// 가짜 심볼 — 실존 종목·거래소 이름이 아니다. va 는 지금 값, vb 는 미리보기가 오가는 다음 값
const SEEDS: Array<[string, number, number]> = [
["NOVA", 2.41, 3.87], ["ORBIT", -1.32, 0.94], ["LUMEN", 4.05, 2.16], ["KEEL", -3.28, -4.41],
["AXIOM", 0.62, -1.85], ["VERDE", -2.17, -0.43], ["PIER", 3.36, 1.02], ["HALON", -4.12, -2.58],
["CIRRUS", 1.74, 4.28], ["TALUS", -0.85, 2.33], ["MERID", 2.90, 0.17], ["ZENIT", -1.96, -3.74],
];
/** 시드 PRNG — 외부 API·키가 필요 없다 */
function mulberry32(a: number) {
return function () {
a |= 0; a = a + 0x6D2B79F5 | 0;
let t = Math.imul(a ^ a >>> 15, 1 | a);
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
return ((t ^ t >>> 14) >>> 0) / 4294967296;
};
}
// 방향을 색만으로 전하지 않는다 — 화살표와 부호를 숫자 옆에 같이 적는다
const arrow = (v: number) => (v >= 0 ? "▲" : "▼");
const pct = (v: number) => (v >= 0 ? "+" : "−") + Math.abs(v).toFixed(2) + "%";
function initialTiles(): Tile[] {
// 대각선 세 파도 — (행 + 열) % 3 이 같은 타일끼리 한 파도로 묶인다
return SEEDS.map(([sym, va, vb], i) => ({
sym, va, vb, group: (Math.floor(i / 4) + (i % 4)) % 3, chg: va,
}));
}
export default function ChangeHeatmap({ demo = true, seed = 2966, intervalMs = 850 }: ChangeHeatmapProps) {
const randRef: Box<(() => number) | null> = useRef(null);
if (randRef.current === null) { randRef.current = mulberry32(seed); }
const [tiles, setTiles] = useState<Tile[]>(initialTiles);
const [live, setLive] = useState(!demo);
const cursor = useRef(0) as Box<number>;
useEffect(() => {
if (!live) { return; }
const id = window.setInterval(() => {
// 탭이 안 보이는 동안은 세지 않는다 — 돌아왔을 때 밀린 갱신이 한꺼번에 터지지 않게
if (document.visibilityState === "hidden") { return; }
const at = cursor.current;
cursor.current = (at + 1) % SEEDS.length;
setTiles((old) => old.map((t, i) => {
// 한 번에 타일 세 개만 굴린다 — 한 타일이 초당 0.3회 바뀌어 WCAG 2.3.1 아래다
const hit = [0, 1, 2].some((k) => (at + k * 5) % SEEDS.length === i);
if (!hit) { return t; }
const rand = randRef.current as () => number;
// 등락률은 -4.6 ~ +4.6 안에서 걷는다. 흔들림 폭(4.2)과 되돌리는 힘(0.22)이 균형을 이뤄
// 값이 0 근처로 주저앉지도, 한쪽 끝에 붙어 살지도 않는다
let next = t.chg + (rand() - 0.5) * 4.2 - t.chg * 0.22;
if (next > 4.6) { next = 4.6; }
if (next < -4.6) { next = -4.6; }
return { ...t, chg: next };
}));
}, intervalMs);
return () => window.clearInterval(id);
}, [live, intervalMs]);
const rootCls = [styles.root, live ? "" : styles.isDemo].filter(Boolean).join(" ");
return (
<div
className={rootCls}
onPointerMove={() => setLive(true)}
onPointerDown={() => setLive(true)}
onFocus={() => setLive(true)}
>
<div className={styles.board}>
<div className={styles.head}>
<p className={styles.title}>전 종목 등락률</p>
<span className={styles.liveTag}>실시간</span>
</div>
{/* 균등 격자 4×3. 트리맵이 아니라 칸 크기가 전부 같다 — 크기는 정보를 안 나르고 색만 나른다 */}
<ul className={styles.grid}>
{tiles.map((t) => (
<li
key={t.sym}
className={styles.tile}
data-g={t.group}
/* --chg 는 타일 자신에 올린다. @property 를 inherits: false 로 등록했으므로
부모(격자)에 걸면 타일이 못 읽는다 — 262-08 에서 같은 함정을 실측했다 */
style={live
? { "--va": t.va, "--vb": t.vb, "--chg": t.chg.toFixed(2) }
: { "--va": t.va, "--vb": t.vb }}
>
<span className={styles.sym}>{t.sym}</span>
<span className={styles.val}>
{/* 지금 값·다음 값 두 장을 같은 칸에 겹친다 — 컷 교체라 폭이 달라도 자리가 안 흔들린다 */}
<span className={`${styles.chg} ${styles.chgA}`}>{arrow(t.chg)} {pct(t.chg)}</span>
<span className={`${styles.chg} ${styles.chgB}`}>{arrow(t.vb)} {pct(t.vb)}</span>
</span>
</li>
))}
</ul>
</div>
</div>
);
}06 등락률이 색으로 바뀌는 격자 히트맵 — 측정 누적면적 29.14% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 주식 앱 ui 실시간 시세판 9종 — API 키 없이
이 데모가 속한 글
주식 앱 ui 실시간 시세판 9종 — API 키 없이주식 앱 ui 의 시세판은 사람이 새로고침하지 않아도 숫자가 저절로 바뀌는 화면입니다. 값이 오르면 초록으로, 내리면 빨강으로 한 번 번쩍인 뒤 잠잠해지는 그 장면을 아홉 가지로 만들었고, 증권사 계정도 유료 데이터 이 글의 데모 전부 보기


