--- /dev/null
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>High Quality Tag Coverage (Score > 70)</title>
+ <style>
+ html, body {
+ height: 100%;
+ margin: 0;
+ overflow: hidden; /* Prevent full page scroll */
+ background: #f8f9fa;
+ color: #202124;
+ font-family: 'Google Sans', Roboto, sans-serif;
+ }
+
+ body {
+ display: flex;
+ flex-direction: column;
+ padding: 20px;
+ box-sizing: border-box;
+ }
+
+ h1 { margin-bottom: 5px; }
+ p.subtitle { color: #5f6368; margin-top: 0; margin-bottom: 20px; font-size: 0.9rem; }
+
+ /* Table Styles */
+ .table-container { overflow-x: auto; box-shadow: 0 1px 3px rgba(0,0,0,0.2); background: white; border-radius: 8px; max-height: 90vh; }
+ table { border-collapse: separate; border-spacing: 0; width: 100%; min-width: 1200px; }
+
+ /* Headers */
+ th { background-color: #f1f3f4; position: sticky; top: 0; z-index: 10; font-weight: 600; color: #5f6368; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.5px; padding: 10px 8px; border-bottom: 1px solid #ddd; text-align: left; }
+
+ /* Freeze first column */
+ th:first-child, td:first-child { position: sticky; left: 0; background-color: #fff; z-index: 11; border-right: 2px solid #ddd; min-width: 120px; }
+ th:first-child { z-index: 12; background-color: #f1f3f4; }
+
+ td { font-size: 0.85rem; padding: 8px; border-bottom: 1px solid #eee; text-align: right; border-right: 1px solid #f0f0f0; }
+ td:first-child { text-align: left; color: #202124; }
+
+ /* Row Highlighting */
+ tr:hover td { background-color: #f1f3f4 !important; }
+ /*tr.highlight-row td { background-color: #fff8e1; } /* Light Amber base */
+ /* tr.highlight-row td:first-child { color: #b06000; } /* Text color for label */
+
+ /* Ensure frozen column keeps highlight color */
+ tr.highlight-row td:first-child { background-color: #f9cc6b; }
+
+ /* Conditional Highlighting (Cells) */
+ .cell-zero { color: #e0e0e0; }
+
+ /* Override row color for specific cells */
+
+ td.cell-one { background-color: #fff5e1; color: #b06000; }
+
+ tr.highlight-row td.cell-many { background-color: #c8e6c9; } /* Darker green for contrast */
+ td.cell-many { background-color: #e6f4ea; color: #137333; }
+
+ /* Interactive Cells */
+ .clickable { cursor: pointer; }
+ .clickable:hover { box-shadow: inset 0 0 0 2px #1a73e8; }
+
+ /* Modal Styles */
+ .modal-overlay { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 1000; justify-content: center; align-items: center; }
+ .modal-active { display: flex; }
+ .modal-content { background: white; width: 90%; max-width: 600px; max-height: 80vh; border-radius: 8px; box-shadow: 0 4px 6px rgba(0,0,0,0.1); display: flex; flex-direction: column; }
+ .modal-header { padding: 16px 20px; border-bottom: 1px solid #ddd; display: flex; justify-content: space-between; align-items: center; background: #f1f3f4; border-radius: 8px 8px 0 0; }
+ .modal-title { margin: 0; font-size: 1.1rem; color: #202124; }
+ .close-btn { background: none; border: none; font-size: 1.5rem; cursor: pointer; color: #5f6368; }
+ .modal-body { padding: 20px; overflow-y: auto; }
+ ul.font-list { list-style: none; padding: 0; margin: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 10px; }
+ ul.font-list li a { text-decoration: none; color: #1a73e8; font-size: 0.95rem; display: block; padding: 4px 8px; border-radius: 4px; }
+ ul.font-list li a:hover { background-color: #e8f0fe; }
+
+ #status { padding: 10px; margin-bottom: 15px; border-radius: 4px; display: none; }
+ .loading { background-color: #e8f0fe; color: #1967d2; display: block !important; }
+ .error { background-color: #fce8e6; color: #c5221f; display: block !important; }
+ </style>
+</head>
+<body>
+
+ <h1>Global Tag Coverage (High Quality Only)</h1>
+ <p class="subtitle">
+ Fonts per Language per Tag.
+ <strong>Filtered:</strong> Score ≥ 70.
+ <span style="background:#fff8e1; padding:2px 5px; color:#b06000; border-radius:4px;">Highlighted rows</span> are priority scripts.
+ </p>
+
+ <div class="table-container">
+ <table id="dashboard">
+ <thead>
+ </thead>
+ <tbody>
+ </tbody>
+ </table>
+ </div>
+
+ <div id="modal" class="modal-overlay">
+ <div class="modal-content">
+ <div class="modal-header">
+ <h2 id="modal-title" class="modal-title">Fonts List</h2>
+ <button class="close-btn" onclick="closeModal()">×</button>
+ </div>
+ <div class="modal-body">
+ <ul id="font-list" class="font-list"></ul>
+ </div>
+ </div>
+ </div>
+
+<script>
+ // --- Configuration ---
+ const METADATA_URL = "catalog_metadata.json";
+ const TAGS_URL = "https://raw.githubusercontent.com/google/fonts/refs/heads/main/tags/all/families.csv";
+
+ // Quality Weights & Threshold
+ const WEIGHTS = { "Concept": 0.4, "Drawing": 0.3, "Spacing": 0.2, "Wordspace": 0.1 };
+ const QUALITY_THRESHOLD = 70;
+
+ const ALLOWED_SUBSETS = [
+ "latin", "devanagari", "japanese", "korean", "arabic", "thai",
+ "bengali", "chinese-simplified", "cyrillic", "greek", "hebrew",
+ "tamil", "telugu", "gujarati", "kannada", "malayalam", "gurmukhi",
+ "oriya", "myanmar", "armenian", "sinhala", "georgian", "chinese-traditional"
+ ];
+
+ const HIGHLIGHT_SUBSETS = [
+ "latin", "cyrillic", "greek", "devanagari", "hebrew", "arabic",
+ "korean", "japanese", "thai", "bengali", "chinese-simplified"
+ ];
+
+ // --- Main Execution ---
+ document.addEventListener("DOMContentLoaded", async () => {
+ const statusDiv = document.getElementById('status');
+ try {
+ const [metaRes, tagsRes] = await Promise.all([ fetch(METADATA_URL), fetch(TAGS_URL) ]);
+
+ if (!metaRes.ok) throw new Error(`Metadata Error: ${metaRes.status}`);
+ if (!tagsRes.ok) throw new Error(`Tags CSV Error: ${tagsRes.status}`);
+
+ const metadata = await metaRes.json();
+ const tagsText = await tagsRes.text();
+
+ const parsedData = parseCSVFull(tagsText);
+ const { scores, fontTagsMap, uniqueTags } = parsedData;
+
+ const tableData = aggregateData(metadata.familyMetadataList, fontTagsMap, scores, uniqueTags);
+
+ renderTable(tableData, uniqueTags);
+ statusDiv.style.display = 'none';
+
+ } catch (err) {
+ console.error(err);
+ statusDiv.textContent = `Error: ${err.message}`;
+ statusDiv.className = 'error';
+ }
+ });
+
+ // --- Data Processing ---
+ function parseCSVFull(csvText) {
+ const lines = csvText.split('\n');
+ const fontTagsMap = {};
+ const uniqueTags = new Set();
+ const rawQualityValues = {};
+
+ for (let i = 1; i < lines.length; i++) {
+ const row = lines[i].split(',');
+ if (row.length < 3) continue;
+
+ const family = row[0].trim();
+ let rawTag = row[2].trim();
+ let weightStr = (row[3] || "0").trim();
+ if (!rawTag && row[1]) rawTag = row[1].trim();
+ let weight = parseFloat(weightStr);
+
+ if (rawTag.includes("Quality/")) {
+ const parts = rawTag.split("Quality/");
+ const type = parts[1];
+ if (type && !isNaN(weight)) {
+ if (!rawQualityValues[family]) rawQualityValues[family] = {};
+ rawQualityValues[family][type] = weight;
+ }
+ }
+ else {
+ let cleanTag = rawTag.replace(/^[\/"']+|[\/"']+$/g, '');
+ if (cleanTag) {
+ uniqueTags.add(cleanTag);
+ if (!fontTagsMap[family]) fontTagsMap[family] = new Set();
+ fontTagsMap[family].add(cleanTag);
+ }
+ }
+ }
+
+ const scores = {};
+ for (const [family, categories] of Object.entries(rawQualityValues)) {
+ let totalScore = 0;
+ totalScore += (categories['Concept'] || 0) * WEIGHTS['Concept'];
+ totalScore += (categories['Drawing'] || 0) * WEIGHTS['Drawing'];
+ totalScore += (categories['Spacing'] || 0) * WEIGHTS['Spacing'];
+ totalScore += (categories['Wordspace'] || 0) * WEIGHTS['Wordspace'];
+ scores[family] = totalScore;
+ }
+
+ return { scores, fontTagsMap, uniqueTags: Array.from(uniqueTags).sort() };
+ }
+
+ function aggregateData(fontList, fontTagsMap, scores, uniqueTags) {
+ const data = {};
+
+ ALLOWED_SUBSETS.forEach(sub => {
+ data[sub] = { tagsUsedCount: 0, tagBuckets: {} };
+ uniqueTags.forEach(tag => data[sub].tagBuckets[tag] = []);
+ });
+
+ fontList.forEach(font => {
+ const familyName = font.family;
+ const quality = scores[familyName] || 0;
+ if (quality < QUALITY_THRESHOLD) return;
+
+ const fontTags = fontTagsMap[familyName];
+ if (!fontTags) return;
+
+ font.subsets.forEach(subset => {
+ if (!data[subset]) return;
+ fontTags.forEach(tag => {
+ if (data[subset].tagBuckets[tag]) {
+ data[subset].tagBuckets[tag].push(familyName);
+ }
+ });
+ });
+ });
+
+ Object.values(data).forEach(row => {
+ let used = 0;
+ uniqueTags.forEach(tag => {
+ if (row.tagBuckets[tag].length > 0) used++;
+ });
+ row.tagsUsedCount = used;
+ });
+
+ return data;
+ }
+
+function renderTable(data, uniqueTags) {
+ const table = document.getElementById('dashboard');
+ const thead = table.querySelector('thead');
+ const tbody = table.querySelector('tbody');
+
+ let headerHTML = `<tr>
+ <th>Language Subset</th>
+ <th>Tags populated</th>
+ <th>Coverage %</th>`;
+
+ uniqueTags.forEach(tag => {
+ // UPDATED: Use the full tag name directly
+ const displayName = tag;
+ headerHTML += `<th title="${tag}">${displayName}</th>`;
+ });
+ headerHTML += `</tr>`;
+ thead.innerHTML = headerHTML;
+
+ // Sort by Tags Populated Descending
+ const sortedSubsets = Object.entries(data).sort((a, b) => b[1].tagsUsedCount - a[1].tagsUsedCount);
+ const totalPossibleTags = uniqueTags.length;
+
+ sortedSubsets.forEach(([subset, rowData]) => {
+ const tr = document.createElement('tr');
+
+ // Apply Highlight Class if applicable
+ if (HIGHLIGHT_SUBSETS.includes(subset)) {
+ tr.classList.add('highlight-row');
+ }
+
+ const coveragePct = totalPossibleTags > 0 ? (rowData.tagsUsedCount / totalPossibleTags) : 0;
+ const coverageStr = (coveragePct * 100).toFixed(1) + '%';
+
+ tr.innerHTML = `
+ <td>${subset}</td>
+ <td style="font-weight:bold; text-align:right;">${rowData.tagsUsedCount}</td>
+ <td style="text-align:right; color:#5f6368;">${coverageStr}</td>
+ `;
+
+ uniqueTags.forEach(tag => {
+ const fontList = rowData.tagBuckets[tag];
+ const count = fontList.length;
+ const td = document.createElement('td');
+
+ td.textContent = count;
+
+ if (count === 0) {
+ td.className = 'cell-zero';
+ td.textContent = "-";
+ } else if (count === 1) {
+ td.className = 'cell-one clickable';
+ td.onclick = () => openModal(subset, tag, fontList);
+ } else {
+ td.className = 'cell-many clickable';
+ td.onclick = () => openModal(subset, tag, fontList);
+ }
+
+ tr.appendChild(td);
+ });
+
+ tbody.appendChild(tr);
+ });
+ }
+
+ // Modal Logic
+ const modal = document.getElementById('modal');
+ const modalTitle = document.getElementById('modal-title');
+ const fontListEl = document.getElementById('font-list');
+
+ function openModal(subset, tag, fonts) {
+ modalTitle.textContent = `${subset} — ${tag} (${fonts.length})`;
+ fontListEl.innerHTML = '';
+ fonts.sort((a, b) => a.localeCompare(b));
+ fonts.forEach(font => {
+ const li = document.createElement('li');
+ const a = document.createElement('a');
+ a.textContent = font;
+ a.href = `https://fonts.google.com/specimen/${font.replace(/ /g, '+')}`;
+ a.target = "_blank";
+ li.appendChild(a);
+ fontListEl.appendChild(li);
+ });
+ modal.classList.add('modal-active');
+ }
+
+ function closeModal() { modal.classList.remove('modal-active'); }
+ modal.addEventListener('click', (e) => { if (e.target === modal) closeModal(); });
+
+</script>
+</body>
+</html>
\ No newline at end of file
--- /dev/null
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Top 50% Fonts Tag Distribution</title>
+ <style>
+ /* --- LAYOUT --- */
+ html, body {
+ height: 100%;
+ margin: 0;
+ overflow: hidden;
+ background: #f8f9fa;
+ color: #202124;
+ font-family: 'Google Sans', Roboto, sans-serif;
+ }
+
+ body {
+ display: flex;
+ flex-direction: column;
+ padding: 20px;
+ box-sizing: border-box;
+ }
+
+ .header-section {
+ flex: 0 0 auto;
+ margin-bottom: 20px;
+ }
+
+ h1 { margin: 0 0 5px 0; }
+ p.subtitle { color: #5f6368; margin: 0 0 15px 0; font-size: 0.9rem; }
+
+ /* --- CARD & TABLE --- */
+ .card {
+ flex: 1;
+ background: white;
+ border-radius: 8px;
+ box-shadow: 0 1px 3px rgba(0,0,0,0.2);
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ }
+
+ .card-header {
+ padding: 15px 20px;
+ background: #f1f3f4;
+ border-bottom: 1px solid #ddd;
+ font-weight: 600;
+ color: #202124;
+ font-size: 1rem;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ }
+
+ .table-container {
+ flex: 1;
+ overflow: auto;
+ }
+
+ table { width: 100%; border-collapse: separate; border-spacing: 0; min-width: 800px; }
+
+ th {
+ background-color: #fff;
+ position: sticky; top: 0; z-index: 10;
+ font-size: 0.75rem;
+ text-transform: uppercase; letter-spacing: 0.5px;
+ padding: 12px 16px; border-bottom: 2px solid #eee;
+ text-align: left; color: #5f6368;
+ cursor: pointer;
+ user-select: none;
+ }
+
+ th:hover { background-color: #f1f3f4; color: #202124; }
+
+ /* Sort indicators */
+ th::after { content: ' \2195'; color: #ccc; font-size: 0.8em; margin-left: 5px; }
+ th.asc::after { content: ' \2191'; color: #202124; }
+ th.desc::after { content: ' \2193'; color: #202124; }
+
+ td {
+ padding: 10px 16px;
+ border-bottom: 1px solid #f0f0f0;
+ font-size: 0.9rem;
+ vertical-align: middle;
+ }
+
+ td.num { text-align: right; font-variant-numeric: tabular-nums; font-weight: 500; }
+ td.tag { color: #1a73e8; font-weight: 500; }
+ .ratio-context { font-size: 0.8em; color: #888; margin-left: 6px; font-weight: normal; }
+
+ tr:hover td { background-color: #f8f9fa; }
+
+ /* Visual Bars */
+ .bar-container { display: flex; align-items: center; justify-content: flex-end; gap: 10px; }
+ .bar-bg { width: 100px; height: 6px; background: #eee; border-radius: 3px; }
+ .bar-fill { height: 100%; background: #1a73e8; border-radius: 3px; transition: width 0.3s; }
+
+ #status { padding: 10px; margin-bottom: 15px; border-radius: 4px; display: none; }
+ .loading { background-color: #e8f0fe; color: #1967d2; display: block !important; }
+ .error { background-color: #fce8e6; color: #c5221f; display: block !important; }
+
+ /* Modal */
+ .modal-overlay { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 1000; justify-content: center; align-items: center; }
+ .modal-active { display: flex; }
+ .modal-content { background: white; width: 90%; max-width: 500px; max-height: 80vh; border-radius: 8px; box-shadow: 0 4px 6px rgba(0,0,0,0.1); display: flex; flex-direction: column; }
+ .modal-header { padding: 16px 20px; border-bottom: 1px solid #ddd; display: flex; justify-content: space-between; align-items: center; background: #f1f3f4; }
+ .modal-title { margin: 0; font-size: 1.1rem; color: #202124; }
+ .close-btn { background: none; border: none; font-size: 1.5rem; cursor: pointer; color: #5f6368; }
+ .modal-body { padding: 20px; overflow-y: auto; }
+ ul.font-list { list-style: none; padding: 0; margin: 0; display: flex; flex-wrap: wrap; gap: 8px; }
+ ul.font-list li a { text-decoration: none; color: #1a73e8; font-size: 0.9rem; display: block; padding: 4px 10px; background:#e8f0fe; border-radius: 12px; }
+ ul.font-list li a:hover { background-color: #d2e3fc; }
+ </style>
+</head>
+<body>
+
+ <div class="header-section">
+ <h1>Top 50% Fonts Tag Distribution</h1>
+ <p class="subtitle">Analyzing which tags are represented by the top 50% font families by usage.</p>
+ </div>
+
+ <div class="card">
+ <div class="card-header">
+ Tag Analysis
+ <span style="font-size:0.8rem; color:#5f6368; font-weight:normal;">Click headers to sort</span>
+ </div>
+ <div class="table-container">
+ <table id="main-table">
+ <thead>
+ <tr>
+ <th onclick="sortTable('tag')" id="th-tag">Tag Category</th>
+ <th onclick="sortTable('count')" id="th-count" style="text-align:right">Top 50% Count</th>
+ <th onclick="sortTable('ratio')" id="th-ratio" style="text-align:right">Ratio (vs Total)</th>
+ </tr>
+ </thead>
+ <tbody>
+ </tbody>
+ </table>
+ </div>
+ </div>
+
+ <div id="modal" class="modal-overlay">
+ <div class="modal-content">
+ <div class="modal-header">
+ <h2 id="modal-title" class="modal-title">Fonts List</h2>
+ <button class="close-btn" onclick="closeModal()">×</button>
+ </div>
+ <div class="modal-body">
+ <ul id="font-list" class="font-list"></ul>
+ </div>
+ </div>
+ </div>
+
+<script>
+ // --- Configuration ---
+ const METADATA_URL = "catalog_metadata.json";
+ const TAGS_URL = "https://raw.githubusercontent.com/google/fonts/refs/heads/main/tags/all/families.csv";
+ const USAGE_URL = "usage_data.csv";
+
+ // Global Data Storage
+ let TABLE_DATA = [];
+ if(!window.TAG_LOOKUP) window.TAG_LOOKUP = {};
+
+ // --- Main Execution ---
+ document.addEventListener("DOMContentLoaded", async () => {
+ const statusDiv = document.getElementById('status');
+ try {
+ const [metaRes, tagsRes, usageRes] = await Promise.all([
+ fetch(METADATA_URL),
+ fetch(TAGS_URL),
+ fetch(USAGE_URL)
+ ]);
+
+ if (!metaRes.ok) throw new Error(`Metadata Error: ${metaRes.status}`);
+ if (!tagsRes.ok) throw new Error(`Tags CSV Error: ${tagsRes.status}`);
+ if (!usageRes.ok) throw new Error(`Usage CSV Error: ${usageRes.status}`);
+
+ const metadata = await metaRes.json();
+ const tagsText = await tagsRes.text();
+ const usageText = await usageRes.text();
+
+ const fontTagsMap = parseTags(tagsText);
+ const usageMap = parseUsageCSV(usageText);
+
+ // 1. Identify Top 50% Fonts
+ const top50Fonts = getTop50PercentFonts(metadata.familyMetadataList, usageMap);
+
+ // 2. Aggregation
+ const tagCountsTop50 = countTagsInSet(top50Fonts, fontTagsMap);
+ const tagCountsTotal = countTotalTags(metadata.familyMetadataList, fontTagsMap);
+
+ // 3. Merge Data for Table
+ TABLE_DATA = mergeData(tagCountsTop50, tagCountsTotal);
+
+ // 4. Initial Render (Sort by Count Descending)
+ sortTable('count');
+ statusDiv.style.display = 'none';
+
+ } catch (err) {
+ console.error(err);
+ statusDiv.innerHTML = `<strong>Error:</strong> ${err.message}<br><small>Note: usage_data.csv must be in the same folder.</small>`;
+ statusDiv.className = 'error';
+ }
+ });
+
+ // --- Data Processing ---
+
+ function parseCSVLine(line) {
+ let parts = [];
+ let current = '';
+ let inQuotes = false;
+ for (let i = 0; i < line.length; i++) {
+ let char = line[i];
+ if (char === '"') { inQuotes = !inQuotes; }
+ else if (char === ',' && !inQuotes) { parts.push(current); current = ''; }
+ else { current += char; }
+ }
+ parts.push(current);
+ return parts.map(p => p.trim().replace(/^"|"$/g, ''));
+ }
+
+ function parseTags(csvText) {
+ const lines = csvText.split('\n');
+ const fontTags = {};
+
+ for (let i = 1; i < lines.length; i++) {
+ const line = lines[i].trim();
+ if(!line) continue;
+ const parts = parseCSVLine(line);
+ if (parts.length < 3) continue;
+
+ const family = parts[0];
+ let rawTag = parts[2];
+
+ if (!rawTag && parts[1] && parts[1].includes('/')) rawTag = parts[1];
+ if (!rawTag) continue;
+
+ if (rawTag.includes("Quality/")) continue;
+
+ let cleanTag = rawTag.replace(/^\/+/g, '');
+
+ if (!fontTags[family]) fontTags[family] = new Set();
+ fontTags[family].add(cleanTag);
+ }
+ return fontTags;
+ }
+
+ function parseUsageCSV(csvText) {
+ const lines = csvText.split('\n');
+ const usage = {};
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i].trim();
+ if (!line) continue;
+ const parts = parseCSVLine(line);
+ if (parts.length < 2) continue;
+ const family = parts[0];
+ const views = parseInt(parts[1].replace(/,/g, ''));
+ if (!isNaN(views)) usage[family] = views;
+ }
+ return usage;
+ }
+
+ function getTop50PercentFonts(fontList, usageMap) {
+ let allFonts = fontList.map(f => ({
+ family: f.family,
+ views: usageMap[f.family] || 0
+ }));
+ allFonts.sort((a, b) => b.views - a.views);
+ const medianIndex = Math.floor(allFonts.length / 2);
+ return allFonts.slice(0, medianIndex);
+ }
+
+ function countTagsInSet(fontList, fontTagsMap) {
+ const counts = {};
+ fontList.forEach(font => {
+ const tags = fontTagsMap[font.family];
+ if (tags) {
+ tags.forEach(tag => {
+ if (!counts[tag]) counts[tag] = { tag: tag, count: 0, fonts: [] };
+ counts[tag].count++;
+ counts[tag].fonts.push(font.family);
+ });
+ }
+ });
+ return Object.values(counts);
+ }
+
+ function countTotalTags(fontList, fontTagsMap) {
+ const totals = {};
+ fontList.forEach(font => {
+ const tags = fontTagsMap[font.family];
+ if (tags) {
+ tags.forEach(tag => {
+ totals[tag] = (totals[tag] || 0) + 1;
+ });
+ }
+ });
+ return totals;
+ }
+
+ function mergeData(top50Data, totalData) {
+ // top50Data is array of objects {tag, count, fonts[]}
+ // We map over it to add total and ratio
+ return top50Data.map(item => {
+ const total = totalData[item.tag] || item.count;
+ return {
+ ...item,
+ total: total,
+ ratio: total > 0 ? (item.count / total) : 0
+ };
+ });
+ }
+
+ // --- Rendering ---
+
+ function renderTable() {
+ const tbody = document.querySelector('#main-table tbody');
+ tbody.innerHTML = '';
+
+ // Find max count for bar chart scaling
+ const maxCount = Math.max(...TABLE_DATA.map(d => d.count), 1);
+
+ TABLE_DATA.forEach(row => {
+ const tr = document.createElement('tr');
+
+ const pct = (row.count / maxCount) * 100;
+ const ratioPercent = (row.ratio * 100).toFixed(0);
+
+ tr.innerHTML = `
+ <td class="tag">${row.tag}</td>
+
+ <td class="num" style="cursor:pointer" onclick="openModal('${row.tag}')">
+ <div class="bar-container">
+ <span>${row.count}</span>
+ <div class="bar-bg">
+ <div class="bar-fill" style="width:${pct}%"></div>
+ </div>
+ </div>
+ </td>
+
+ <td class="num">
+ ${ratioPercent}%
+ <span class="ratio-context">(${row.count}/${row.total})</span>
+ </td>
+ `;
+ tbody.appendChild(tr);
+
+ // Populate global lookup for modal
+ window.TAG_LOOKUP[row.tag] = row.fonts;
+ });
+ }
+
+ // --- Sorting ---
+ let currentSort = { col: 'count', dir: 'desc' };
+
+ window.sortTable = function(col) {
+ // Toggle direction if clicking same column
+ if (currentSort.col === col) {
+ currentSort.dir = currentSort.dir === 'asc' ? 'desc' : 'asc';
+ } else {
+ currentSort.col = col;
+ currentSort.dir = 'desc'; // Default desc for new sorts
+ }
+
+ // Update Header Styles
+ document.querySelectorAll('th').forEach(th => th.className = '');
+ const activeHeader = document.getElementById(`th-${col}`);
+ if(activeHeader) activeHeader.classList.add(currentSort.dir);
+
+ // Sort Data
+ TABLE_DATA.sort((a, b) => {
+ let valA = a[col];
+ let valB = b[col];
+
+ // String sort for Tag
+ if (col === 'tag') {
+ return currentSort.dir === 'asc'
+ ? valA.localeCompare(valB)
+ : valB.localeCompare(valA);
+ }
+ // Numeric sort for Count/Ratio
+ return currentSort.dir === 'asc' ? valA - valB : valB - valA;
+ });
+
+ renderTable();
+ }
+
+ // --- Modal Logic ---
+ const modal = document.getElementById('modal');
+ const modalTitle = document.getElementById('modal-title');
+ const fontListEl = document.getElementById('font-list');
+
+ window.openModal = function(tag) {
+ const fonts = window.TAG_LOOKUP[tag];
+ if(!fonts) return;
+
+ modalTitle.textContent = `${tag} (Top 50%) — ${fonts.length} Fonts`;
+ fontListEl.innerHTML = '';
+
+ fonts.sort((a, b) => a.localeCompare(b));
+
+ fonts.forEach(font => {
+ const li = document.createElement('li');
+ const a = document.createElement('a');
+ a.textContent = font;
+ a.href = `https://fonts.google.com/specimen/${font.replace(/ /g, '+')}`;
+ a.target = "_blank";
+ li.appendChild(a);
+ fontListEl.appendChild(li);
+ });
+
+ modal.classList.add('modal-active');
+ }
+
+ window.closeModal = function() {
+ modal.classList.remove('modal-active');
+ }
+
+ modal.addEventListener('click', (e) => {
+ if (e.target === modal) closeModal();
+ });
+
+</script>
+</body>
+</html>
\ No newline at end of file
--- /dev/null
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Google Fonts Quality Dashboard</title>
+ <style>
+ html, body {
+ height: 100%;
+ margin: 0;
+ overflow: hidden; /* Prevent full page scroll */
+ background: #f8f9fa;
+ color: #202124;
+ font-family: 'Google Sans', Roboto, sans-serif;
+ }
+
+ body {
+ display: flex;
+ flex-direction: column;
+ padding: 20px;
+ box-sizing: border-box;
+ }
+ h1 { margin-bottom: 5px; }
+ p.subtitle { color: #5f6368; margin-top: 0; margin-bottom: 20px; font-size: 0.9rem; }
+
+ /* Table Styles */
+ .table-container { overflow-x: auto; box-shadow: 0 1px 3px rgba(0,0,0,0.2); background: white; border-radius: 8px; }
+ table { border-collapse: collapse; width: 100%; min-width: 800px; }
+ th, td { text-align: left; padding: 12px 16px; border-bottom: 1px solid #ddd; }
+ th { background-color: #f1f3f4; position: sticky; top: 0; font-weight: 600; color: #5f6368; font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.5px; }
+
+ /* Highlighting Logic */
+ tr:hover { background-color: #f1f3f4 !important; }
+ tr.highlight-row { background-color: #fff8e1; }
+ /*tr.highlight-row td:first-child { font-weight: bold; color: #b06000; }
+
+ /* Numeric columns */
+ .num { text-align: right; font-variant-numeric: tabular-nums; }
+
+ /* Interactive Cells */
+ .clickable { cursor: pointer; color: #1a73e8; text-decoration: underline; text-decoration-style: dotted; text-underline-offset: 3px; }
+ .clickable:hover { background-color: #d2e3fc; color: #174ea6; }
+
+ /* Conditional Formatting */
+ .good-ratio { color: #137333; font-weight: bold; background-color: #e6f4ea; }
+ .zero-score { color: #aaa; pointer-events: none; }
+
+ /* Status Bar */
+ #status { padding: 10px; margin-bottom: 15px; border-radius: 4px; display: none; }
+ .loading { background-color: #e8f0fe; color: #1967d2; display: block !important; }
+ .error { background-color: #fce8e6; color: #c5221f; display: block !important; }
+
+ /* Modal Styles */
+ .modal-overlay { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 1000; justify-content: center; align-items: center; }
+ .modal-active { display: flex; }
+ .modal-content { background: white; width: 90%; max-width: 600px; max-height: 80vh; border-radius: 8px; box-shadow: 0 4px 6px rgba(0,0,0,0.1); display: flex; flex-direction: column; }
+ .modal-header { padding: 16px 20px; border-bottom: 1px solid #ddd; display: flex; justify-content: space-between; align-items: center; background: #f1f3f4; border-radius: 8px 8px 0 0; }
+ .modal-title { margin: 0; font-size: 1.1rem; color: #202124; }
+ .close-btn { background: none; border: none; font-size: 1.5rem; cursor: pointer; color: #5f6368; }
+ .modal-body { padding: 20px; overflow-y: auto; }
+
+ ul.font-list { list-style: none; padding: 0; margin: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 10px; }
+ ul.font-list li a { text-decoration: none; color: #1a73e8; font-size: 0.95rem; display: block; padding: 4px 8px; border-radius: 4px; }
+ ul.font-list li a:hover { background-color: #e8f0fe; }
+ </style>
+</head>
+<body>
+
+ <h1>Font Quality by Language Subset</h1>
+ <p class="subtitle">Live data from Google Fonts Metadata & GitHub Tags. Sorted by count of fonts > 70.</p>
+
+ <div class="table-container">
+ <table id="dashboard">
+ <thead>
+ <tr>
+ <th>Language Subset</th>
+ <th class="num">Total Fonts</th>
+ <th class="num">Score < 50</th>
+ <th class="num">50–60</th>
+ <th class="num">60–70</th>
+ <th class="num">70–80</th>
+ <th class="num">> 80</th>
+ <th class="num" style="background: #e8f0fe;">Total > 70</th>
+ <th class="num">Ratio (>70)</th>
+ </tr>
+ </thead>
+ <tbody>
+ </tbody>
+ </table>
+ </div>
+
+ <div id="modal" class="modal-overlay">
+ <div class="modal-content">
+ <div class="modal-header">
+ <h2 id="modal-title" class="modal-title">Fonts List</h2>
+ <button class="close-btn" onclick="closeModal()">×</button>
+ </div>
+ <div class="modal-body">
+ <ul id="font-list" class="font-list"></ul>
+ </div>
+ </div>
+ </div>
+
+<script>
+ // --- Configuration ---
+ const METADATA_URL = "catalog_metadata.json";
+ const TAGS_URL = "https://raw.githubusercontent.com/google/fonts/refs/heads/main/tags/all/families.csv";
+
+ const WEIGHTS = { "Concept": 0.4, "Drawing": 0.3, "Spacing": 0.2, "Wordspace": 0.1 };
+
+ const ALLOWED_SUBSETS = [
+ "latin", "devanagari", "japanese", "korean", "arabic", "thai",
+ "bengali", "chinese-simplified", "cyrillic", "greek", "hebrew",
+ "tamil", "telugu", "gujarati", "kannada", "malayalam", "gurmukhi",
+ "oriya", "myanmar", "armenian", "sinhala", "georgian", "chinese-traditional"
+ ];
+
+ const HIGHLIGHT_SUBSETS = [
+ "latin", "cyrillic", "greek", "devanagari", "hebrew", "arabic",
+ "korean", "japanese", "thai", "bengali", "chinese-simplified"
+ ];
+
+ // --- Main Execution ---
+ document.addEventListener("DOMContentLoaded", async () => {
+ const statusDiv = document.getElementById('status');
+ try {
+ const [metaRes, tagsRes] = await Promise.all([ fetch(METADATA_URL), fetch(TAGS_URL) ]);
+
+ if (!metaRes.ok) throw new Error(`Metadata Error: ${metaRes.status}`);
+ if (!tagsRes.ok) throw new Error(`Tags CSV Error: ${tagsRes.status}`);
+
+ const metadata = await metaRes.json();
+ const tagsText = await tagsRes.text();
+
+ const scores = parseTagsAndCalculateScores(tagsText);
+ const stats = aggregateStats(metadata.familyMetadataList, scores);
+
+ renderTable(stats);
+ statusDiv.style.display = 'none';
+
+ } catch (err) {
+ console.error(err);
+ statusDiv.textContent = `Error loading data: ${err.message}.`;
+ statusDiv.className = 'error';
+ }
+ });
+
+ // --- Modal Logic ---
+ const modal = document.getElementById('modal');
+ const modalTitle = document.getElementById('modal-title');
+ const fontListEl = document.getElementById('font-list');
+
+ function openModal(subset, category, fonts) {
+ modalTitle.textContent = `${subset} — ${category} (${fonts.length})`;
+ fontListEl.innerHTML = '';
+ fonts.sort((a, b) => a.localeCompare(b));
+ fonts.forEach(font => {
+ const li = document.createElement('li');
+ const a = document.createElement('a');
+ a.textContent = font;
+ a.href = `https://fonts.google.com/specimen/${font.replace(/ /g, '+')}`;
+ a.target = "_blank";
+ li.appendChild(a);
+ fontListEl.appendChild(li);
+ });
+ modal.classList.add('modal-active');
+ }
+
+ function closeModal() { modal.classList.remove('modal-active'); }
+ modal.addEventListener('click', (e) => { if (e.target === modal) closeModal(); });
+
+ // --- Data Logic ---
+ function parseTagsAndCalculateScores(csvText) {
+ const lines = csvText.split('\n');
+ const rawValues = {};
+ const familyScores = {};
+
+ for (let i = 1; i < lines.length; i++) {
+ const row = lines[i].split(',');
+ if (row.length < 4) continue;
+
+ const family = row[0].trim();
+ const tag = row[2].trim();
+ const weight = parseFloat(row[3]);
+
+ if (tag.includes('Quality/')) {
+ const parts = tag.split('Quality/');
+ const type = parts[1];
+ if (type && !isNaN(weight)) {
+ if (!rawValues[family]) rawValues[family] = {};
+ rawValues[family][type] = weight;
+ }
+ }
+ }
+
+ for (const [family, categories] of Object.entries(rawValues)) {
+ let totalScore = 0;
+ totalScore += (categories['Concept'] || 0) * WEIGHTS['Concept'];
+ totalScore += (categories['Drawing'] || 0) * WEIGHTS['Drawing'];
+ totalScore += (categories['Spacing'] || 0) * WEIGHTS['Spacing'];
+ totalScore += (categories['Wordspace'] || 0) * WEIGHTS['Wordspace'];
+ familyScores[family] = totalScore;
+ }
+ return familyScores;
+ }
+
+ function aggregateStats(fontList, scores) {
+ const subsetStats = {};
+ ALLOWED_SUBSETS.forEach(sub => {
+ subsetStats[sub] = {
+ total: [], below_50: [], score_50_60: [], score_60_70: [],
+ score_70_80: [], above_80: [], above_70_total: []
+ };
+ });
+
+ fontList.forEach(font => {
+ const score = scores[font.family] || 0;
+ const name = font.family;
+ font.subsets.forEach(subset => {
+ if (!subsetStats[subset]) return;
+ const s = subsetStats[subset];
+ s.total.push(name);
+ if (score < 50) s.below_50.push(name);
+ else if (score < 60) s.score_50_60.push(name);
+ else if (score < 70) s.score_60_70.push(name);
+ else if (score < 80) s.score_70_80.push(name);
+ else s.above_80.push(name);
+ if (score >= 70) s.above_70_total.push(name);
+ });
+ });
+ return subsetStats;
+ }
+
+ function renderTable(stats) {
+ const tbody = document.querySelector('#dashboard tbody');
+ let sortedSubsets = Object.entries(stats);
+
+ // --- NEW SORTING LOGIC ---
+ // Sort strictly by the length of "above_70_total" array (descending)
+ sortedSubsets.sort((a, b) => {
+ return b[1].above_70_total.length - a[1].above_70_total.length;
+ });
+
+ sortedSubsets.forEach(([name, data]) => {
+ const tr = document.createElement('tr');
+
+ // Apply highlight class if language is in the priority list
+ if (HIGHLIGHT_SUBSETS.includes(name)) {
+ tr.classList.add('highlight-row');
+ }
+
+ const totalCount = data.total.length;
+ const highQualityCount = data.above_70_total.length;
+ const ratio = totalCount > 0 ? (highQualityCount / totalCount) : 0;
+ const ratioPercent = (ratio * 100).toFixed(1) + '%';
+ const ratioClass = ratio > 0.5 ? 'good-ratio' : '';
+
+ const createCell = (list, label) => {
+ const count = list.length;
+ const isZero = count === 0;
+ const td = document.createElement('td');
+ td.className = `num ${isZero ? 'zero-score' : 'clickable'}`;
+ td.textContent = count;
+ if (!isZero) td.onclick = () => openModal(name, label, list);
+ return td;
+ };
+
+ const tdName = document.createElement('td');
+ tdName.textContent = name;
+ tr.appendChild(tdName);
+
+ tr.appendChild(createCell(data.total, "Total Fonts"));
+ tr.appendChild(createCell(data.below_50, "Score < 50"));
+ tr.appendChild(createCell(data.score_50_60, "Score 50–60"));
+ tr.appendChild(createCell(data.score_60_70, "Score 60–70"));
+ tr.appendChild(createCell(data.score_70_80, "Score 70–80"));
+ tr.appendChild(createCell(data.above_80, "Score > 80"));
+
+ // Highlight the column we are sorting by
+ const tdAbove70 = createCell(data.above_70_total, "Total Score > 70");
+ tdAbove70.style.background = "#e8f0fe"; // subtle blue highlight
+ tr.appendChild(tdAbove70);
+
+ const tdRatio = document.createElement('td');
+ tdRatio.className = `num ${ratioClass}`;
+ tdRatio.textContent = ratioPercent;
+ tr.appendChild(tdRatio);
+
+ tbody.appendChild(tr);
+ });
+ }
+</script>
+
+</body>
+</html>
\ No newline at end of file
--- /dev/null
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Tag Perception</title>
+ <style>
+ /* --- LAYOUT --- */
+ html, body {
+ height: 100%;
+ margin: 0;
+ overflow: hidden;
+ background: #f8f9fa;
+ color: #202124;
+ font-family: 'Google Sans', Roboto, sans-serif;
+ }
+
+ body {
+ display: flex;
+ flex-direction: column;
+ padding: 20px;
+ box-sizing: border-box;
+ }
+
+ .header-section {
+ flex: 0 0 auto;
+ margin-bottom: 15px;
+ }
+
+ h1 { margin: 0 0 5px 0; }
+ p.subtitle { color: #5f6368; margin: 0 0 15px 0; font-size: 0.9rem; }
+
+ /* --- CHART SECTION --- */
+ .chart-section {
+ flex: 0 0 auto;
+ background: white;
+ border-radius: 8px;
+ box-shadow: 0 1px 3px rgba(0,0,0,0.2);
+ margin-bottom: 20px;
+ padding: 15px 20px;
+ display: flex;
+ flex-direction: column;
+ }
+
+ .chart-title {
+ font-size: 0.85rem;
+ font-weight: 600;
+ color: #5f6368;
+ margin-bottom: 10px;
+ text-transform: uppercase;
+ }
+
+ /* INCREASED HEIGHT & PADDING HERE */
+ .chart-container {
+ height: 180px; /* Taller to fit labels */
+ display: flex;
+ align-items: flex-end;
+ gap: 4px;
+ overflow-x: auto;
+ padding-bottom: 120px; /* More space for rotated text */
+ scrollbar-width: thin;
+ scrollbar-color: #ccc transparent;
+
+ background-image: linear-gradient(
+ to bottom,
+ transparent 89px,
+ #dadce0 89px,
+ #dadce0 90px,
+ transparent 90px
+ );
+ background-attachment: local;
+ background-attachment: scroll;
+ background-repeat: no-repeat
+ }
+
+ .chart-container::-webkit-scrollbar { height: 6px; }
+ .chart-container::-webkit-scrollbar-thumb { background-color: #ccc; border-radius: 3px; }
+
+ .chart-bar-group {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ min-width: 30px;
+ height: 100%;
+ justify-content: flex-end;
+ position: relative;
+ }
+
+ .chart-bar {
+ width: 18px;
+ background-color: #34a853;
+ border-radius: 3px 3px 0 0;
+ transition: height 0.3s ease;
+ position: relative;
+ }
+
+ .chart-bar:hover { background-color: #2d9147; }
+
+ .chart-bar:hover::after {
+ content: attr(data-val);
+ position: absolute;
+ bottom: 100%;
+ left: 50%;
+ transform: translateX(-50%);
+ background: rgba(0,0,0,0.8);
+ color: white;
+ padding: 4px 6px;
+ border-radius: 4px;
+ font-size: 0.75rem;
+ white-space: nowrap;
+ margin-bottom: 4px;
+ z-index: 10;
+ }
+
+ /* ADJUSTED LABEL POSITIONING */
+ .chart-label {
+ margin-top: 10px; /* Shift down */
+ font-size: 9px;
+ color: #5f6368;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ width: 120px; /* Wider */
+ text-align: left;
+
+ /* Rotate 45deg (down-right) from the top-left corner */
+ transform: rotate(45deg);
+ transform-origin: left top;
+
+ position: absolute;
+ top: 100%;
+ left: 50%; /* Start at center of bar */
+ margin-left: -2px; /* Slight centering adjustment */
+ }
+
+ /* --- TABLE SECTION --- */
+ .table-container {
+ flex: 1;
+ overflow: auto;
+ background: white;
+ border-radius: 8px;
+ box-shadow: 0 1px 3px rgba(0,0,0,0.2);
+ position: relative;
+ }
+
+ table { border-collapse: separate; border-spacing: 0; width: 100%; min-width: 1000px; }
+
+ th {
+ background-color: #f1f3f4;
+ position: sticky; top: 0; z-index: 10;
+ font-weight: 600; color: #5f6368; font-size: 0.75rem;
+ text-transform: uppercase; letter-spacing: 0.5px;
+ padding: 12px 16px; border-bottom: 1px solid #ddd;
+ text-align: left;
+ cursor: pointer;
+ }
+
+ th:hover { background-color: #e8eaed; color: #202124; }
+
+ td { font-size: 0.9rem; padding: 12px 16px; border-bottom: 1px solid #eee; vertical-align: middle; }
+ td.num { text-align: right; font-variant-numeric: tabular-nums; }
+ td.tag-name { font-weight: 500; color: #1a73e8; }
+ td.rank { color: #5f6368; font-weight: bold; }
+
+ .bar-container { display: flex; align-items: center; gap: 8px; justify-content: flex-end; }
+ .bar { height: 6px; border-radius: 3px; min-width: 2px; }
+ .bar-high { background-color: #34a853; }
+ .bar-mid { background-color: #fbbc04; }
+ .bar-low { background-color: #ea4335; }
+ .count-label { font-size: 0.85rem; color: #202124; min-width: 30px; text-align: right; }
+
+ #status { padding: 10px; margin-bottom: 15px; border-radius: 4px; display: none; }
+ .loading { background-color: #e8f0fe; color: #1967d2; display: block !important; }
+ .error { background-color: #fce8e6; color: #c5221f; display: block !important; }
+
+ .modal-overlay { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 1000; justify-content: center; align-items: center; }
+ .modal-active { display: flex; }
+ .modal-content { background: white; width: 90%; max-width: 600px; max-height: 80vh; border-radius: 8px; box-shadow: 0 4px 6px rgba(0,0,0,0.1); display: flex; flex-direction: column; }
+ .modal-header { padding: 16px 20px; border-bottom: 1px solid #ddd; display: flex; justify-content: space-between; align-items: center; background: #f1f3f4; }
+ .modal-title { margin: 0; font-size: 1.1rem; color: #202124; }
+ .close-btn { background: none; border: none; font-size: 1.5rem; cursor: pointer; color: #5f6368; }
+ .modal-body { padding: 20px; overflow-y: auto; }
+ ul.font-list { list-style: none; padding: 0; margin: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 10px; }
+ ul.font-list li a { text-decoration: none; color: #1a73e8; font-size: 0.95rem; display: block; padding: 4px 8px; border-radius: 4px; }
+ ul.font-list li a:hover { background-color: #e8f0fe; }
+
+ </style>
+</head>
+<body>
+
+ <div class="header-section">
+ <h1>Tag Perception</h1>
+ <p class="subtitle">Ranked by Usage, sorted by Ratio of good fonts to total fonts.</p>
+ </div>
+
+ <div class="chart-section">
+ <div class="chart-title">High Quality Ratio (>70) by Tag</div>
+ <div id="ratio-chart" class="chart-container">
+ </div>
+ </div>
+
+ <div class="table-container">
+ <table id="dashboard">
+ <thead>
+ <tr>
+ <th onclick="sortTable('tag')">Tag / Category</th>
+ <th onclick="sortTable('viewRank')" class="num" title="Fixed Rank by Total Views">Usage Rank</th>
+ <th onclick="sortTable('views')" class="num">Total Monthly Views</th>
+ <th onclick="sortTable('high')" class="num" title="Score >= 70">Score ≥ 70</th>
+ <th onclick="sortTable('mid')" class="num" title="50 <= Score < 70">Score 50–70</th>
+ <th onclick="sortTable('low')" class="num" title="Score < 50">Score < 50</th>
+ <th onclick="sortTable('ratio')" class="num">Ratio > 70</th>
+ </tr>
+ </thead>
+ <tbody>
+ </tbody>
+ </table>
+ </div>
+
+ <div id="modal" class="modal-overlay">
+ <div class="modal-content">
+ <div class="modal-header">
+ <h2 id="modal-title" class="modal-title">Fonts List</h2>
+ <button class="close-btn" onclick="closeModal()">×</button>
+ </div>
+ <div class="modal-body">
+ <ul id="font-list" class="font-list"></ul>
+ </div>
+ </div>
+ </div>
+
+<script>
+ // --- Configuration ---
+ const METADATA_URL = "catalog_metadata.json";
+ const TAGS_URL = "https://raw.githubusercontent.com/google/fonts/refs/heads/main/tags/all/families.csv";
+ const USAGE_URL = "usage_data.csv";
+
+ const WEIGHTS = { "Concept": 0.4, "Drawing": 0.3, "Spacing": 0.2, "Wordspace": 0.1 };
+
+ let TABLE_DATA = [];
+
+ // --- Main Execution ---
+ document.addEventListener("DOMContentLoaded", async () => {
+ const statusDiv = document.getElementById('status');
+ try {
+ const [metaRes, tagsRes, usageRes] = await Promise.all([
+ fetch(METADATA_URL),
+ fetch(TAGS_URL),
+ fetch(USAGE_URL)
+ ]);
+
+ if (!metaRes.ok) throw new Error(`Metadata Error: ${metaRes.status}`);
+ if (!tagsRes.ok) throw new Error(`Tags CSV Error: ${tagsRes.status}`);
+ if (!usageRes.ok) throw new Error(`Usage CSV Error: ${usageRes.status}`);
+
+ const metadata = await metaRes.json();
+ const tagsText = await tagsRes.text();
+ const usageText = await usageRes.text();
+
+ const { scores, fontTags } = parseTagsData(tagsText);
+ const usageMap = parseUsageCSV(usageText);
+
+ TABLE_DATA = aggregateData(metadata.familyMetadataList, scores, fontTags, usageMap);
+
+ renderTable(TABLE_DATA);
+ statusDiv.style.display = 'none';
+
+ } catch (err) {
+ console.error(err);
+ statusDiv.innerHTML = `<strong>Error:</strong> ${err.message}<br><small>Note: usage_data.csv must be in the same folder.</small>`;
+ statusDiv.className = 'error';
+ }
+ });
+
+ // --- Parsing Logic ---
+
+ function parseCSVLine(line) {
+ let parts = [];
+ let current = '';
+ let inQuotes = false;
+ for (let i = 0; i < line.length; i++) {
+ let char = line[i];
+ if (char === '"') { inQuotes = !inQuotes; }
+ else if (char === ',' && !inQuotes) { parts.push(current); current = ''; }
+ else { current += char; }
+ }
+ parts.push(current);
+ return parts.map(p => p.trim().replace(/^"|"$/g, ''));
+ }
+
+ function parseTagsData(csvText) {
+ const lines = csvText.split('\n');
+ const rawQualityValues = {};
+ const fontTags = {};
+
+ for (let i = 1; i < lines.length; i++) {
+ const line = lines[i].trim();
+ if(!line) continue;
+
+ const parts = parseCSVLine(line);
+ if (parts.length < 3) continue;
+
+ const family = parts[0];
+ let rawTag = parts[2];
+ let weightStr = parts[3];
+
+ if (!rawTag && parts[1] && parts[1].includes('/')) {
+ rawTag = parts[1];
+ weightStr = parts[2];
+ }
+
+ if (!rawTag) continue;
+
+ if (rawTag.includes("Quality/")) {
+ const type = rawTag.split("Quality/")[1];
+ const weight = parseFloat(weightStr || 0);
+ if (type && !isNaN(weight)) {
+ if (!rawQualityValues[family]) rawQualityValues[family] = {};
+ rawQualityValues[family][type] = weight;
+ }
+ } else {
+ // Show full tag (remove only leading slash)
+ let cleanTag = rawTag.replace(/^\/+/g, '');
+
+ if (!fontTags[family]) fontTags[family] = new Set();
+ fontTags[family].add(cleanTag);
+ }
+ }
+
+ const scores = {};
+ for (const [family, cats] of Object.entries(rawQualityValues)) {
+ let s = 0;
+ s += (cats['Concept'] || 0) * WEIGHTS['Concept'];
+ s += (cats['Drawing'] || 0) * WEIGHTS['Drawing'];
+ s += (cats['Spacing'] || 0) * WEIGHTS['Spacing'];
+ s += (cats['Wordspace'] || 0) * WEIGHTS['Wordspace'];
+ scores[family] = s;
+ }
+
+ return { scores, fontTags };
+ }
+
+ function parseUsageCSV(csvText) {
+ const lines = csvText.split('\n');
+ const usage = {};
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i].trim();
+ if (!line) continue;
+ const parts = parseCSVLine(line);
+ if (parts.length < 2) continue;
+ const family = parts[0];
+ const views = parseInt(parts[1].replace(/,/g, ''));
+ if (!isNaN(views)) usage[family] = views;
+ }
+ return usage;
+ }
+
+ // --- Aggregation Logic ---
+
+ function aggregateData(fontList, scores, fontTagsMap, usageMap) {
+ const tagStats = {};
+
+ fontList.forEach(font => {
+ const family = font.family;
+ const views = usageMap[family] || 0;
+ const score = scores[family] || 0;
+ const tags = fontTagsMap[family];
+
+ if (tags) {
+ tags.forEach(tag => {
+ if (!tagStats[tag]) {
+ tagStats[tag] = {
+ tag: tag,
+ views: 0,
+ high: 0, mid: 0, low: 0,
+ fontsHigh: [], fontsMid: [], fontsLow: []
+ };
+ }
+
+ tagStats[tag].views += views;
+
+ if (score >= 70) {
+ tagStats[tag].high++;
+ tagStats[tag].fontsHigh.push(family);
+ } else if (score >= 50) {
+ tagStats[tag].mid++;
+ tagStats[tag].fontsMid.push(family);
+ } else {
+ tagStats[tag].low++;
+ tagStats[tag].fontsLow.push(family);
+ }
+ });
+ }
+ });
+
+ const result = Object.values(tagStats).map(t => {
+ const total = t.high + t.mid + t.low;
+ t.ratio = total > 0 ? (t.high / total) : 0;
+ return t;
+ });
+
+ // 1. Assign Usage Rank
+ result.sort((a, b) => b.views - a.views);
+ result.forEach((item, index) => {
+ item.viewRank = index + 1;
+ });
+
+ // 2. Initial Sort by Ratio Descending
+ result.sort((a, b) => b.ratio - a.ratio);
+
+ return result;
+ }
+
+ // --- Rendering ---
+
+ function renderTable(data) {
+ const tbody = document.querySelector('#dashboard tbody');
+ tbody.innerHTML = '';
+
+ renderChart(data);
+
+ data.forEach(row => {
+ const tr = document.createElement('tr');
+ const totalFonts = row.high + row.mid + row.low;
+ const ratioPercent = (row.ratio * 100).toFixed(1) + '%';
+
+ const createBarCell = (count, type, fontList) => {
+ const percent = totalFonts > 0 ? (count / totalFonts) * 100 : 0;
+ const width = Math.max(2, percent * 0.8);
+
+ let barClass = type === 'high' ? 'bar-high' : type === 'mid' ? 'bar-mid' : 'bar-low';
+ let cursor = count > 0 ? 'pointer' : 'default';
+ let onclick = count > 0 ? `onclick="openModal('${row.tag}', '${type}', this)"` : '';
+
+ return `
+ <td class="num" style="cursor:${cursor}" ${onclick}>
+ <div class="bar-container">
+ <div class="count-label">${count}</div>
+ <div class="bar ${barClass}" style="width: ${width}px; opacity: ${count ? 1 : 0.1}"></div>
+ </div>
+ </td>
+ `;
+ };
+
+ tr.innerHTML = `
+ <td class="tag-name">${row.tag}</td>
+ <td class="num rank">#${row.viewRank}</td>
+ <td class="num"><strong>${row.views.toLocaleString()}</strong></td>
+ ${createBarCell(row.high, 'high', row.fontsHigh)}
+ ${createBarCell(row.mid, 'mid', row.fontsMid)}
+ ${createBarCell(row.low, 'low', row.fontsLow)}
+ <td class="num" style="font-weight:bold;">${ratioPercent}</td>
+ `;
+ tbody.appendChild(tr);
+ });
+ }
+
+ function renderChart(data) {
+ const container = document.getElementById('ratio-chart');
+ container.innerHTML = '';
+
+ data.forEach(row => {
+ const pct = (row.ratio * 100).toFixed(1);
+ const height = Math.max(2, row.ratio * 100);
+
+ const barGroup = document.createElement('div');
+ barGroup.className = 'chart-bar-group';
+
+ barGroup.innerHTML = `
+ <div class="chart-bar" style="height:${height}%" data-val="${pct}%"></div>
+ <div class="chart-label">${row.tag}</div>
+ `;
+ container.appendChild(barGroup);
+ });
+ }
+
+ // --- Modal Logic ---
+ const modal = document.getElementById('modal');
+ const modalTitle = document.getElementById('modal-title');
+ const fontListEl = document.getElementById('font-list');
+
+ window.openModal = function(tag, type, el) {
+ const rowData = TABLE_DATA.find(r => r.tag === tag);
+ if(!rowData) return;
+
+ let fonts = [];
+ let label = "";
+
+ if(type === 'high') { fonts = rowData.fontsHigh; label = "Score >= 70"; }
+ else if(type === 'mid') { fonts = rowData.fontsMid; label = "Score 50-70"; }
+ else { fonts = rowData.fontsLow; label = "Score < 50"; }
+
+ if(fonts.length === 0) return;
+
+ modalTitle.textContent = `${tag} — ${label} (${fonts.length})`;
+ fontListEl.innerHTML = '';
+
+ fonts.sort((a, b) => a.localeCompare(b));
+
+ fonts.forEach(font => {
+ const li = document.createElement('li');
+ const a = document.createElement('a');
+ a.textContent = font;
+ a.href = `https://fonts.google.com/specimen/${font.replace(/ /g, '+')}`;
+ a.target = "_blank";
+ li.appendChild(a);
+ fontListEl.appendChild(li);
+ });
+
+ modal.classList.add('modal-active');
+ }
+
+ window.closeModal = function() {
+ modal.classList.remove('modal-active');
+ }
+
+ modal.addEventListener('click', (e) => {
+ if (e.target === modal) closeModal();
+ });
+
+ // --- Sorting ---
+ let currentSort = { col: 'ratio', dir: 'desc' };
+
+ window.sortTable = function(col) {
+ if (currentSort.col === col) {
+ currentSort.dir = currentSort.dir === 'asc' ? 'desc' : 'asc';
+ } else {
+ currentSort.col = col;
+ currentSort.dir = 'desc';
+ }
+
+ TABLE_DATA.sort((a, b) => {
+ let valA = a[col];
+ let valB = b[col];
+
+ if (col === 'tag') {
+ return currentSort.dir === 'asc'
+ ? valA.localeCompare(valB)
+ : valB.localeCompare(valA);
+ }
+ return currentSort.dir === 'asc' ? valA - valB : valB - valA;
+ });
+
+ renderTable(TABLE_DATA);
+ }
+
+</script>
+</body>
+</html>
\ No newline at end of file
--- /dev/null
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Multi-Threshold Tag Coverage Dashboard</title>
+ <style>
+ /* --- GLOBAL LAYOUT --- */
+ html, body {
+ height: 100%;
+ margin: 0;
+ overflow: hidden;
+ background: #f8f9fa;
+ color: #202124;
+ font-family: 'Google Sans', Roboto, sans-serif;
+ }
+
+ body {
+ display: flex;
+ flex-direction: column;
+ padding: 20px;
+ box-sizing: border-box;
+ }
+
+ .header-section {
+ flex: 0 0 auto;
+ margin-bottom: 15px;
+ }
+
+ h1 { margin: 0 0 5px 0; }
+ p.subtitle { color: #5f6368; margin: 0 0 15px 0; font-size: 0.9rem; }
+
+ /* The scrolling container */
+ .table-container {
+ flex: 1;
+ overflow: auto;
+ background: white;
+ border-radius: 8px;
+ box-shadow: 0 1px 3px rgba(0,0,0,0.2);
+ position: relative;
+ }
+
+ /* --- TABLE RESET --- */
+ table {
+ border-collapse: separate;
+ border-spacing: 0;
+ width: 100%;
+ min-width: 1400px;
+ }
+
+ /* --- STANDARD CELLS --- */
+ th, td {
+ padding: 8px;
+ font-size: 0.85rem;
+ white-space: nowrap;
+ box-sizing: border-box;
+ }
+
+ /* Standard Header */
+ th {
+ background-color: #f1f3f4;
+ position: sticky; top: 0; z-index: 10;
+ font-weight: 600; color: #5f6368; font-size: 0.75rem;
+ text-transform: uppercase; letter-spacing: 0.5px;
+ border-bottom: 1px solid #ddd;
+ text-align: left;
+ }
+
+ /* Standard Data Cell */
+ td {
+ border-bottom: 1px solid #eee;
+ text-align: right;
+ border-right: 1px solid #f0f0f0;
+ }
+
+ /* --- FROZEN COLUMNS LOGIC (Only 1 & 2) --- */
+
+ th:nth-child(1), td:nth-child(1),
+ th:nth-child(2), td:nth-child(2) {
+ position: sticky;
+ z-index: 12;
+ background-color: #fff;
+ }
+
+ th:nth-child(1), th:nth-child(2) {
+ z-index: 14;
+ background-color: #f1f3f4;
+ }
+
+ /* Col 1: Language */
+ th:nth-child(1), td:nth-child(1) {
+ left: 0;
+ width: 160px; min-width: 160px; max-width: 160px;
+ border-right: 1px solid #e0e0e0;
+ }
+
+ /* Col 2: Threshold */
+ th:nth-child(2), td:nth-child(2) {
+ left: 160px;
+ width: 90px; min-width: 90px; max-width: 90px;
+ border-right: 2px solid #ccc; /* Heavy freeze line */
+ }
+
+ /* Col 3 & 4: Unfrozen */
+ th:nth-child(3), td:nth-child(3) {
+ width: 110px; min-width: 110px;
+ text-align: right;
+ border-right: 1px solid #f0f0f0;
+ }
+ th:nth-child(4), td:nth-child(4) {
+ width: 100px; min-width: 100px;
+ text-align: right;
+ border-right: 1px solid #f0f0f0;
+ }
+
+ /* --- ROW & DATA STYLING --- */
+
+ .row-80 td:first-child { border-top: 2px solid #e0e0e0; font-weight: bold; color: #202124; }
+ .row-70 td:first-child { padding-left: 20px; color: #5f6368; font-size: 0.8rem; }
+ .row-60 td:first-child { padding-left: 20px; color: #5f6368; font-size: 0.8rem; border-bottom: 1px solid #ccc; }
+
+ .row-80 td { border-top: 2px solid #e0e0e0; }
+ .row-60 td { border-bottom: 1px solid #ccc; }
+
+ /* Highlights */
+ .cell-gap-blue { background-color: #e1f5fe !important; color: #0277bd; font-weight: bold; }
+ .cell-gap-orange { background-color: #fbe9e7 !important; color: #d84315; font-weight: bold; }
+ .cell-zero { color: #e0e0e0; }
+
+ .clickable { cursor: pointer; font-weight: 500; }
+ .clickable:hover { box-shadow: inset 0 0 0 2px #1a73e8; background-color: #f8f9fa; }
+
+ .cell-gap-blue:hover { background-color: #b3e5fc !important; }
+ .cell-gap-orange:hover { background-color: #ffccbc !important; }
+
+ /* Legend */
+ .legend { font-size: 0.85rem; display: flex; gap: 15px; align-items: center; flex-wrap: wrap;}
+ .legend-item { display: flex; align-items: center; gap: 5px; }
+ .swatch { width: 16px; height: 16px; border-radius: 3px; display: inline-block; border: 1px solid #ddd; }
+
+ /* Status & Modal */
+ #status { padding: 10px; margin-bottom: 15px; border-radius: 4px; display: none; }
+ .loading { background-color: #e8f0fe; color: #1967d2; display: block !important; }
+ .error { background-color: #fce8e6; color: #c5221f; display: block !important; }
+
+ .modal-overlay { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 1000; justify-content: center; align-items: center; }
+ .modal-active { display: flex; }
+ .modal-content { background: white; width: 90%; max-width: 600px; max-height: 80vh; border-radius: 8px; box-shadow: 0 4px 6px rgba(0,0,0,0.1); display: flex; flex-direction: column; }
+ .modal-header { padding: 16px 20px; border-bottom: 1px solid #ddd; display: flex; justify-content: space-between; align-items: center; background: #f1f3f4; border-radius: 8px 8px 0 0; }
+ .modal-title { margin: 0; font-size: 1.1rem; color: #202124; }
+ .close-btn { background: none; border: none; font-size: 1.5rem; cursor: pointer; color: #5f6368; }
+ .modal-body { padding: 20px; overflow-y: auto; }
+ ul.font-list { list-style: none; padding: 0; margin: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 10px; }
+ ul.font-list li a { text-decoration: none; color: #1a73e8; font-size: 0.95rem; display: block; padding: 4px 8px; border-radius: 4px; }
+ ul.font-list li a:hover { background-color: #e8f0fe; }
+ </style>
+</head>
+<body>
+
+ <div class="header-section">
+ <h1>Multi-Threshold Tag Coverage Dashboard</h1>
+ <p class="subtitle">Comparing availability at 80, 70, and 60 quality score thresholds.</p>
+
+ <div class="legend">
+ <div class="legend-item"><span class="swatch" style="background:#e1f5fe"></span> <strong>Potential (Blue):</strong> These fonts are >70, but 0 fonts exist >80.</div>
+ <div class="legend-item"><span class="swatch" style="background:#fbe9e7"></span> <strong>Weak (Orange):</strong> These fonts are >60, but 0 fonts exist >70.</div>
+ </div>
+
+ </div>
+
+ <div class="table-container">
+ <table id="dashboard">
+ <thead>
+ </thead>
+ <tbody>
+ </tbody>
+ </table>
+ </div>
+
+ <div id="modal" class="modal-overlay">
+ <div class="modal-content">
+ <div class="modal-header">
+ <h2 id="modal-title" class="modal-title">Fonts List</h2>
+ <button class="close-btn" onclick="closeModal()">×</button>
+ </div>
+ <div class="modal-body">
+ <ul id="font-list" class="font-list"></ul>
+ </div>
+ </div>
+ </div>
+
+<script>
+ // --- Configuration ---
+ const METADATA_URL = "catalog_metadata.json";
+ const TAGS_URL = "https://raw.githubusercontent.com/google/fonts/refs/heads/main/tags/all/families.csv";
+
+ const WEIGHTS = { "Concept": 0.4, "Drawing": 0.3, "Spacing": 0.2, "Wordspace": 0.1 };
+
+ const ALLOWED_SUBSETS = [
+ "latin", "devanagari", "japanese", "korean", "arabic", "thai",
+ "bengali", "chinese-simplified", "cyrillic", "greek", "hebrew",
+ "tamil", "telugu", "gujarati", "kannada", "malayalam", "gurmukhi",
+ "oriya", "myanmar", "armenian", "sinhala", "georgian", "chinese-traditional"
+ ];
+
+ // --- Main Execution ---
+ document.addEventListener("DOMContentLoaded", async () => {
+ const statusDiv = document.getElementById('status');
+ try {
+ const [metaRes, tagsRes] = await Promise.all([ fetch(METADATA_URL), fetch(TAGS_URL) ]);
+ if (!metaRes.ok) throw new Error(`Metadata Error: ${metaRes.status}`);
+ if (!tagsRes.ok) throw new Error(`Tags CSV Error: ${tagsRes.status}`);
+
+ const metadata = await metaRes.json();
+ const tagsText = await tagsRes.text();
+
+ const parsedData = parseCSVFull(tagsText);
+ const { scores, fontTagsMap, uniqueTags } = parsedData;
+
+ const tableData = aggregateData(metadata.familyMetadataList, fontTagsMap, scores, uniqueTags);
+
+ renderTable(tableData, uniqueTags);
+ statusDiv.style.display = 'none';
+
+ } catch (err) {
+ console.error(err);
+ statusDiv.textContent = `Error: ${err.message}`;
+ statusDiv.className = 'error';
+ }
+ });
+
+ // --- Data Processing ---
+ function parseCSVFull(csvText) {
+ const lines = csvText.split('\n');
+ const fontTagsMap = {};
+ const uniqueTags = new Set();
+ const rawQualityValues = {};
+
+ for (let i = 1; i < lines.length; i++) {
+ const row = lines[i].split(',');
+ if (row.length < 3) continue;
+
+ const family = row[0].trim();
+ let rawTag = row[2].trim();
+ let weightStr = (row[3] || "0").trim();
+ if (!rawTag && row[1]) rawTag = row[1].trim();
+ let weight = parseFloat(weightStr);
+
+ if (rawTag.includes("Quality/")) {
+ const parts = rawTag.split("Quality/");
+ const type = parts[1];
+ if (type && !isNaN(weight)) {
+ if (!rawQualityValues[family]) rawQualityValues[family] = {};
+ rawQualityValues[family][type] = weight;
+ }
+ }
+ else {
+ let cleanTag = rawTag.replace(/^[\/"']+|[\/"']+$/g, '');
+ if (cleanTag) {
+ uniqueTags.add(cleanTag);
+ if (!fontTagsMap[family]) fontTagsMap[family] = new Set();
+ fontTagsMap[family].add(cleanTag);
+ }
+ }
+ }
+
+ const scores = {};
+ for (const [family, categories] of Object.entries(rawQualityValues)) {
+ let totalScore = 0;
+ totalScore += (categories['Concept'] || 0) * WEIGHTS['Concept'];
+ totalScore += (categories['Drawing'] || 0) * WEIGHTS['Drawing'];
+ totalScore += (categories['Spacing'] || 0) * WEIGHTS['Spacing'];
+ totalScore += (categories['Wordspace'] || 0) * WEIGHTS['Wordspace'];
+ scores[family] = totalScore;
+ }
+
+ return { scores, fontTagsMap, uniqueTags: Array.from(uniqueTags).sort() };
+ }
+
+ function aggregateData(fontList, fontTagsMap, scores, uniqueTags) {
+ const data = {};
+
+ ALLOWED_SUBSETS.forEach(sub => {
+ data[sub] = {
+ cov70: 0, // Track "Total Tags Covered" at 70 for sorting
+ tagBuckets: {}
+ };
+ uniqueTags.forEach(tag => {
+ data[sub].tagBuckets[tag] = { t80: [], t70: [], t60: [] };
+ });
+ });
+
+ fontList.forEach(font => {
+ const familyName = font.family;
+ const quality = scores[familyName] || 0;
+
+ if (quality < 60) return;
+
+ const fontTags = fontTagsMap[familyName];
+ if (!fontTags) return;
+
+ font.subsets.forEach(subset => {
+ if (!data[subset]) return;
+
+ fontTags.forEach(tag => {
+ const bucket = data[subset].tagBuckets[tag];
+ if (!bucket) return;
+
+ if (quality >= 60) bucket.t60.push(familyName);
+ if (quality >= 70) bucket.t70.push(familyName);
+ if (quality >= 80) bucket.t80.push(familyName);
+ });
+ });
+ });
+
+ // Pre-calculate sums for sorting
+ Object.values(data).forEach(row => {
+ let count70 = 0;
+ uniqueTags.forEach(tag => {
+ const bucket = row.tagBuckets[tag];
+ // "Total Tags Covered at 70": Check if there is AT LEAST ONE font > 70
+ if (bucket.t70.length > 0) count70++;
+ });
+ row.cov70 = count70;
+ });
+
+ return data;
+ }
+
+ // --- Rendering Logic ---
+
+ function createCell(count, higherTierCount = -1, highlightType = null) {
+ const td = document.createElement('td');
+ td.textContent = count;
+
+ if (count === 0) {
+ td.className = 'cell-zero';
+ td.textContent = "-";
+ return td;
+ }
+
+ td.className = 'clickable';
+
+ if (higherTierCount === 0) {
+ if (highlightType === 'blue') {
+ td.classList.add('cell-gap-blue');
+ td.title = "These fonts are >70, but 0 fonts exist >80";
+ }
+ if (highlightType === 'orange') {
+ td.classList.add('cell-gap-orange');
+ td.title = "These fonts are >60, but 0 fonts exist >70";
+ }
+ }
+
+ return td;
+ }
+
+ function renderTable(data, uniqueTags) {
+ const table = document.getElementById('dashboard');
+ const thead = table.querySelector('thead');
+ const tbody = table.querySelector('tbody');
+
+ let headerHTML = `<tr>
+ <th style="min-width:140px;">Language / Script</th>
+ <th title="Metric">Threshold</th>
+ <th title="Sum of Highlighted Cells">Upgrade<br>Opportunities</th>
+ <th title="Number of tags with fonts present">Total Tags<br>Covered</th>`;
+
+ uniqueTags.forEach(tag => {
+ headerHTML += `<th title="${tag}">${tag}</th>`;
+ });
+ headerHTML += `</tr>`;
+ thead.innerHTML = headerHTML;
+
+ // Sort rows by "Total Tags Covered" at 70 (cov70)
+ const sortedSubsets = Object.entries(data).sort((a, b) => b[1].cov70 - a[1].cov70);
+
+ sortedSubsets.forEach(([subset, rowData]) => {
+
+ const tr80 = document.createElement('tr'); tr80.className = 'row-80';
+ const tr70 = document.createElement('tr'); tr70.className = 'row-70';
+ const tr60 = document.createElement('tr'); tr60.className = 'row-60';
+
+ // --- SUM CALCULATION LOGIC ---
+ let sum80 = 0, cov80 = 0;
+ let sum70 = 0, cov70 = 0;
+ let sum60 = 0, cov60 = 0;
+
+ uniqueTags.forEach(tag => {
+ const bucket = rowData.tagBuckets[tag];
+ const c80 = bucket.t80.length;
+ const c70 = bucket.t70.length;
+ const c60 = bucket.t60.length;
+
+ if(c80 > 0) cov80++;
+ if(c70 > 0) cov70++;
+ if(c60 > 0) cov60++;
+
+ if (c70 > 0 && c80 === 0) sum70 += c70;
+ if (c60 > 0 && c70 === 0) sum60 += c60;
+ });
+
+ tr80.innerHTML = `<td>${subset}</td><td style="color:#202124; font-weight:bold;">> 80</td>
+ <td>-</td><td>${cov80}</td>`;
+
+ tr70.innerHTML = `<td> </td><td style="color:#202124; font-weight:bold;">> 70</td>
+ <td>${sum70}</td><td>${cov70}</td>`;
+
+ tr60.innerHTML = `<td> </td><td style="color:#202124; font-weight:bold;">> 60</td>
+ <td>${sum60}</td><td>${cov60}</td>`;
+
+ // Append Tag Cells
+ uniqueTags.forEach(tag => {
+ const bucket = rowData.tagBuckets[tag];
+ const c80 = bucket.t80.length;
+ const c70 = bucket.t70.length;
+ const c60 = bucket.t60.length;
+
+ const td80 = createCell(c80, -1, null);
+ if (c80 > 0) td80.onclick = () => openModal(subset, `${tag} (>80)`, bucket.t80);
+
+ const td70 = createCell(c70, c80, 'blue');
+ if (c70 > 0) td70.onclick = () => openModal(subset, `${tag} (>70)`, bucket.t70);
+
+ const td60 = createCell(c60, c70, 'orange');
+ if (c60 > 0) td60.onclick = () => openModal(subset, `${tag} (>60)`, bucket.t60);
+
+ tr80.appendChild(td80);
+ tr70.appendChild(td70);
+ tr60.appendChild(td60);
+ });
+
+ tbody.appendChild(tr80);
+ tbody.appendChild(tr70);
+ tbody.appendChild(tr60);
+ });
+ }
+
+ const modal = document.getElementById('modal');
+ const modalTitle = document.getElementById('modal-title');
+ const fontListEl = document.getElementById('font-list');
+
+ function openModal(subset, title, fonts) {
+ modalTitle.textContent = `${subset} — ${title} (${fonts.length})`;
+ fontListEl.innerHTML = '';
+ fonts.sort((a, b) => a.localeCompare(b));
+ fonts.forEach(font => {
+ const li = document.createElement('li');
+ const a = document.createElement('a');
+ a.textContent = font;
+ a.href = `https://fonts.google.com/specimen/${font.replace(/ /g, '+')}`;
+ a.target = "_blank";
+ li.appendChild(a);
+ fontListEl.appendChild(li);
+ });
+ modal.classList.add('modal-active');
+ }
+
+ function closeModal() { modal.classList.remove('modal-active'); }
+ modal.addEventListener('click', (e) => { if (e.target === modal) closeModal(); });
+
+</script>
+</body>
+</html>
\ No newline at end of file
--- /dev/null
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Font Usage & Quality Analysis</title>
+ <style>
+ /* --- LAYOUT --- */
+ html, body {
+ height: 100%;
+ margin: 0;
+ overflow: hidden;
+ background: #f8f9fa;
+ color: #202124;
+ font-family: 'Google Sans', Roboto, sans-serif;
+ }
+
+ body {
+ display: flex;
+ flex-direction: column;
+ padding: 20px;
+ box-sizing: border-box;
+ }
+
+ .header-section {
+ flex: 0 0 auto;
+ margin-bottom: 15px;
+ }
+
+ h1 { margin: 0 0 5px 0; }
+ p.subtitle { color: #5f6368; margin: 0 0 15px 0; font-size: 0.9rem; }
+
+ /* The container */
+ .scroll-area {
+ flex: 1;
+ overflow: auto;
+ background: #f8f9fa;
+ border-radius: 8px;
+ position: relative;
+ }
+
+ .table-card {
+ background: white;
+ border-radius: 8px;
+ box-shadow: 0 1px 3px rgba(0,0,0,0.2);
+ margin-bottom: 30px;
+ overflow: hidden;
+ }
+
+ h2.section-title {
+ margin: 0;
+ padding: 15px 20px;
+ background: #e8f0fe;
+ color: #1967d2;
+ font-size: 1rem;
+ border-bottom: 1px solid #d2e3fc;
+ }
+
+ .unscored-header { background: #f1f3f4 !important; color: #5f6368 !important; border-bottom: 1px solid #ddd !important; }
+
+ table { border-collapse: separate; border-spacing: 0; width: 100%;}
+
+ /* Headers */
+ th {
+ background-color: #fff;
+ position: sticky; top: 0; z-index: 10;
+ font-weight: 600; color: #5f6368; font-size: 0.75rem;
+ text-transform: uppercase; letter-spacing: 0.5px;
+ padding: 12px 16px; border-bottom: 1px solid #ddd;
+ text-align: left;
+ white-space: nowrap;
+ cursor: pointer;
+ }
+
+ th:hover { background-color: #e8eaed; color: #202124; }
+
+ td { font-size: 0.9rem; padding: 10px 16px; border-bottom: 1px solid #eee; background: white; }
+ td.num { text-align: right; font-variant-numeric: tabular-nums; }
+
+ /* --- HIGHLIGHTING LOGIC --- */
+ tr.row-risk td { background-color: #fce8e6; color: #c5221f; }
+ tr.row-risk td:first-child { font-weight: bold; border-left: 4px solid #c5221f; }
+
+ tr.row-opportunity td { background-color: #e6f4ea; color: #137333; }
+ tr.row-opportunity td:first-child { font-weight: bold; border-left: 4px solid #137333; }
+
+ /* Legend */
+ .legend { font-size: 0.85rem; display: flex; gap: 20px; align-items: center; flex-wrap: wrap; margin-top: 10px;}
+ .legend-item { display: flex; align-items: center; gap: 6px; }
+ .swatch { width: 16px; height: 16px; border-radius: 3px; display: inline-block; border: 1px solid #ddd; }
+
+ /* Controls Bar */
+ .controls-bar {
+ margin-top: 15px;
+ display: flex;
+ align-items: center;
+ gap: 30px;
+ background: white;
+ padding: 10px 15px;
+ border-radius: 8px;
+ box-shadow: 0 1px 2px rgba(0,0,0,0.1);
+ }
+
+ .control-group { display: flex; flex-direction: column; gap: 5px; }
+ .control-title { font-size: 0.75rem; color: #5f6368; font-weight: 600; text-transform: uppercase; }
+
+ .toggle-label { display: flex; align-items: center; cursor: pointer; font-size: 0.9rem; user-select: none; }
+ .toggle-label input { margin-right: 8px; width: 16px; height: 16px; }
+
+ /* Radio Group */
+ .radio-group { display: flex; gap: 15px; }
+ .radio-label { display: flex; align-items: center; cursor: pointer; font-size: 0.9rem; }
+ .radio-label input { margin-right: 6px; }
+
+ /* Links & Interactions */
+ a.font-link { text-decoration: none; color: inherit; }
+ a.font-link:hover { text-decoration: underline; }
+
+ .clickable-score {
+ cursor: pointer;
+ text-decoration: underline;
+ text-decoration-style: dotted;
+ text-underline-offset: 3px;
+ color: #1a73e8;
+ }
+ .clickable-score:hover {
+ background-color: rgba(0,0,0,0.05);
+ color: #1557b0;
+ }
+
+ #status { padding: 10px; margin-bottom: 15px; border-radius: 4px; display: none; }
+ .loading { background-color: #e8f0fe; color: #1967d2; display: block !important; }
+ .error { background-color: #fce8e6; color: #c5221f; display: block !important; }
+
+ /* --- MODAL STYLES --- */
+ .modal-overlay { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 1000; justify-content: center; align-items: center; }
+ .modal-active { display: flex; }
+ .modal-content { background: white; width: 90%; max-width: 450px; border-radius: 8px; box-shadow: 0 4px 6px rgba(0,0,0,0.1); display: flex; flex-direction: column; overflow: hidden; }
+ .modal-header { padding: 12px 20px; border-bottom: 1px solid #ddd; display: flex; justify-content: space-between; align-items: center; background: #f1f3f4; }
+ .modal-title { margin: 0; font-size: 1.1rem; color: #202124; }
+ .close-btn { background: none; border: none; font-size: 1.5rem; cursor: pointer; color: #5f6368; }
+ .modal-body { padding: 15px 20px; }
+
+ .score-table { width: 100%; border-collapse: collapse; margin-top: 0; font-size: 0.9rem; }
+ .score-table th { background: none; border-bottom: 2px solid #ddd; padding: 6px 4px; text-align: left; color: #5f6368; font-size: 0.75rem; text-transform: uppercase; }
+ .score-table td { padding: 8px 4px; border-bottom: 1px solid #eee; text-align: right; font-variant-numeric: tabular-nums; }
+ .score-table td:first-child { text-align: left; font-weight: 500; width: 35%; }
+ .total-row td { font-weight: bold; border-top: 2px solid #ddd; background-color: #f8f9fa; }
+ </style>
+</head>
+<body>
+
+ <div class="header-section">
+ <h1>Font Usage & Quality Analysis</h1>
+ <p class="subtitle">Comparing 30-day views against amalgamated quality scores.</p>
+
+ <div class="legend">
+ <div class="legend-item">
+ <span class="swatch" style="background:#fce8e6; border-color:#c5221f;"></span>
+ <strong>Attention Needed:</strong> Top 50% Usage & Score < 60
+ </div>
+ <div class="legend-item">
+ <span class="swatch" style="background:#e6f4ea; border-color:#137333;"></span>
+ <strong>Hidden Gem:</strong> Bottom 50% Usage & Score > 70
+ </div>
+ </div>
+
+ <div class="controls-bar">
+ <div class="control-group">
+ <div class="control-title">Filter View</div>
+ <label class="toggle-label">
+ <input type="checkbox" id="filterToggle" onchange="renderTables()" checked>
+ Show Priority Items Only
+ </label>
+ </div>
+
+ <div style="width:1px; height:30px; background:#ddd;"></div>
+
+ <div class="control-group">
+ <div class="control-title">Scoring Model</div>
+ <div class="radio-group">
+ <label class="radio-label">
+ <input type="radio" name="scoreMode" value="standard" checked onchange="updateScoringMode()">
+ Quality Bar (Design Heavy)
+ </label>
+ <label class="radio-label">
+ <input type="radio" name="scoreMode" value="cheap" onchange="updateScoringMode()">
+ Cheap Fix (Spacing Heavy)
+ </label>
+ </div>
+ </div>
+ </div>
+
+ </div>
+
+ <div class="scroll-area">
+
+ <div class="table-card">
+ <table id="main-dashboard">
+ <thead>
+ <tr>
+ <th onclick="sortTable('family')">Font Family</th>
+ <th onclick="sortTable('rank')" class="num">Usage Rank</th>
+ <th onclick="sortTable('views')" class="num">30 Day Views</th>
+ <th onclick="sortTable('score')" class="num">Quality Score</th>
+ <th>Status</th>
+ </tr>
+ </thead>
+ <tbody>
+ </tbody>
+ </table>
+ </div>
+
+ <div class="table-card">
+ <h2 class="section-title unscored-header">Unscored Fonts (Quality Score 0)</h2>
+ <table id="unscored-dashboard">
+ <thead>
+ <tr>
+ <th style="width:40%">Font Family</th>
+ <th class="num">30 Day Views</th>
+ <th class="num">Quality Score</th>
+ <th>Status</th>
+ </tr>
+ </thead>
+ <tbody>
+ </tbody>
+ </table>
+ </div>
+ </div>
+
+ <div id="modal" class="modal-overlay">
+ <div class="modal-content">
+ <div class="modal-header">
+ <h2 id="modal-title" class="modal-title">Score Breakdown</h2>
+ <button class="close-btn" onclick="closeModal()">×</button>
+ </div>
+ <div class="modal-body">
+ <div id="breakdown-container"></div>
+ </div>
+ </div>
+ </div>
+
+<script>
+ // --- Configuration ---
+ const METADATA_URL = "catalog_metadata.json";
+ const TAGS_URL = "https://raw.githubusercontent.com/google/fonts/refs/heads/main/tags/all/families.csv";
+ const USAGE_URL = "usage_data.csv";
+
+ // Scoring Models
+ const WEIGHTS_STANDARD = { "Concept": 0.4, "Drawing": 0.3, "Spacing": 0.2, "Wordspace": 0.1 };
+ const WEIGHTS_CHEAP = { "Concept": 0.1, "Drawing": 0.2, "Spacing": 0.3, "Wordspace": 0.4 };
+
+ // State
+ let ACTIVE_WEIGHTS = WEIGHTS_STANDARD;
+
+ // Data Storage
+ let RAW_METADATA = [];
+ let RAW_SCORES = {}; // { Family: { Concept: 80, Drawing: 50... } }
+ let RAW_USAGE = {};
+
+ let SCORED_DATA = [];
+ let UNSCORED_DATA = [];
+
+ // --- Main Execution ---
+ document.addEventListener("DOMContentLoaded", async () => {
+ const statusDiv = document.getElementById('status');
+ try {
+ const [metaRes, tagsRes, usageRes] = await Promise.all([
+ fetch(METADATA_URL),
+ fetch(TAGS_URL),
+ fetch(USAGE_URL)
+ ]);
+
+ if (!metaRes.ok) throw new Error(`Metadata Error: ${metaRes.status}`);
+ if (!tagsRes.ok) throw new Error(`Tags CSV Error: ${tagsRes.status}`);
+ if (!usageRes.ok) throw new Error(`Usage CSV Error: ${usageRes.status}`);
+
+ RAW_METADATA = (await metaRes.json()).familyMetadataList;
+ const tagsText = await tagsRes.text();
+ const usageText = await usageRes.text();
+
+ RAW_SCORES = parseTagsRaw(tagsText);
+ RAW_USAGE = parseUsageCSV(usageText);
+
+ // Initial Calculation
+ recalculateAllData();
+
+ renderTables();
+ statusDiv.style.display = 'none';
+
+ } catch (err) {
+ console.error(err);
+ statusDiv.innerHTML = `<strong>Error:</strong> ${err.message}<br><small>Note: usage_data.csv must be in the same folder.</small>`;
+ statusDiv.className = 'error';
+ }
+ });
+
+ // --- Parsing Logic ---
+
+ function parseCSVLine(line) {
+ let parts = [];
+ let current = '';
+ let inQuotes = false;
+
+ for (let i = 0; i < line.length; i++) {
+ let char = line[i];
+
+ if (char === '"') {
+ inQuotes = !inQuotes;
+ } else if (char === ',' && !inQuotes) {
+ parts.push(current);
+ current = '';
+ } else {
+ current += char;
+ }
+ }
+ parts.push(current);
+ return parts.map(p => p.trim().replace(/^"|"$/g, ''));
+ }
+
+ // Get Raw Metric Values only (Score calc happens later)
+ function parseTagsRaw(csvText) {
+ const lines = csvText.split('\n');
+ const rawValues = {};
+
+ for (let i = 1; i < lines.length; i++) {
+ const line = lines[i].trim();
+ if(!line) continue;
+
+ const parts = parseCSVLine(line);
+ if (parts.length < 3) continue;
+
+ const family = parts[0];
+ let rawTag = parts[2];
+ let weightStr = parts[3];
+
+ if (!rawTag && parts[1] && parts[1].includes('/')) {
+ rawTag = parts[1];
+ weightStr = parts[2];
+ }
+
+ let weight = parseFloat(weightStr || 0);
+
+ if (rawTag && rawTag.includes("Quality/")) {
+ const type = rawTag.split("Quality/")[1];
+ if (type && !isNaN(weight)) {
+ if (!rawValues[family]) rawValues[family] = {};
+ rawValues[family][type] = weight;
+ }
+ }
+ }
+ return rawValues;
+ }
+
+ function parseUsageCSV(csvText) {
+ const lines = csvText.split('\n');
+ const usage = {};
+
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i].trim();
+ if (!line) continue;
+
+ const parts = parseCSVLine(line);
+ if (parts.length < 2) continue;
+
+ const family = parts[0];
+ const viewString = parts[1].replace(/,/g, '');
+ const views = parseInt(viewString);
+
+ if (!isNaN(views)) {
+ usage[family] = views;
+ }
+ }
+ return usage;
+ }
+
+ // --- Core Logic ---
+
+ function updateScoringMode() {
+ const mode = document.querySelector('input[name="scoreMode"]:checked').value;
+ ACTIVE_WEIGHTS = (mode === 'cheap') ? WEIGHTS_CHEAP : WEIGHTS_STANDARD;
+
+ recalculateAllData();
+ renderTables();
+ }
+
+ function recalculateAllData() {
+ let scored = [];
+ let unscored = [];
+
+ RAW_METADATA.forEach(font => {
+ const family = font.family;
+ const views = RAW_USAGE[family] || 0;
+ const breakdown = RAW_SCORES[family] || {};
+
+ // Calculate Score based on ACTIVE_WEIGHTS
+ let finalScore = 0;
+ finalScore += (breakdown['Concept'] || 0) * ACTIVE_WEIGHTS['Concept'];
+ finalScore += (breakdown['Drawing'] || 0) * ACTIVE_WEIGHTS['Drawing'];
+ finalScore += (breakdown['Spacing'] || 0) * ACTIVE_WEIGHTS['Spacing'];
+ finalScore += (breakdown['Wordspace'] || 0) * ACTIVE_WEIGHTS['Wordspace'];
+
+ const row = { family, views, score: finalScore, breakdown };
+
+ if (finalScore === 0) {
+ unscored.push(row);
+ } else {
+ scored.push(row);
+ }
+ });
+
+ // Rank and Status Logic
+ scored.sort((a, b) => b.views - a.views); // Sort by Views for Ranking
+
+ const medianIndex = Math.floor(scored.length / 2);
+
+ SCORED_DATA = scored.map((row, index) => {
+ const rank = index + 1;
+ const isTopHalf = index < medianIndex;
+
+ let status = "Normal";
+ let type = "normal";
+
+ if (isTopHalf && row.score < 60) {
+ status = "High Usage, Low Quality";
+ type = "risk";
+ } else if (!isTopHalf && row.score > 70) {
+ status = "Low Usage, High Quality";
+ type = "opportunity";
+ }
+
+ return { ...row, rank, type, status };
+ });
+
+ UNSCORED_DATA = unscored.sort((a, b) => b.views - a.views);
+ }
+
+ // --- Rendering ---
+
+ function renderTables() {
+ const mainTbody = document.querySelector('#main-dashboard tbody');
+ const unscoredTbody = document.querySelector('#unscored-dashboard tbody');
+
+ mainTbody.innerHTML = '';
+ unscoredTbody.innerHTML = '';
+
+ const filterActive = document.getElementById('filterToggle').checked;
+
+ SCORED_DATA.forEach(row => {
+ if (filterActive && row.type === 'normal') return;
+
+ const tr = document.createElement('tr');
+ if (row.type === 'risk') tr.className = 'row-risk';
+ if (row.type === 'opportunity') tr.className = 'row-opportunity';
+
+ const viewsStr = row.views.toLocaleString();
+ const scoreStr = row.score.toFixed(1);
+
+ const safeFamily = row.family.replace(/'/g, "\\'");
+ const scoreCell = `<span class="clickable-score" onclick="openScoreModal('${safeFamily}')">${scoreStr}</span>`;
+
+ tr.innerHTML = `
+ <td><a href="https://fonts.google.com/specimen/${row.family.replace(/ /g, '+')}" class="font-link" target="_blank">${row.family}</a></td>
+ <td class="num">#${row.rank}</td>
+ <td class="num">${viewsStr}</td>
+ <td class="num">${scoreCell}</td>
+ <td>${row.status}</td>
+ `;
+ mainTbody.appendChild(tr);
+ });
+
+ UNSCORED_DATA.forEach(row => {
+ const tr = document.createElement('tr');
+ const viewsStr = row.views.toLocaleString();
+
+ tr.innerHTML = `
+ <td><a href="https://fonts.google.com/specimen/${row.family.replace(/ /g, '+')}" class="font-link" target="_blank">${row.family}</a></td>
+ <td class="num">${viewsStr}</td>
+ <td class="num" style="color:#aaa;">0.0</td>
+ <td style="color:#aaa;">Pending Scoring</td>
+ `;
+ unscoredTbody.appendChild(tr);
+ });
+ }
+
+ // --- Modal Logic ---
+ function openScoreModal(familyName) {
+ const font = SCORED_DATA.find(f => f.family === familyName) || UNSCORED_DATA.find(f => f.family === familyName);
+ if (!font) return;
+
+ const modal = document.getElementById('modal');
+ const modalTitle = document.getElementById('modal-title');
+ const container = document.getElementById('breakdown-container');
+
+ modalTitle.textContent = `${font.family} Breakdown`;
+
+ const c = font.breakdown['Concept'] || 0;
+ const d = font.breakdown['Drawing'] || 0;
+ const s = font.breakdown['Spacing'] || 0;
+ const w = font.breakdown['Wordspace'] || 0;
+
+ // Dynamic Calculation based on ACTIVE weights
+ const c_val = (c * ACTIVE_WEIGHTS['Concept']).toFixed(1);
+ const d_val = (d * ACTIVE_WEIGHTS['Drawing']).toFixed(1);
+ const s_val = (s * ACTIVE_WEIGHTS['Spacing']).toFixed(1);
+ const w_val = (w * ACTIVE_WEIGHTS['Wordspace']).toFixed(1);
+
+ const total = font.score.toFixed(1);
+
+ // Convert weights to percentage for display
+ const c_pct = (ACTIVE_WEIGHTS['Concept'] * 100) + '%';
+ const d_pct = (ACTIVE_WEIGHTS['Drawing'] * 100) + '%';
+ const s_pct = (ACTIVE_WEIGHTS['Spacing'] * 100) + '%';
+ const w_pct = (ACTIVE_WEIGHTS['Wordspace'] * 100) + '%';
+
+ container.innerHTML = `
+ <table class="score-table">
+ <thead>
+ <tr>
+ <th>Metric</th>
+ <th>Raw</th>
+ <th>Wgt</th>
+ <th>Val</th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr>
+ <td>Concept</td>
+ <td>${c}</td>
+ <td>${c_pct}</td>
+ <td>${c_val}</td>
+ </tr>
+ <tr>
+ <td>Drawing</td>
+ <td>${d}</td>
+ <td>${d_pct}</td>
+ <td>${d_val}</td>
+ </tr>
+ <tr>
+ <td>Spacing</td>
+ <td>${s}</td>
+ <td>${s_pct}</td>
+ <td>${s_val}</td>
+ </tr>
+ <tr>
+ <td>Wordspace</td>
+ <td>${w}</td>
+ <td>${w_pct}</td>
+ <td>${w_val}</td>
+ </tr>
+ <tr class="total-row">
+ <td>Total</td>
+ <td></td>
+ <td></td>
+ <td>${total}</td>
+ </tr>
+ </tbody>
+ </table>
+ `;
+
+ modal.classList.add('modal-active');
+ }
+
+ function closeModal() {
+ document.getElementById('modal').classList.remove('modal-active');
+ }
+
+ document.getElementById('modal').addEventListener('click', (e) => {
+ if (e.target === document.getElementById('modal')) closeModal();
+ });
+
+ // --- Sorting ---
+ let currentSort = { col: 'views', dir: 'desc' };
+
+ function sortTable(col) {
+ if (currentSort.col === col) {
+ currentSort.dir = currentSort.dir === 'asc' ? 'desc' : 'asc';
+ } else {
+ currentSort.col = col;
+ currentSort.dir = 'desc';
+ }
+
+ SCORED_DATA.sort((a, b) => {
+ let valA = a[col];
+ let valB = b[col];
+
+ if (typeof valA === 'string') {
+ return currentSort.dir === 'asc'
+ ? valA.localeCompare(valB)
+ : valB.localeCompare(valA);
+ }
+ return currentSort.dir === 'asc' ? valA - valB : valB - valA;
+ });
+
+ renderTables();
+ }
+</script>
+</body>
+</html>
\ No newline at end of file