worldcloud / index.html
Lashtw's picture
Update index.html
c6445cb verified
Raw History Blame Contribute Delete
22.2 kB
<!DOCTYPE html>
<html lang="zh-Hant">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>即時互動文字雲 - 教師主控端</title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+TC:wght@400;500;700;800;900&display=swap" rel="stylesheet">
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/wordcloud2.js/1.0.2/wordcloud2.min.js"></script>
<style>
body {
font-family: 'Noto Sans TC', sans-serif;
}
.overlay {
display: none;
position: fixed;
inset: 0;
z-index: 1000;
background: rgba(15, 23, 42, 0.6);
backdrop-filter: blur(4px);
align-items: center;
justify-content: center;
}
.overlay.active {
display: flex;
}
/* Custom Scrollbar for list panels */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: #f1f5f9;
border-radius: 8px;
}
::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 8px;
}
::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
</style>
</head>
<body class="bg-slate-100 text-slate-800 h-screen overflow-hidden flex flex-col">
<div id="loading" class="fixed inset-0 bg-slate-900/80 text-white flex flex-col items-center justify-center z-[2000] transition-opacity duration-300">
<div class="w-12 h-12 border-4 border-orange-500 border-t-transparent rounded-full animate-spin mb-4"></div>
<p class="text-lg font-medium tracking-wider">正在載入文字雲系統...</p>
</div>
<div class="flex-1 flex flex-col lg:flex-row h-full overflow-hidden">
<!-- ================= 左側:大畫布文字雲區域 ================= -->
<main class="flex-1 flex flex-col p-4 lg:p-6 overflow-hidden relative min-h-0">
<!-- 頂部資訊條:題目與總數統計 -->
<header class="flex items-center justify-between bg-white px-6 py-3.5 rounded-2xl shadow-sm border border-slate-200/80 mb-4 shrink-0">
<div class="flex items-center gap-3 overflow-hidden mr-4">
<span class="text-xl">📌</span>
<h2 id="questionDisplay" class="text-base sm:text-lg font-bold text-slate-700 truncate">
等待教師發布題目...
</h2>
</div>
<div class="flex items-center gap-2 bg-orange-50 px-4 py-1.5 rounded-xl border border-orange-200 shrink-0">
<span class="text-xs sm:text-sm font-semibold text-orange-600">總詞彙數量</span>
<span id="wordCount" class="text-lg sm:text-xl font-extrabold text-orange-600">0</span>
</div>
</header>
<!-- Canvas 畫布容器 -->
<div id="cloud-container" class="flex-1 bg-white rounded-2xl shadow-sm border border-slate-200/80 relative overflow-hidden flex items-center justify-center">
<canvas id="word-canvas" class="w-full h-full cursor-pointer block"></canvas>
<!-- 右下角開發者資訊標籤 -->
<div class="absolute bottom-4 right-4 bg-white/90 backdrop-blur border border-slate-200/80 px-3.5 py-1.5 rounded-xl text-xs text-slate-500 shadow-sm z-10 flex items-center gap-2 select-none">
<span>程式設計者:精華國中 藍星宇</span>
<span class="text-slate-300">|</span>
<a href="https://www.facebook.com/groups/1554372228718393" target="_blank" rel="noopener" class="text-blue-600 hover:underline font-medium">
臉書社團:萬物皆數
</a>
</div>
</div>
</main>
<!-- ================= 右側:控制中心工具欄 ================= -->
<aside class="w-full lg:w-80 xl:w-96 bg-white border-t lg:border-t-0 lg:border-l border-slate-200 p-5 flex flex-col justify-between gap-5 overflow-y-auto shrink-0 z-20">
<!-- 上半部:標題、QR Code 與發布區域 -->
<div class="space-y-5">
<!-- 標頭列 -->
<div class="flex items-center justify-between border-b border-slate-100 pb-3">
<div class="flex items-center gap-2">
<span class="text-2xl">☁️</span>
<div>
<h1 class="text-lg font-bold text-slate-800 leading-tight">文字雲主控端</h1>
<p class="text-xs text-slate-400">即時互動與詞彙視覺化工具</p>
</div>
</div>
<div class="bg-orange-500 text-white font-black text-sm px-3 py-1 rounded-xl shadow-sm">
房號:<span id="roomCodeDisplay">----</span>
</div>
</div>
<!-- QR Code 掃碼加入區 -->
<div class="bg-slate-50 border border-slate-200/80 rounded-2xl p-4 flex flex-col items-center">
<p class="text-xs font-semibold text-slate-500 mb-2">學生掃描加入</p>
<div class="p-2.5 bg-white rounded-xl border border-slate-200 shadow-sm mb-3">
<div id="qrcode" class="flex justify-center items-center"></div>
</div>
<div class="grid grid-cols-2 gap-2 w-full">
<button id="btnCopyLink" class="px-3 py-2 bg-white hover:bg-slate-100 border border-slate-200 text-slate-700 text-xs font-semibold rounded-xl transition-all shadow-sm flex items-center justify-center gap-1">
📋 複製連結
</button>
<button id="btnOpenGuest" class="px-3 py-2 bg-white hover:bg-slate-100 border border-slate-200 text-slate-700 text-xs font-semibold rounded-xl transition-all shadow-sm flex items-center justify-center gap-1">
🔗 開啟學員端
</button>
</div>
</div>
<!-- 發布討論題目 -->
<div class="space-y-2">
<label for="questionInput" class="text-xs font-bold text-slate-600 block">設定討論題目</label>
<div class="flex gap-2">
<input id="questionInput" type="text" placeholder="輸入題目,學生端同步顯示..." class="flex-1 px-3 py-2 text-sm bg-slate-50 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-orange-400 focus:bg-white transition-all">
<button id="btnQuestion" class="px-4 py-2 bg-orange-500 hover:bg-orange-600 active:scale-95 text-white font-semibold text-sm rounded-xl transition-all shadow-sm shrink-0">
送出
</button>
</div>
</div>
<!-- 房間設定開關 -->
<div class="space-y-3 border-t border-slate-100 pt-3">
<div class="flex items-center justify-between p-2.5 rounded-xl hover:bg-slate-50 transition-colors">
<div>
<div class="text-xs font-bold text-slate-700">🔒 鎖定房間</div>
<div class="text-[11px] text-slate-400">開啟後將暫停接收學生新回應</div>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" id="lockToggle" class="sr-only peer">
<div class="w-11 h-6 bg-slate-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-slate-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-orange-500"></div>
</label>
</div>
<div class="flex items-center justify-between p-2.5 rounded-xl hover:bg-slate-50 transition-colors">
<div>
<div class="text-xs font-bold text-slate-700">🔄 允許多次送出</div>
<div class="text-[11px] text-slate-400">學生可重複提交多個詞彙</div>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" id="toggleSwitch" class="sr-only peer">
<div class="w-11 h-6 bg-slate-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-slate-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-orange-500"></div>
</label>
</div>
</div>
</div>
<!-- 下半部:工具選單按鈕區 -->
<div class="space-y-2 border-t border-slate-100 pt-3">
<div class="text-xs font-bold text-slate-400 mb-2">工具選單</div>
<div class="grid grid-cols-3 gap-2">
<button id="btnFullScreen" class="px-3 py-2.5 bg-slate-100 hover:bg-slate-200 text-slate-700 text-xs font-semibold rounded-xl transition-all flex items-center justify-center gap-1">
⛶ 全螢幕
</button>
<button id="btnScreenshot" class="px-3 py-2.5 bg-slate-100 hover:bg-slate-200 text-slate-700 text-xs font-semibold rounded-xl transition-all flex items-center justify-center gap-1">
📸 截圖
</button>
<button id="btnCSV" class="px-3 py-2.5 bg-slate-100 hover:bg-slate-200 text-slate-700 text-xs font-semibold rounded-xl transition-all flex items-center justify-center gap-1">
📄 匯出 CSV
</button>
</div>
<button id="btnReset" class="w-full py-2.5 bg-red-50 hover:bg-red-100 text-red-600 border border-red-200 font-semibold text-xs rounded-xl transition-all shadow-sm mt-2 flex items-center justify-center gap-1">
🗑 清除重來
</button>
</div>
</aside>
</div>
<!-- 詞彙選擇列表彈窗 -->
<div class="overlay" id="pickOverlay">
<div class="bg-white p-6 rounded-2xl max-w-lg w-[90%] max-h-[75vh] flex flex-col shadow-2xl">
<h3 class="text-lg font-bold text-slate-800 mb-3">選擇要檢視或放大的詞彙:</h3>
<div id="pickList" class="flex-1 overflow-y-auto space-x-1 space-y-2 py-2"></div>
<div class="mt-4 text-right border-t pt-3">
<button id="pickClose" class="px-5 py-2 bg-slate-100 hover:bg-slate-200 text-slate-700 text-sm font-semibold rounded-xl transition-colors">
取消
</button>
</div>
</div>
</div>
<!-- 詞彙放大顯示彈窗 (加寬加高優化) -->
<div class="overlay" id="zoomOverlay">
<div class="bg-white p-8 md:p-10 rounded-3xl max-w-2xl md:max-w-3xl lg:max-w-4xl w-[92%] text-center shadow-2xl transition-all">
<div id="zoomText" class="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-extrabold text-slate-800 break-words leading-snug my-6 md:my-8 px-2 max-h-[60vh] overflow-y-auto"></div>
<div class="flex gap-3 justify-center border-t border-slate-100 pt-6">
<button id="zoomDelete" class="px-6 py-2.5 bg-red-500 hover:bg-red-600 text-white text-sm font-semibold rounded-xl transition-colors shadow-sm">
🗑 刪除此詞彙
</button>
<button id="zoomClose" class="px-6 py-2.5 bg-slate-100 hover:bg-slate-200 text-slate-700 text-sm font-semibold rounded-xl transition-colors">
關閉
</button>
</div>
</div>
</div>
<!-- 清除數據確認彈窗 -->
<div class="overlay" id="resetConfirmOverlay">
<div class="bg-white p-6 rounded-2xl max-w-sm w-[90%] text-center shadow-2xl">
<div class="text-3xl mb-2">⚠️</div>
<h3 class="text-lg font-bold text-slate-800 mb-2">重置所有資料?</h3>
<p class="text-sm text-slate-500 mb-6">這將會清除當前畫面上所有的文字雲詞彙,此動作無法復原。</p>
<div class="flex gap-3 justify-center">
<button id="btnConfirmReset" class="flex-1 py-2.5 bg-red-500 hover:bg-red-600 text-white text-sm font-semibold rounded-xl transition-colors">
確定清除
</button>
<button id="btnCancelReset" class="flex-1 py-2.5 bg-slate-100 hover:bg-slate-200 text-slate-700 text-sm font-semibold rounded-xl transition-colors">
取消
</button>
</div>
</div>
</div>
<!-- 複製成功 Toast 提示 -->
<div id="toast" class="fixed bottom-6 left-1/2 -translate-x-1/2 bg-slate-900/90 text-white px-5 py-2.5 rounded-2xl shadow-xl text-sm font-medium transition-all opacity-0 pointer-events-none z-[3000]">
已複製學員端連結!
</div>
<script type="module">
import { initializeApp } from 'https://www.gstatic.com/firebasejs/10.12.2/firebase-app.js';
import { getFirestore, doc, setDoc, onSnapshot, updateDoc, deleteField } from 'https://www.gstatic.com/firebasejs/10.12.2/firebase-firestore.js';
// Firebase Configuration
const firebaseConfig = {
apiKey: "AIzaSyAcIQYst9gFRyE2nCqCwNgCL3LG2VkBXbM",
authDomain: "factor-529c9.firebaseapp.com",
projectId: "factor-529c9",
storageBucket: "factor-529c9.firebasestorage.app",
messagingSenderId: "575829635896",
appId: "1:575829635896:web:a65b6edd9d163375b087cd"
};
const app = initializeApp(firebaseConfig);
const db = getFirestore(app);
// Application State Variables
const roomCode = Math.floor(1000 + Math.random() * 9000).toString();
let allowMultiple = false;
let isLocked = false;
let wordData = {};
let isDrawing = false;
let drawQueue = false;
let selectedWord = null;
// UI Elements Reference
const canvas = document.getElementById('word-canvas');
const container = document.getElementById('cloud-container');
const roomRef = doc(db, 'rooms', roomCode);
const clientUrl = `${location.origin}${location.pathname.replace(/[^/]*$/, '')}guest.html?room=${roomCode}`;
async function initRoom() {
try {
await setDoc(roomRef, { roomCode, locked: false, allowMultiple: false, words: {}, question: '' }, { merge: true });
document.getElementById('roomCodeDisplay').textContent = roomCode;
// Listen for real-time Firestore updates
onSnapshot(roomRef, (docSnap) => {
if (!docSnap.exists()) return;
const data = docSnap.data();
if (data.allowMultiple !== allowMultiple) {
allowMultiple = data.allowMultiple;
document.getElementById('toggleSwitch').checked = !!allowMultiple;
}
if (data.locked !== isLocked) {
isLocked = !!data.locked;
document.getElementById('lockToggle').checked = isLocked;
}
document.getElementById('questionDisplay').textContent = data.question ? data.question : '等待教師發布題目...';
document.getElementById('wordCount').textContent = Object.keys(data.words || {}).length;
const newWords = data.words || {};
if (JSON.stringify(newWords) !== JSON.stringify(wordData)) {
wordData = newWords;
queueDraw();
}
});
} catch (err) {
console.error("Firebase init error:", err);
}
}
function queueDraw() {
if (isDrawing) { drawQueue = true; return; }
drawCloud();
}
function drawCloud() {
canvas.width = container.clientWidth;
canvas.height = container.clientHeight;
const list = Object.entries(wordData).map(([w, c]) => [w, c]);
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (list.length === 0) {
ctx.font = '16px "Noto Sans TC"';
ctx.fillStyle = '#94A3B8';
ctx.textAlign = 'center';
ctx.fillText('等待學生送出詞彙…', canvas.width / 2, canvas.height / 2);
return;
}
isDrawing = true;
// 智慧調適文字大小:詞彙少時大幅放大標題字級,隨詞彙增加自動縮小
const wordCount = list.length;
const minDim = Math.min(canvas.width, canvas.height);
// 計算最高頻率詞彙
const maxFreq = Math.max(...list.map(item => item[1]));
WordCloud(canvas, {
list,
gridSize: Math.max(4, Math.floor(minDim / 100)),
weightFactor: (w) => {
// 依總詞數動態選擇基礎縮放比例
let baseScale;
if (wordCount <= 3) baseScale = minDim * 0.32;
else if (wordCount <= 8) baseScale = minDim * 0.22;
else if (wordCount <= 20) baseScale = minDim * 0.15;
else baseScale = minDim * 0.09;
const relativeWeight = Math.pow(w / maxFreq, 0.7);
return Math.max(16, relativeWeight * baseScale);
},
fontFamily: 'Noto Sans TC, sans-serif',
color: () => ['#F97316', '#3B82F6', '#10B981', '#8B5CF6', '#EF4444', '#F59E0B', '#06B6D4'][Math.floor(Math.random() * 7)],
rotateRatio: 0.15,
minSize: 12,
weightMode: 'size',
shape: 'circle',
ellipticity: 0.75,
backgroundColor: 'transparent'
});
}
// 監聽 WordCloud 畫布繪製完成事件
canvas.addEventListener('wordcloudstop', function onStop() {
isDrawing = false;
if (drawQueue) {
drawQueue = false;
setTimeout(drawCloud, 300);
}
});
canvas.addEventListener('click', () => {
if (Object.keys(wordData).length === 0) return;
const listEl = document.getElementById('pickList');
listEl.innerHTML = '';
Object.keys(wordData).sort((a, b) => wordData[b] - wordData[a]).forEach(word => {
const chip = document.createElement('span');
chip.className = 'inline-block m-1.5 px-4 py-2 text-sm font-medium bg-amber-50 hover:bg-amber-100 border border-amber-200 text-amber-900 rounded-2xl cursor-pointer transition-colors shadow-sm';
chip.textContent = `${word} (${wordData[word]})`;
chip.addEventListener('click', () => { hidePick(); showZoom(word); });
listEl.appendChild(chip);
});
document.getElementById('pickOverlay').classList.add('active');
});
function hidePick() { document.getElementById('pickOverlay').classList.remove('active'); }
document.getElementById('pickClose').addEventListener('click', hidePick);
function showZoom(word) {
selectedWord = word;
document.getElementById('zoomText').textContent = word;
document.getElementById('zoomOverlay').classList.add('active');
}
function hideZoom() {
document.getElementById('zoomOverlay').classList.remove('active');
selectedWord = null;
}
document.getElementById('zoomClose').addEventListener('click', hideZoom);
document.getElementById('zoomOverlay').addEventListener('click', e => {
if (e.target === document.getElementById('zoomOverlay')) hideZoom();
});
document.getElementById('zoomDelete').addEventListener('click', async () => {
if (!selectedWord) return;
await updateDoc(roomRef, { words: { [selectedWord]: deleteField() } });
hideZoom();
});
document.getElementById('lockToggle').addEventListener('change', async () => {
isLocked = document.getElementById('lockToggle').checked;
await updateDoc(roomRef, { locked: isLocked });
});
document.getElementById('toggleSwitch').addEventListener('change', async () => {
allowMultiple = document.getElementById('toggleSwitch').checked;
await updateDoc(roomRef, { allowMultiple });
});
document.getElementById('btnQuestion').addEventListener('click', async () => {
const text = document.getElementById('questionInput').value.trim();
await updateDoc(roomRef, { question: text });
document.getElementById('questionInput').value = '';
});
document.getElementById('btnFullScreen').addEventListener('click', () => {
if (!document.fullscreenElement) document.documentElement.requestFullscreen();
else document.exitFullscreen();
});
document.getElementById('btnScreenshot').addEventListener('click', () => {
const tempCanvas = document.createElement('canvas');
tempCanvas.width = canvas.width;
tempCanvas.height = canvas.height;
const tCtx = tempCanvas.getContext('2d');
tCtx.fillStyle = '#FFFFFF';
tCtx.fillRect(0, 0, tempCanvas.width, tempCanvas.height);
tCtx.drawImage(canvas, 0, 0);
const dataUrl = tempCanvas.toDataURL("image/png");
const a = document.createElement('a');
a.href = dataUrl;
a.download = `即時互動文字雲_${roomCode}.png`;
a.click();
});
document.getElementById('btnCSV').addEventListener('click', () => {
const rows = [['詞彙', '次數'], ...Object.entries(wordData)];
const csv = rows.map(r => r.map(v => `"${String(v).replace(/"/g, '""')}"`).join(',')).join('\n');
const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' });
const u = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = u;
a.download = `文字雲統計_${roomCode}.csv`;
a.click();
URL.revokeObjectURL(u);
});
// 清除重來:彈出自訂 Modal
document.getElementById('btnReset').addEventListener('click', () => {
document.getElementById('resetConfirmOverlay').classList.add('active');
});
document.getElementById('btnCancelReset').addEventListener('click', () => {
document.getElementById('resetConfirmOverlay').classList.remove('active');
});
document.getElementById('btnConfirmReset').addEventListener('click', async () => {
document.getElementById('resetConfirmOverlay').classList.remove('active');
try { await updateDoc(roomRef, { words: {} }); }
catch (e) { console.error("Reset failed:", e); }
});
// 複製連結與 Toast
function showToast(msg) {
const toast = document.getElementById('toast');
toast.textContent = msg;
toast.style.opacity = '1';
setTimeout(() => { toast.style.opacity = '0'; }, 2200);
}
document.getElementById('btnCopyLink').addEventListener('click', () => {
const tempInput = document.createElement('input');
tempInput.value = clientUrl;
document.body.appendChild(tempInput);
tempInput.select();
document.execCommand('copy');
document.body.removeChild(tempInput);
showToast('已複製學員端連結!');
});
document.getElementById('btnOpenGuest').addEventListener('click', () => {
window.open(clientUrl, '_blank', 'noopener');
});
window.addEventListener('load', () => {
initRoom();
// 產生乾淨專屬 QR Code
try {
new QRCode(document.getElementById('qrcode'), {
text: clientUrl,
width: 140,
height: 140,
colorDark: "#1E293B",
colorLight: "#FFFFFF",
correctLevel: QRCode.CorrectLevel.M
});
} catch (err) {
console.error("QRCode Error:", err);
}
// 視窗尺寸改變時重繪畫布
const resizeObserver = new ResizeObserver(() => {
queueDraw();
});
resizeObserver.observe(container);
// 淡出並隱藏載入遮罩
const loading = document.getElementById('loading');
loading.style.opacity = '0';
setTimeout(() => loading.style.display = 'none', 300);
});
</script>
<script>
window.MathJax = {
tex: { inlineMath: [['$','$'],['\\(','\\)']], displayMath: [['$$','$$'],['\\[','\\]']], processEscapes: true },
options: { skipHtmlTags: ['script','noscript','style','textarea','pre'] }
};
</script>
<script async src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-chtml.js"></script>
</body>
</html>