Public
Edited
May 15
Insert cell
d3 = require("d3@7")
Insert cell
cloud = require("d3-cloud@1.2.5")
Insert cell
colorPalette = [
"#F5F5F4", "#FDFDDE", "#CFFDA4", "#FDA65E",
"#87B6FF", "#FDA25B"
]
Insert cell
randomColor = () =>
colorPalette[Math.floor(Math.random() * colorPalette.length)]
Insert cell
breakTextByLength = (text, maxChars = 20) => {
const lines = []
for (let i = 0; i < text.length; i += maxChars) {
lines.push(text.slice(i, i + maxChars))
}
return lines.join("\n")
}
Insert cell
interpolateColor = (a, b, t) => {
const ca = d3.color(a), cb = d3.color(b)
const r = ca.r + (cb.r - ca.r) * t
const g = ca.g + (cb.g - ca.g) * t
const b_ = ca.b + (cb.b - ca.b) * t
return `rgb(${r},${g},${b_})`
}
Insert cell
sheetUrl = "https://docs.google.com/spreadsheets/d/e/2PACX-1vRuOAZZ_qU9cd_XY0RUGqenMQCWvzXa9j98GaY93uVLiJXdCANOxnxAlP59g5KtB0sG5VCGm_i7d6YH/pub?gid=1262353735&single=true&output=csv"
Insert cell
loadData = async () => {
const raw = await d3.csv(sheetUrl)
const cleaned = raw.map(d => d["1열"])
.filter(Boolean)
.map(w => w.trim())
.filter(w => w.length >= 1 && w.length <= 100)

const recent = cleaned.slice(-30).reverse()
return recent.map(text => ({ text }))
}
Insert cell
{
if (!globalThis.frequencies) globalThis.frequencies = []
}
Insert cell
{
globalThis.frequencies = await loadData()
}
Insert cell
{
setInterval(async () => {
console.log("⏳ 데이터 자동 갱신 중...")
globalThis.frequencies = await loadData()
}, 10000)
}
Insert cell
viewof wordcloud = {
// _____ 캔버스 초기화 (1920×1080) _____
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
canvas.width = 1920;
canvas.height = 1080;
canvas.style = "display:block;margin:0 auto;background:#00b140";

// _____ 이모지 캐시 및 정규식 _____
const emojiCache = {};
let emojiRegex;
try { emojiRegex = /\p{Emoji}/u; }
catch { emojiRegex = /[\uD800-\uDBFF][\uDC00-\uDFFF]/; }

// _____ 데이터 로드 함수 _____
async function fetchData() {
const data = await loadData();
return data.map(d => d.text);
}

// _____ 물리엔진 및 타이밍 설정 _____
const gravity = 500; // 중력 가속도(px/s²)
const restitution = 0.2; // 반발 계수
const dt = 1/60; // 고정 프레임 시간
const cycleDuration = 30000; // 색상 전환 주기(ms)
const spawnDelay = 3000; // 스폰 간격(ms)
const sizeOptions = [40, 50, 60, 70];

let bodies = [];
let dataQueue = [];
let nextSpawnTime = 0;

// _____ 바디(문자열) 생성 함수 _____
function createBody(text) {
// 줄바꿈: 10자마다
const size = sizeOptions[Math.floor(Math.random() * sizeOptions.length)];
ctx.font = `${size}px sans-serif`;
const raw = text;
const lines = raw.length > 10 ? raw.match(/.{1,10}/g) : [raw];
const widths = lines.map(line => ctx.measureText(line).width);
const width = Math.max(...widths);
const height = size * lines.length;
const x = Math.random() * (canvas.width - width);
const y = -height;
// 이모지 이미지 미리 로드
for (const ch of lines.join('')) {
if (emojiRegex.test(ch) && !emojiCache[ch]) {
const code = ch.codePointAt(0).toString(16);
const img = new Image();
img.src = `https://twemoji.maxcdn.com/v/latest/72x72/${code}.png`;
emojiCache[ch] = img;
}
}
return {
textLines: lines,
size, width, height,
x, y,
vx: 0, vy: 0,
settled: false,
prevColor: randomColor(),
targetColor: randomColor(),
colorStart: performance.now(),
colorDuration: cycleDuration
};
}

// _____ 리셋: 대기열 초기화 및 큐 채우기 _____
function reset() {
bodies = [];
fetchData().then(texts => { dataQueue = [...texts]; nextSpawnTime = 0; });
}

// _____ 스폰 로직: 일정 시간마다 위에서 생성 _____
function maybeSpawn(now) {
if (dataQueue.length && now >= nextSpawnTime) {
bodies.push(createBody(dataQueue.shift()));
nextSpawnTime = now + spawnDelay;
}
}

// _____ 물리 + 렌더 루프 _____
function draw(now) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = "#00b140";
ctx.fillRect(0, 0, canvas.width, canvas.height);

// 스폰 체크
maybeSpawn(now);

// 물리 연산
for (const b of bodies) {
if (!b.settled) {
b.vy += gravity * dt;
b.x += b.vx * dt;
b.y += b.vy * dt;
// 바닥 충돌
if (b.y + b.height > canvas.height) {
b.y = canvas.height - b.height;
b.vy *= -restitution;
if (Math.abs(b.vy) < 10) { b.vy = 0; b.settled = true; }
}
// 이전 바디와 스택 충돌
for (const o of bodies) {
if (o === b || !o.settled) continue;
const overlapY = (b.y + b.height) - o.y;
if (overlapY > 0 && b.x + b.width > o.x && b.x < o.x + o.width) {
b.y -= overlapY;
b.vy *= -restitution;
if (Math.abs(b.vy) < 10) { b.vy = 0; b.settled = true; }
}
}
}
}

// 렌더링
for (const b of bodies) {
// 색상 전환
const t = Math.min(1, (now - b.colorStart) / b.colorDuration);
const color = interpolateColor(b.prevColor, b.targetColor, t);
if (t >= 1) {
b.prevColor = b.targetColor;
b.targetColor = randomColor();
b.colorStart = now;
}
ctx.font = `${b.size}px sans-serif`;
ctx.fillStyle = color;
b.textLines.forEach((line, i) => {
ctx.fillText(line, b.x, b.y + i * b.size);
});
}

// 스택 높이 체크 (70%)
const settled = bodies.filter(b => b.settled);
if (settled.length) {
const minY = Math.min(...settled.map(b => b.y));
if (canvas.height - minY > canvas.height * 0.7) {
reset();
}
}

// 대기열이 비고 모든 바디가 정착된 경우 자동 리셋
if (dataQueue.length === 0 && bodies.length > 0 && bodies.every(b => b.settled)) {
reset();
}

requestAnimationFrame(draw);
}

// 초기 실행
reset();
requestAnimationFrame(draw);

return canvas;
}

Insert cell

Purpose-built for displays of data

Observable is your go-to platform for exploring data and creating expressive data visualizations. Use reactive JavaScript notebooks for prototyping and a collaborative canvas for visual data exploration and dashboard creation.
Learn more