07Contour bands — stacking thresholds into a topographic map
Trace several thresholds at once and tint each band a different pastel — the same field turns into a topographic scene like a weather or pressure map.
Best forData art and section backgrounds
getImageDatacreateLinearGradientrequestAnimationFrame

<main class="stage">
<div class="cd is-demo" id="cdRoot">
<p class="cd__hint">
<span>Track five thresholds at once instead of one, filling each band with a different pastel — the same scalar field becomes a topo scene like a temperature or pressure map</span>
</p>
<div class="cd__field" id="cdField" role="application" aria-label="Contour-Band Topo Map test panel" tabindex="0">
<canvas class="cd__cv" id="cdCv"></canvas>
</div>
<div class="cd__bar">
<span class="cd__meta"><b class="cd__val" id="cdN">5</b>bands · contour topo map</span>
<span class="cd__meta cd__meta--dim">canvas · getImageData · createLinearGradient</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 등고 띠(07). @keyframes 없음 — 움직임은 rAF 필드 시뮬레이션(가산 열원 + getImageData 임계값)뿐.
// 무대는 밝은 지도(paper). 편 공통 파스텔 팔레트를 --mb-* 로 (07~09 동일 계열, 항목마다 무대만 다름).
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-sky;
@include stage($stage-paper);
// ── 편 공통 파스텔 팔레트(BD9~11 원칙: 편마다 다시 정하지 않는다) ──
$mb-ink: $stage-paper; // "무데이터" 낮은 층 — 종이색 그대로(빈 바다)
$mb-band1: $subject-cream; // 저지대
$mb-band2: $subject-mint;
$mb-band3: $subject-sky;
$mb-band4: $subject-lilac;
$mb-band5: $subject-pink; // 고지대(피크)
$mb-line: $stage-ink;
$mb-text: $stage-ink;
.cd {
--mb-ink: #{$mb-ink};
--mb-band-1: #{$mb-band1};
--mb-band-2: #{$mb-band2};
--mb-band-3: #{$mb-band3};
--mb-band-4: #{$mb-band4};
--mb-band-5: #{$mb-band5};
--mb-line: #{$mb-line};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.cd__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $mb-text;
}
.cd__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $mb-ink;
box-shadow: inset 0 0 0 1px rgba($mb-line, .14);
}
.cd__field:focus-visible { outline: 2px solid $mb-band3; outline-offset: 2px; }
.cd__cv { display: block; width: 100%; height: 100%; }
.cd__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.cd__meta { font-size: 10px; font-weight: 500; color: rgba($mb-text, .72); }
.cd__val { color: $mb-band4; font-weight: 700; }
.cd__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.cd { gap: $sp-1; }
.cd__field { height: 100px; }
.cd__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 등고 한 장만 남는다.
@media (prefers-reduced-motion: reduce) {
.cd.is-still .cd__field { cursor: default !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);
}
.cd {
--mb-ink: #ffffff;
--mb-band-1: #fff7e6;
--mb-band-2: #4cd4a6;
--mb-band-3: #6ec8ff;
--mb-band-4: #b9a0ff;
--mb-band-5: #ff9ebb;
--mb-line: #17141a;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.cd__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #17141a;
}
.cd__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #ffffff;
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.14);
}
.cd__field:focus-visible {
outline: 2px solid #6ec8ff;
outline-offset: 2px;
}
.cd__cv {
display: block;
width: 100%;
height: 100%;
}
.cd__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.cd__meta {
font-size: 10px;
font-weight: 500;
color: rgba(23, 20, 26, 0.72);
}
.cd__val {
color: #b9a0ff;
font-weight: 700;
}
.cd__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.cd {
gap: 4px;
}
.cd__field {
height: 100px;
}
.cd__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.cd.is-still .cd__field {
cursor: default !important;
}
}// 등고 띠(07). 스칼라 필드 = 이동하는 열원 5개(가우시안 낙하) + 지형 파동(사인 두 겹)의 합.
// 필드를 저해상도 오프스크린 캔버스에 가산 합성(globalCompositeOperation=lighter)으로 그린 뒤
// getImageData 로 읽어 임계값 5단으로 층을 나누고, 층마다 다른 파스텔을 저해상도 색 캔버스에 칠해
// 확대(drawImage, 이미지 스무딩)하면 등고선 대신 부드러운 등고 "띠"가 된다 — 실제 등고선(마칭 스퀘어
// 경계 폴리곤)이 아니라 필드를 직접 색칠하는 채워진 등고대 방식(기상도가 쓰는 방식)이다.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형을 만든다 ──
var N_SRC = 5; // 열원 개수 — 3 은 성긴 지도, 7 은 촘촘한 등고
var BANDS = 5; // 임계값 층 수
var SPREAD = 0.46; // 열원 낙하 반경 비율(필드 폭 대비) — 크면 띠가 화면을 넓게 덮는다
// ────────────────────────────────────────────────────
var root = document.getElementById('cdRoot');
var field = document.getElementById('cdField');
var cv = document.getElementById('cdCv');
var ctx = cv.getContext('2d');
var nOut = document.getElementById('cdN');
nOut.textContent = String(BANDS);
var css = getComputedStyle(root);
function tok(name) { return css.getPropertyValue(name).trim(); }
var BAND_COL = [tok('--mb-band-1'), tok('--mb-band-2'), tok('--mb-band-3'), tok('--mb-band-4'), tok('--mb-band-5')];
var C_INK = tok('--mb-ink');
var C_LINE = tok('--mb-line');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0;
var LOWW = 0, LOWH = 0, SCALE = 5;
var fieldCv = document.createElement('canvas');
var fieldCtx = fieldCv.getContext('2d');
var bandCv = document.createElement('canvas');
var bandCtx = bandCv.getContext('2d');
var raf = 0, t0 = -1;
// 시드 고정 PRNG — 미리보기가 항상 같은 열원 경로로 흐른다
function makeRand(seed) {
var st = seed >>> 0;
return function () {
st = (st + 0x6D2B79F5) >>> 0;
var t = st;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
var rand = makeRand(0x317A07);
var sources = [];
function buildSources() {
sources = [];
for (var i = 0; i < N_SRC; i++) {
sources.push({
fx: 0.35 + rand() * 1.6, fy: 0.28 + rand() * 1.4,
ph: rand() * Math.PI * 2, ax: 0.22 + rand() * 0.24, ay: 0.22 + rand() * 0.24,
cx: rand(), cy: rand()
});
}
}
function fit() {
var r = field.getBoundingClientRect();
W = r.width; H = r.height;
cv.width = Math.round(W); cv.height = Math.round(H);
LOWW = Math.max(48, Math.round(W / SCALE));
LOWH = Math.max(30, Math.round(H / SCALE));
fieldCv.width = LOWW; fieldCv.height = LOWH;
bandCv.width = LOWW; bandCv.height = LOWH;
ctx.imageSmoothingEnabled = true;
}
// 열원 위치 — 리사주 곡선으로 화면을 골고루 도는 궤도(누적면적 확보)
function srcPos(s, t) {
return [
(s.cx + Math.sin(t * s.fx + s.ph) * s.ax) * LOWW,
(s.cy + Math.cos(t * s.fy + s.ph * 1.3) * s.ay) * LOWH
];
}
function drawField(t) {
fieldCtx.clearRect(0, 0, LOWW, LOWH);
fieldCtx.globalCompositeOperation = 'source-over';
fieldCtx.fillStyle = '#000';
fieldCtx.fillRect(0, 0, LOWW, LOWH);
fieldCtx.globalCompositeOperation = 'lighter';
var rad = Math.max(LOWW, LOWH) * SPREAD;
for (var i = 0; i < sources.length; i++) {
var p = srcPos(sources[i], t);
var g = fieldCtx.createRadialGradient(p[0], p[1], 0, p[0], p[1], rad);
g.addColorStop(0, 'rgba(255,255,255,0.95)');
g.addColorStop(0.55, 'rgba(255,255,255,0.38)');
g.addColorStop(1, 'rgba(255,255,255,0)');
fieldCtx.fillStyle = g;
fieldCtx.fillRect(0, 0, LOWW, LOWH);
}
fieldCtx.globalCompositeOperation = 'source-over';
}
// 지형 파동 — 열원과 무관하게 화면 전체를 항상 등고대로 채우는 배경 항(기상도의 "언제나 있는" 등고)
function terrain(nx, ny, t) {
var a = Math.sin(nx * 5.4 + t * 0.25) * Math.cos(ny * 4.1 - t * 0.19);
var b = Math.sin((nx + ny) * 3.1 - t * 0.31);
return (0.5 + 0.32 * a + 0.18 * b);
}
function classify(t) {
var img = fieldCtx.getImageData(0, 0, LOWW, LOWH);
var d = img.data;
var thr = [];
for (var k = 1; k <= BANDS; k++) thr.push(k / (BANDS + 1));
for (var y = 0; y < LOWH; y++) {
for (var x = 0; x < LOWW; x++) {
var idx = (y * LOWW + x) * 4;
var v = d[idx] / 255; // 열원 필드(0..1)
var mix = v * 0.72 + terrain(x / LOWW, y / LOWH, t) * 0.28;
var band = 0;
for (var k2 = 0; k2 < thr.length; k2++) if (mix >= thr[k2]) band = k2 + 1;
bandCtx.fillStyle = band === 0 ? C_INK : BAND_COL[band - 1];
bandCtx.fillRect(x, y, 1, 1);
}
}
}
function draw(t) {
drawField(t);
classify(t);
ctx.clearRect(0, 0, W, H);
ctx.drawImage(bandCv, 0, 0, LOWW, LOWH, 0, 0, W, H);
// 대기 하이라이트 — 위쪽에서 은은하게 밝아지는 선형 그라디언트(지도 위 유리 반사감)
var sky = ctx.createLinearGradient(0, 0, 0, H);
sky.addColorStop(0, 'rgba(255,255,255,0.22)');
sky.addColorStop(0.4, 'rgba(255,255,255,0.04)');
sky.addColorStop(1, 'rgba(255,255,255,0)');
ctx.fillStyle = sky;
ctx.fillRect(0, 0, W, H);
// 등고 경계선 — 저해상도 색 경계를 살짝 도드라지게(격자 라인 몇 개)
ctx.strokeStyle = C_LINE;
ctx.lineWidth = 1;
ctx.globalAlpha = 0.14;
for (var gy = 0; gy < 4; gy++) {
var yy = (gy + 0.5) / 4 * H;
ctx.beginPath(); ctx.moveTo(0, yy); ctx.lineTo(W, yy); ctx.stroke();
}
ctx.globalAlpha = 1;
}
function stillFrame() { draw(0.4); }
function frame(now) {
if (t0 < 0) t0 = now;
var t = (now - t0) / 1000;
draw(t);
raf = requestAnimationFrame(frame);
}
window.addEventListener('resize', function () {
fit();
if (reduce) stillFrame();
});
fit();
buildSources();
if (reduce) {
stillFrame();
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// ContourBand — vanilla 판(run/317/demo/07-metaball-contour-band-topo/index.html) 의 React 포트.
// 등고 띠 — 열원 5개(가우시안 낙하)와 지형 파동을 저해상도 캔버스에 가산 합성하고 getImageData 로
// 읽어 임계값 5단으로 층을 나눠 층마다 다른 파스텔 띠를 채운다. 위상 지도(기상도) 장면.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ContourBand.module.scss";
export type ContourBandProps = {
duration?: string;
easing?: string;
color?: string;
sources?: number; // 열원 개수 — 변형 "성긴/촘촘한 등고" 축
bands?: number; // 임계값 층 수
spread?: number; // 열원 낙하 반경 비율 — 크면 띠가 화면을 넓게 덮는다
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type Source = { fx: number; fy: number; ph: number; ax: number; ay: number; cx: number; cy: number };
const FIELD_LABEL = "등고 띠 위상 지도 실험판";
const SCALE = 5;
function makePRNG(seed: number): () => number {
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;
};
}
export default function ContourBand({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#6ec8ff",
sources: nSrc = 5,
bands = 5,
spread = 0.46,
}: ContourBandProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const sizeRef = useRef({ w: 0, h: 0 }) as Box<{ w: number; h: number }>;
const lowRef = useRef({ w: 0, h: 0 }) as Box<{ w: number; h: number }>;
const fieldCtxRef = useRef<CanvasRenderingContext2D | null>(null);
const bandCtxRef = useRef<CanvasRenderingContext2D | null>(null);
const colRef = useRef<{ band: string[]; ink: string; line: string } | null>(null);
const srcRef = useRef(nSrc) as Box<number>;
const bandsRef = useRef(bands) as Box<number>;
const spreadRef = useRef(spread) as Box<number>;
const sourcesRef = useRef<Source[]>([]) as Box<Source[]>;
const rafRef = useRef(0) as Box<number>;
const [n, setN] = useState(String(bands));
const [still, setStill] = useState(false);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function buildSources(): void {
const rand = makePRNG(0x317a07);
const arr: Source[] = [];
for (let i = 0; i < srcRef.current; i++) {
arr.push({
fx: 0.35 + rand() * 1.6, fy: 0.28 + rand() * 1.4,
ph: rand() * Math.PI * 2, ax: 0.22 + rand() * 0.24, ay: 0.22 + rand() * 0.24,
cx: rand(), cy: rand(),
});
}
sourcesRef.current = arr;
}
function srcPos(s: Source, t: number, lw: number, lh: number): [number, number] {
return [
(s.cx + Math.sin(t * s.fx + s.ph) * s.ax) * lw,
(s.cy + Math.cos(t * s.fy + s.ph * 1.3) * s.ay) * lh,
];
}
function drawField(t: number): void {
const fieldCtx = fieldCtxRef.current;
const { w: lw, h: lh } = lowRef.current;
if (!fieldCtx) return;
fieldCtx.globalCompositeOperation = "source-over";
fieldCtx.fillStyle = "#000";
fieldCtx.fillRect(0, 0, lw, lh);
fieldCtx.globalCompositeOperation = "lighter";
const rad = Math.max(lw, lh) * spreadRef.current;
const sources = sourcesRef.current;
for (let i = 0; i < sources.length; i++) {
const p = srcPos(sources[i], t, lw, lh);
const g = fieldCtx.createRadialGradient(p[0], p[1], 0, p[0], p[1], rad);
g.addColorStop(0, "rgba(255,255,255,0.95)");
g.addColorStop(0.55, "rgba(255,255,255,0.38)");
g.addColorStop(1, "rgba(255,255,255,0)");
fieldCtx.fillStyle = g;
fieldCtx.fillRect(0, 0, lw, lh);
}
fieldCtx.globalCompositeOperation = "source-over";
}
function terrain(nx: number, ny: number, t: number): number {
const a = Math.sin(nx * 5.4 + t * 0.25) * Math.cos(ny * 4.1 - t * 0.19);
const b = Math.sin((nx + ny) * 3.1 - t * 0.31);
return 0.5 + 0.32 * a + 0.18 * b;
}
function classify(t: number): void {
const fieldCtx = fieldCtxRef.current;
const bandCtx = bandCtxRef.current;
const col = colRef.current;
const { w: lw, h: lh } = lowRef.current;
if (!fieldCtx || !bandCtx || !col) return;
const img = fieldCtx.getImageData(0, 0, lw, lh);
const d = img.data;
const nB = bandsRef.current;
const thr: number[] = [];
for (let k = 1; k <= nB; k++) thr.push(k / (nB + 1));
for (let y = 0; y < lh; y++) {
for (let x = 0; x < lw; x++) {
const idx = (y * lw + x) * 4;
const v = d[idx] / 255;
const mix = v * 0.72 + terrain(x / lw, y / lh, t) * 0.28;
let band = 0;
for (let k2 = 0; k2 < thr.length; k2++) if (mix >= thr[k2]) band = k2 + 1;
bandCtx.fillStyle = band === 0 ? col.ink : col.band[band - 1];
bandCtx.fillRect(x, y, 1, 1);
}
}
}
function draw(t: number): void {
const ctx = ctxRef.current;
const bandCtx = bandCtxRef.current;
const col = colRef.current;
const { w: W, h: H } = sizeRef.current;
const { w: lw, h: lh } = lowRef.current;
if (!ctx || !bandCtx || !col) return;
drawField(t);
classify(t);
ctx.clearRect(0, 0, W, H);
ctx.drawImage(bandCvOf(), 0, 0, lw, lh, 0, 0, W, H);
const sky = ctx.createLinearGradient(0, 0, 0, H);
sky.addColorStop(0, "rgba(255,255,255,0.22)");
sky.addColorStop(0.4, "rgba(255,255,255,0.04)");
sky.addColorStop(1, "rgba(255,255,255,0)");
ctx.fillStyle = sky;
ctx.fillRect(0, 0, W, H);
ctx.strokeStyle = col.line;
ctx.lineWidth = 1;
ctx.globalAlpha = 0.14;
for (let gy = 0; gy < 4; gy++) {
const yy = ((gy + 0.5) / 4) * H;
ctx.beginPath(); ctx.moveTo(0, yy); ctx.lineTo(W, yy); ctx.stroke();
}
ctx.globalAlpha = 1;
}
// 저해상도 캔버스 참조 — fit 에서 생성한 것을 다시 얻는 헬퍼
const fieldCvRef = useRef<HTMLCanvasElement | null>(null);
const bandCvRef = useRef<HTMLCanvasElement | null>(null);
function fieldCvOf(): HTMLCanvasElement { return fieldCvRef.current as HTMLCanvasElement; }
function bandCvOf(): HTMLCanvasElement { return bandCvRef.current as HTMLCanvasElement; }
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const ctx = cv.getContext("2d");
if (!ctx) return;
const r = field.getBoundingClientRect();
sizeRef.current.w = r.width;
sizeRef.current.h = r.height;
cv.width = Math.round(r.width);
cv.height = Math.round(r.height);
const lw = Math.max(48, Math.round(r.width / SCALE));
const lh = Math.max(30, Math.round(r.height / SCALE));
lowRef.current = { w: lw, h: lh };
const fc = fieldCvOf(); fc.width = lw; fc.height = lh;
const bc = bandCvOf(); bc.width = lw; bc.height = lh;
fieldCtxRef.current = fc.getContext("2d");
bandCtxRef.current = bc.getContext("2d");
ctx.imageSmoothingEnabled = true;
ctxRef.current = ctx;
}
function stillFrame(): void { draw(0.4); }
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const t = (now - t0) / 1000;
draw(t);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
srcRef.current = nSrc;
bandsRef.current = bands;
spreadRef.current = spread;
const cs = getComputedStyle(root);
const tok = (name: string) => cs.getPropertyValue(name).trim();
colRef.current = {
band: [tok("--mb-band-1"), tok("--mb-band-2"), tok("--mb-band-3"), tok("--mb-band-4"), tok("--mb-band-5")],
ink: tok("--mb-ink"),
line: tok("--mb-line"),
};
buildSources();
fit();
setN(String(bands));
function onResize(): void {
fit();
if (reduced()) stillFrame();
}
window.addEventListener("resize", onResize);
if (reduced()) {
stillFrame();
setStill(true);
} else {
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [nSrc, bands, spread]);
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div className={rootCls} ref={rootRef} style={vars}>
<p className={styles.hint}>
<span>임계값 하나가 아니라 다섯 개를 동시에 추적해 층마다 다른 파스텔을 채우면 같은 스칼라 필드가 온도·기압 지도 같은 위상 띠 장면이 됩니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}><b className={styles.val}>{n}</b>단 등고 띠 · 위상 지도</span>
<span className={styles.metaDim}>canvas · getImageData · createLinearGradient</span>
</div>
</div>
</main>
);
}07 Contour bands — stacking thresholds into a topographic map — Measured area 52.869% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · 9 Metaball Effect Scenes, Marching Squares Canvas
The post this demo lives in
9 Metaball Effect Scenes, Marching Squares CanvasNo library, no WebGL — marching squares links grid cells whose field crosses one threshold, and blobs merge and split li See all demos in this post

