]> git.ipfire.org Git - thirdparty/google/fonts.git/commitdiff
Add Google Fonts Quality Analysis HTML page gh-pages
authorMarc Foley <m.foley.88@gmail.com>
Thu, 25 Sep 2025 13:16:23 +0000 (14:16 +0100)
committerGitHub <noreply@github.com>
Thu, 25 Sep 2025 13:16:23 +0000 (14:16 +0100)
quality-tag-review.html [new file with mode: 0644]

diff --git a/quality-tag-review.html b/quality-tag-review.html
new file mode 100644 (file)
index 0000000..90e6079
--- /dev/null
@@ -0,0 +1,974 @@
+<!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 Analysis</title>
+    <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
+    <style>
+        body {
+            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+            margin: 0;
+            padding: 20px;
+            background-color: #f5f5f5;
+        }
+        .container {
+            max-width: 1200px;
+            margin: 0 auto;
+            background: white;
+            border-radius: 8px;
+            box-shadow: 0 2px 10px rgba(0,0,0,0.1);
+            overflow: hidden;
+        }
+        .header {
+            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+            color: white;
+            padding: 30px;
+            text-align: center;
+        }
+        .header h1 {
+            margin: 0 0 10px 0;
+            font-size: 2.5em;
+            font-weight: 300;
+        }
+        .header p {
+            margin: 0;
+            opacity: 0.9;
+            font-size: 1.1em;
+        }
+        .controls {
+            padding: 20px;
+            background: #f8f9fa;
+            border-bottom: 1px solid #dee2e6;
+        }
+        .weights-section {
+            background: white;
+            padding: 20px;
+            border-radius: 8px;
+            margin-bottom: 20px;
+            box-shadow: 0 1px 3px rgba(0,0,0,0.1);
+        }
+        .filters-section {
+            background: white;
+            padding: 20px;
+            border-radius: 8px;
+            margin-bottom: 20px;
+            box-shadow: 0 1px 3px rgba(0,0,0,0.1);
+        }
+        .filters-title {
+            font-size: 1.2em;
+            font-weight: 600;
+            margin-bottom: 15px;
+            color: #495057;
+        }
+        .tag-filters {
+            display: flex;
+            flex-wrap: wrap;
+            gap: 8px;
+            max-height: 200px;
+            overflow-y: auto;
+            border: 1px solid #dee2e6;
+            border-radius: 6px;
+            padding: 10px;
+            background: #f8f9fa;
+        }
+        .tag-filter {
+            display: flex;
+            align-items: center;
+            gap: 6px;
+            padding: 6px 10px;
+            background: white;
+            border: 1px solid #dee2e6;
+            border-radius: 4px;
+            font-size: 0.85em;
+            cursor: pointer;
+            transition: all 0.2s ease;
+        }
+        .tag-filter:hover {
+            border-color: #dc3545;
+            background: #fff5f5;
+        }
+        .tag-filter.excluded {
+            background: #dc3545;
+            color: white;
+            border-color: #dc3545;
+        }
+        .tag-filter input[type="checkbox"] {
+            margin: 0;
+        }
+        .clear-filters {
+            margin-top: 10px;
+            padding: 8px 16px;
+            background: #6c757d;
+            color: white;
+            border: none;
+            border-radius: 4px;
+            cursor: pointer;
+            font-size: 0.9em;
+        }
+        .clear-filters:hover {
+            background: #5a6268;
+        }
+        .weights-title {
+            font-size: 1.2em;
+            font-weight: 600;
+            margin-bottom: 15px;
+            color: #495057;
+        }
+        .weights-grid {
+            display: grid;
+            grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+            gap: 15px;
+        }
+        .weight-control {
+            display: flex;
+            flex-direction: column;
+            gap: 5px;
+        }
+        .weight-label {
+            font-size: 0.9em;
+            font-weight: 500;
+            color: #495057;
+        }
+        .weight-input {
+            display: flex;
+            align-items: center;
+            gap: 10px;
+        }
+        .weight-slider {
+            flex: 1;
+            -webkit-appearance: none;
+            appearance: none;
+            height: 6px;
+            border-radius: 3px;
+            background: #dee2e6;
+            outline: none;
+        }
+        .weight-slider::-webkit-slider-thumb {
+            -webkit-appearance: none;
+            appearance: none;
+            width: 18px;
+            height: 18px;
+            border-radius: 50%;
+            background: #667eea;
+            cursor: pointer;
+        }
+        .weight-slider::-moz-range-thumb {
+            width: 18px;
+            height: 18px;
+            border-radius: 50%;
+            background: #667eea;
+            cursor: pointer;
+            border: none;
+        }
+        .weight-value {
+            min-width: 40px;
+            font-weight: 600;
+            color: #495057;
+        }
+        .stats {
+            display: flex;
+            gap: 20px;
+            align-items: center;
+            flex-wrap: wrap;
+        }
+        .stat-item {
+            background: white;
+            padding: 15px 20px;
+            border-radius: 6px;
+            box-shadow: 0 1px 3px rgba(0,0,0,0.1);
+        }
+        .stat-label {
+            font-size: 0.9em;
+            color: #6c757d;
+            margin-bottom: 5px;
+        }
+        .stat-value {
+            font-size: 1.5em;
+            font-weight: 600;
+            color: #495057;
+        }
+        .loading {
+            text-align: center;
+            padding: 60px;
+            font-size: 1.2em;
+            color: #6c757d;
+        }
+        .error {
+            text-align: center;
+            padding: 60px;
+            color: #dc3545;
+            font-size: 1.1em;
+        }
+        .table-container {
+            overflow-x: auto;
+        }
+        table {
+            width: 100%;
+            border-collapse: collapse;
+            font-size: 0.95em;
+        }
+        th, td {
+            padding: 12px 16px;
+            text-align: left;
+            border-bottom: 1px solid #dee2e6;
+        }
+        th {
+            background: #f8f9fa;
+            font-weight: 600;
+            color: #495057;
+            position: sticky;
+            top: 0;
+            z-index: 10;
+        }
+        tr:hover {
+            background-color: #f8f9fa;
+        }
+        .font-name {
+            font-weight: 600;
+            color: #495057;
+            font-size: 1.1em;
+        }
+        .font-preview {
+            margin-bottom: 4px;
+            line-height: 1.2;
+        }
+        .font-family-name {
+            font-size: 0.85em;
+            color: #6c757d;
+            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif !important;
+        }
+        .floating-controls {
+            position: fixed;
+            top: 20px;
+            right: 20px;
+            background: white;
+            padding: 20px;
+            border-radius: 8px;
+            box-shadow: 0 4px 12px rgba(0,0,0,0.15);
+            z-index: 1000;
+            min-width: 280px;
+            max-width: 350px;
+        }
+        .floating-controls h3 {
+            margin: 0 0 15px 0;
+            font-size: 1.1em;
+            color: #495057;
+        }
+        .control-group {
+            margin-bottom: 15px;
+        }
+        .control-label {
+            display: block;
+            font-size: 0.9em;
+            font-weight: 500;
+            color: #495057;
+            margin-bottom: 5px;
+        }
+        .control-input {
+            width: 100%;
+            padding: 8px 12px;
+            border: 1px solid #dee2e6;
+            border-radius: 4px;
+            font-size: 0.9em;
+            font-family: inherit;
+        }
+        .control-input:focus {
+            outline: none;
+            border-color: #667eea;
+            box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.2);
+        }
+        
+        .size-control {
+            display: flex;
+            align-items: center;
+            gap: 10px;
+            margin-top: 15px;
+        }
+        .size-slider {
+            flex: 1;
+            -webkit-appearance: none;
+            appearance: none;
+            height: 6px;
+            border-radius: 3px;
+            background: #dee2e6;
+            outline: none;
+        }
+        .size-slider::-webkit-slider-thumb {
+            -webkit-appearance: none;
+            appearance: none;
+            width: 18px;
+            height: 18px;
+            border-radius: 50%;
+            background: #667eea;
+            cursor: pointer;
+        }
+        .size-slider::-moz-range-thumb {
+            width: 18px;
+            height: 18px;
+            border-radius: 50%;
+            background: #667eea;
+            cursor: pointer;
+            border: none;
+        }
+        .size-value {
+            min-width: 40px;
+            font-weight: 600;
+            color: #495057;
+            font-size: 0.9em;
+        }
+        
+        /* Tags popup */
+        .tags-popup {
+            position: absolute;
+            background: white;
+            border: 1px solid #dee2e6;
+            border-radius: 8px;
+            padding: 12px;
+            box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
+            z-index: 1000;
+            max-width: 300px;
+            pointer-events: none;
+            opacity: 0;
+            transform: translateY(-10px);
+            transition: opacity 0.2s ease, transform 0.2s ease;
+        }
+        
+        .tags-popup.visible {
+            opacity: 1;
+            transform: translateY(0);
+        }
+        
+        .tags-popup h4 {
+            margin: 0 0 8px 0;
+            font-size: 0.9em;
+            color: #495057;
+            font-weight: 600;
+        }
+        
+        .tags-list {
+            display: flex;
+            flex-wrap: wrap;
+            gap: 4px;
+        }
+        
+        .tag-item {
+            background: #f8f9fa;
+            border: 1px solid #dee2e6;
+            border-radius: 12px;
+            padding: 2px 8px;
+            font-size: 0.75em;
+            color: #495057;
+        }
+        .quality-tag {
+            background: #e7f3ff;
+            color: #0066cc;
+            padding: 4px 8px;
+            border-radius: 4px;
+            font-size: 0.85em;
+            font-weight: 500;
+        }
+        .score {
+            font-weight: 600;
+            padding: 4px 8px;
+            border-radius: 4px;
+            color: white;
+        }
+        .score-high { background: #28a745; }
+        .score-medium { background: #ffc107; color: #212529; }
+        .score-low { background: #dc3545; }
+        .quality-breakdown {
+            font-size: 0.8em;
+            color: #6c757d;
+            margin-top: 4px;
+        }
+        .weighted-score {
+            font-weight: 700;
+            font-size: 1.1em;
+        }
+        .no-data {
+            text-align: center;
+            padding: 60px;
+            color: #6c757d;
+            font-size: 1.1em;
+        }
+        @media (max-width: 768px) {
+            .stats {
+                flex-direction: column;
+                gap: 10px;
+            }
+            .stat-item {
+                width: 100%;
+                text-align: center;
+            }
+            .floating-controls {
+                position: relative;
+                top: auto;
+                right: auto;
+                margin-bottom: 20px;
+                min-width: auto;
+                max-width: none;
+            }
+            th, td {
+                padding: 8px 12px;
+                font-size: 0.9em;
+            }
+        }
+    </style>
+</head>
+<body>
+    <div id="app">
+        <!-- Floating Controls -->
+                    <div class="floating-controls">
+                <div>
+                    <label style="display: block; margin-bottom: 5px; font-weight: 600; color: #495057; font-size: 0.9em;">Custom Text:</label>
+                    <input 
+                        type="text" 
+                        v-model="previewText" 
+                        placeholder="Enter your preview text..."
+                        class="control-input"
+                    />
+                </div>
+                <div class="size-control">
+                    <label style="font-weight: 600; color: #495057; font-size: 0.9em;">Size:</label>
+                    <input 
+                        type="range" 
+                        v-model="fontSize" 
+                        min="12" 
+                        max="72" 
+                        class="size-slider"
+                    />
+                    <span class="size-value">{{ fontSize }}px</span>
+                </div>
+            </div>
+
+            <!-- Tags popup -->
+            <div 
+                ref="tagsPopup"
+                class="tags-popup"
+                :class="{ visible: popupVisible }"
+                :style="popupStyle"
+            >
+                <h4>Tags</h4>
+                <div class="tags-list">
+                    <span 
+                        v-for="tag in popupTags" 
+                        :key="tag" 
+                        class="tag-item"
+                    >
+                        {{ tag }}
+                    </span>
+                </div>
+            </div>
+
+        <div class="container">
+            <div class="header">
+                <h1>Google Fonts Quality Analysis</h1>
+                <p>Font families filtered by Quality metrics from the Google Fonts repository</p>
+            </div>
+            
+            <div class="controls">
+                <div class="filters-section">
+                    <div class="filters-title">Exclude Tags ({{ excludedTags.size }} excluded)</div>
+                    <div class="tag-filters">
+                        <div 
+                            v-for="tag in availableTags" 
+                            :key="tag"
+                            class="tag-filter"
+                            :class="{ excluded: excludedTags.has(tag) }"
+                            @click="toggleTagExclusion(tag)"
+                        >
+                            <input 
+                                type="checkbox" 
+                                :checked="excludedTags.has(tag)"
+                                @click.stop="toggleTagExclusion(tag)"
+                            >
+                            {{ tag }}
+                        </div>
+                    </div>
+                    <button 
+                        class="clear-filters"
+                        @click="clearAllFilters"
+                        v-if="excludedTags.size > 0"
+                    >
+                        Clear All Filters
+                    </button>
+                </div>
+                
+                <div class="weights-section">
+                    <div class="weights-title">Quality Metric Weights</div>
+                    <div class="weights-grid">
+                        <div class="weight-control">
+                            <div class="weight-label">Concept</div>
+                            <div class="weight-input">
+                                <input 
+                                    type="range" 
+                                    min="0" 
+                                    max="100" 
+                                    v-model.number="weights.concept" 
+                                    class="weight-slider"
+                                    @input="calculateWeightedScores"
+                                >
+                                <span class="weight-value">{{ weights.concept }}%</span>
+                            </div>
+                        </div>
+                        <div class="weight-control">
+                            <div class="weight-label">Drawing</div>
+                            <div class="weight-input">
+                                <input 
+                                    type="range" 
+                                    min="0" 
+                                    max="100" 
+                                    v-model.number="weights.drawing" 
+                                    class="weight-slider"
+                                    @input="calculateWeightedScores"
+                                >
+                                <span class="weight-value">{{ weights.drawing }}%</span>
+                            </div>
+                        </div>
+                        <div class="weight-control">
+                            <div class="weight-label">Spacing</div>
+                            <div class="weight-input">
+                                <input 
+                                    type="range" 
+                                    min="0" 
+                                    max="100" 
+                                    v-model.number="weights.spacing" 
+                                    class="weight-slider"
+                                    @input="calculateWeightedScores"
+                                >
+                                <span class="weight-value">{{ weights.spacing }}%</span>
+                            </div>
+                        </div>
+                        <div class="weight-control">
+                            <div class="weight-label">Wordspace</div>
+                            <div class="weight-input">
+                                <input 
+                                    type="range" 
+                                    min="0" 
+                                    max="100" 
+                                    v-model.number="weights.wordspace" 
+                                    class="weight-slider"
+                                    @input="calculateWeightedScores"
+                                >
+                                <span class="weight-value">{{ weights.wordspace }}%</span>
+                            </div>
+                        </div>
+                    </div>
+                </div>
+                
+                <div class="stats">
+                    <div class="stat-item">
+                        <div class="stat-label">Visible Font Families</div>
+                        <div class="stat-value">{{ filteredFamilies.length }}</div>
+                    </div>
+                    <div class="stat-item">
+                        <div class="stat-label">Total Font Families</div>
+                        <div class="stat-value">{{ processedFamilies.length }}</div>
+                    </div>
+                    <div class="stat-item">
+                        <div class="stat-label">Average Weighted Score</div>
+                        <div class="stat-value">{{ averageWeightedScore }}</div>
+                    </div>
+                    <div class="stat-item">
+                        <div class="stat-label">Weight Total</div>
+                        <div class="stat-value">{{ totalWeight }}%</div>
+                    </div>
+                </div>
+            </div>
+
+            <div v-if="loading" class="loading">
+                Loading CSV data...
+            </div>
+
+            <div v-else-if="error" class="error">
+                {{ error }}
+            </div>
+
+            <div v-else-if="filteredFamilies.length === 0" class="no-data">
+                No font families match the current filters.
+            </div>
+
+            <div v-else class="table-container">
+                <table>
+                    <thead>
+                        <tr>
+                            <th>Font Family</th>
+                            <th>Weighted Score</th>
+                            <th>Quality Breakdown</th>
+                        </tr>
+                    </thead>
+                    <tbody>
+                        <tr v-for="(family, index) in filteredFamilies" :key="index"
+                            @dblclick="showTagsPopup($event, family)"
+                            @mouseleave="hideTagsPopup">
+                            <td class="font-name">
+                                <div 
+                                    class="font-preview" 
+                                    :style="{ 
+                                        fontFamily: family.fontFamily, 
+                                        fontSize: fontSize + 'px' 
+                                    }"
+                                >
+                                    {{ previewText }}
+                                </div>
+                                <div class="font-family-name">{{ family.name }}</div>
+                            </td>
+                            <td>
+                                <span class="score weighted-score" :class="getScoreClass(family.weightedScore)">
+                                    {{ family.weightedScore.toFixed(1) }}
+                                </span>
+                            </td>
+                            <td>
+                                <div class="quality-breakdown">
+                                    <div v-if="family.scores.concept !== null">Concept: {{ family.scores.concept }}</div>
+                                    <div v-if="family.scores.drawing !== null">Drawing: {{ family.scores.drawing }}</div>
+                                    <div v-if="family.scores.spacing !== null">Spacing: {{ family.scores.spacing }}</div>
+                                    <div v-if="family.scores.wordspace !== null">Wordspace: {{ family.scores.wordspace }}</div>
+                                </div>
+                            </td>
+                        </tr>
+                    </tbody>
+                </table>
+            </div>
+        </div>
+    </div>
+
+    <script>
+        const { createApp } = Vue;
+
+        createApp({
+            data() {
+                return {
+                    csvData: [],
+                    filteredData: [],
+                    allCsvData: [],
+                    processedFamilies: [],
+                    loading: true,
+                    error: null,
+                    loadedFonts: new Set(),
+                    availableTags: [],
+                    excludedTags: new Set(),
+                    previewText: 'The quick brown fox jumps over the lazy dog',
+                    fontSize: 24,
+                    popupVisible: false,
+                    popupTags: [],
+                    popupStyle: {},
+                    weights: {
+                        concept: 25,
+                        drawing: 25,
+                        spacing: 25,
+                        wordspace: 25
+                    }
+                }
+            },
+            computed: {
+                uniqueFamilies() {
+                    const families = new Set(this.filteredData.map(row => row.fontFamily));
+                    return families.size;
+                },
+                averageWeightedScore() {
+                    if (this.filteredFamilies.length === 0) return '0.0';
+                    const total = this.filteredFamilies.reduce((sum, family) => sum + family.weightedScore, 0);
+                    return (total / this.filteredFamilies.length).toFixed(1);
+                },
+                totalWeight() {
+                    return this.weights.concept + this.weights.drawing + this.weights.spacing + this.weights.wordspace;
+                },
+                filteredFamilies() {
+                    if (this.excludedTags.size === 0) {
+                        return this.processedFamilies;
+                    }
+                    
+                    return this.processedFamilies.filter(family => {
+                        // Check if this family has any excluded tags
+                        return !family.tags.some(tag => this.excludedTags.has(tag));
+                    });
+                }
+            },
+            methods: {
+                async fetchCSV() {
+                    try {
+                        const response = await fetch('https://raw.githubusercontent.com/google/fonts/refs/heads/main/tags/all/families.csv');
+                        if (!response.ok) {
+                            throw new Error(`HTTP error! status: ${response.status}`);
+                        }
+                        const csvText = await response.text();
+                        this.parseCSV(csvText);
+                    } catch (err) {
+                        this.error = `Failed to fetch CSV data: ${err.message}`;
+                        console.error('Error fetching CSV:', err);
+                    } finally {
+                        this.loading = false;
+                    }
+                },
+                parseCSV(csvText) {
+                    try {
+                        const lines = csvText.trim().split('\n');
+                        const allData = [];
+                        const qualityData = [];
+                        
+                        for (let line of lines) {
+                            // Split by comma, handling potential commas in quoted fields
+                            const columns = line.split(',');
+                            
+                            if (columns.length >= 4) {
+                                const fontFamily = columns[0].trim();
+                                const category = columns[2].trim();
+                                const score = columns[3].trim();
+                                
+                                // Store all data for tag extraction
+                                allData.push({
+                                    fontFamily: fontFamily,
+                                    category: category.replace(/^\//, '').replace(/\/$/, ''), // Remove leading/trailing slashes
+                                    score: parseFloat(score)
+                                });
+                                
+                                // Filter for rows where column 2 contains "Quality"
+                                if (category.includes('Quality')) {
+                                    qualityData.push({
+                                        fontFamily: fontFamily,
+                                        qualityCategory: category.replace(/^\//, '').replace(/\/$/, ''), // Remove leading/trailing slashes
+                                        score: parseFloat(score)
+                                    });
+                                }
+                            }
+                        }
+                        
+                        this.allCsvData = allData;
+                        this.filteredData = qualityData;
+                    this.extractAvailableTags();
+                    this.loadSettingsFromUrl();
+                    this.groupByFamilyAndCalculateScores();                        if (qualityData.length === 0) {
+                            this.error = 'No rows found with "Quality" in the category column.';
+                        }
+                    } catch (err) {
+                        this.error = `Failed to parse CSV data: ${err.message}`;
+                        console.error('Error parsing CSV:', err);
+                    }
+                },
+                extractAvailableTags() {
+                    const tagsSet = new Set();
+                    
+                    for (const row of this.allCsvData) {
+                        // Skip quality tags since we're already filtering by those
+                        if (!row.category.includes('Quality')) {
+                            tagsSet.add(row.category);
+                        }
+                    }
+                    
+                    this.availableTags = Array.from(tagsSet).sort();
+                },
+                loadSettingsFromUrl() {
+                    const urlParams = new URLSearchParams(window.location.search);
+                    
+                    // Load weights from URL parameters
+                    const concept = urlParams.get('concept');
+                    const drawing = urlParams.get('drawing');
+                    const spacing = urlParams.get('spacing');
+                    const wordspace = urlParams.get('wordspace');
+                    
+                    if (concept !== null) this.weights.concept = parseInt(concept) || 25;
+                    if (drawing !== null) this.weights.drawing = parseInt(drawing) || 25;
+                    if (spacing !== null) this.weights.spacing = parseInt(spacing) || 25;
+                    if (wordspace !== null) this.weights.wordspace = parseInt(wordspace) || 25;
+                    
+                    // Load excluded tags
+                    const excludedParam = urlParams.get('exclude');
+                    if (excludedParam) {
+                        const excludedArray = excludedParam.split(',').filter(tag => tag.trim());
+                        this.excludedTags = new Set(excludedArray);
+                    }
+                },
+                groupByFamilyAndCalculateScores() {
+                    const familyMap = new Map();
+                    
+                    // Group scores by family and quality type
+                    for (const row of this.filteredData) {
+                        if (!familyMap.has(row.fontFamily)) {
+                            familyMap.set(row.fontFamily, {
+                                name: row.fontFamily,
+                                fontFamily: this.formatFontFamily(row.fontFamily),
+                                tags: null, // Tags will be calculated on demand
+                                scores: {
+                                    concept: null,
+                                    drawing: null,
+                                    spacing: null,
+                                    wordspace: null
+                                }
+                            });
+                        }
+                        
+                        const family = familyMap.get(row.fontFamily);
+                        
+                        const category = row.qualityCategory.toLowerCase();
+                        
+                        if (category.includes('concept')) {
+                            family.scores.concept = row.score;
+                        } else if (category.includes('drawing')) {
+                            family.scores.drawing = row.score;
+                        } else if (category.includes('spacing')) {
+                            family.scores.spacing = row.score;
+                        } else if (category.includes('wordspace')) {
+                            family.scores.wordspace = row.score;
+                        }
+                    }
+                    
+                    this.processedFamilies = Array.from(familyMap.values());
+                    this.calculateWeightedScores();
+                    this.loadGoogleFonts();
+                },
+                formatFontFamily(fontName) {
+                    // Format font name for CSS font-family property
+                    return `"${fontName}", sans-serif`;
+                },
+                formatFontNameForUrl(fontName) {
+                    // Format font name for Google Fonts URL
+                    return fontName.replace(/\s+/g, '+');
+                },
+                loadGoogleFonts() {
+                    // Load all fonts but batch them to avoid overwhelming the browser
+                    const fontsToLoad = this.filteredFamilies;
+                    
+                    // Load fonts in batches of 20 with a small delay between batches
+                    const batchSize = 20;
+                    for (let i = 0; i < fontsToLoad.length; i += batchSize) {
+                        setTimeout(() => {
+                            const batch = fontsToLoad.slice(i, i + batchSize);
+                            for (const family of batch) {
+                                if (!this.loadedFonts.has(family.name)) {
+                                    this.loadGoogleFont(family.name);
+                                    this.loadedFonts.add(family.name);
+                                }
+                            }
+                        }, (i / batchSize) * 100); // 100ms delay between batches
+                    }
+                },
+                loadGoogleFont(fontName) {
+                    const formattedName = this.formatFontNameForUrl(fontName);
+                    const url = `https://fonts.googleapis.com/css2?family=${formattedName}`;
+                    
+                    // Create link element
+                    const link = document.createElement('link');
+                    link.rel = 'stylesheet';
+                    link.href = url;
+                    
+                    // Add error handling
+                    link.onerror = () => {
+                        console.warn(`Failed to load font: ${fontName}`);
+                    };
+                    
+                    // Append to head
+                    document.head.appendChild(link);
+                },
+                toggleTagExclusion(tag) {
+                    if (this.excludedTags.has(tag)) {
+                        this.excludedTags.delete(tag);
+                    } else {
+                        this.excludedTags.add(tag);
+                    }
+                    // Trigger reactivity
+                    this.excludedTags = new Set(this.excludedTags);
+                    
+                    // Update URL and reload fonts
+                    this.updateUrl();
+                    this.loadGoogleFonts();
+                },
+                clearAllFilters() {
+                    this.excludedTags.clear();
+                    this.excludedTags = new Set();
+                    this.updateUrl();
+                    this.loadGoogleFonts();
+                },
+                updateUrl() {
+                    const url = new URL(window.location.href);
+                    url.search = ''; // Clear existing parameters
+                    
+                    // Add weight parameters
+                    url.searchParams.set('concept', this.weights.concept);
+                    url.searchParams.set('drawing', this.weights.drawing);
+                    url.searchParams.set('spacing', this.weights.spacing);
+                    url.searchParams.set('wordspace', this.weights.wordspace);
+                    
+                    // Add excluded tags
+                    if (this.excludedTags.size > 0) {
+                        const excludedArray = Array.from(this.excludedTags);
+                        url.searchParams.set('exclude', excludedArray.join(','));
+                    }
+                    
+                    // Update browser URL without reloading
+                    window.history.replaceState({}, '', url.toString());
+                },
+                showTagsPopup(event, family) {
+                    // Calculate tags on demand for this family
+                    if (family.tags === null) {
+                        const familyTags = new Set();
+                        for (const row of this.allCsvData) {
+                            if (row.fontFamily === family.name && !row.category.includes('Quality')) {
+                                familyTags.add(row.category);
+                            }
+                        }
+                        family.tags = Array.from(familyTags).sort();
+                    }
+                    
+                    this.popupTags = family.tags;
+                    
+                    // Position the popup
+                    const rect = event.currentTarget.getBoundingClientRect();
+                    this.popupStyle = {
+                        left: Math.min(rect.right + 10, window.innerWidth - 320) + 'px',
+                        top: rect.top + window.scrollY + 'px'
+                    };
+                    
+                    this.popupVisible = true;
+                },
+                hideTagsPopup() {
+                    this.popupVisible = false;
+                    this.popupTags = [];
+                },
+                calculateWeightedScores() {
+                    for (const family of this.processedFamilies) {
+                        let weightedSum = 0;
+                        let totalWeight = 0;
+                        
+                        if (family.scores.concept !== null) {
+                            weightedSum += family.scores.concept * (this.weights.concept / 100);
+                            totalWeight += this.weights.concept / 100;
+                        }
+                        if (family.scores.drawing !== null) {
+                            weightedSum += family.scores.drawing * (this.weights.drawing / 100);
+                            totalWeight += this.weights.drawing / 100;
+                        }
+                        if (family.scores.spacing !== null) {
+                            weightedSum += family.scores.spacing * (this.weights.spacing / 100);
+                            totalWeight += this.weights.spacing / 100;
+                        }
+                        if (family.scores.wordspace !== null) {
+                            weightedSum += family.scores.wordspace * (this.weights.wordspace / 100);
+                            totalWeight += this.weights.wordspace / 100;
+                        }
+                        
+                        // Calculate weighted average (only using available scores)
+                        family.weightedScore = totalWeight > 0 ? weightedSum / totalWeight : 0;
+                    }
+                    
+                    // Sort families by weighted score (highest first)
+                    this.processedFamilies.sort((a, b) => b.weightedScore - a.weightedScore);
+                    
+                    // Update URL when weights change
+                    this.updateUrl();
+                },
+                getScoreClass(score) {
+                    const numScore = parseFloat(score);
+                    if (numScore >= 80) return 'score-high';
+                    if (numScore >= 60) return 'score-medium';
+                    return 'score-low';
+                }
+            },
+            mounted() {
+                this.fetchCSV();
+            }
+        }).mount('#app');
+    </script>
+</body>
+</html>