1042 lines
40 KiB
HTML
1042 lines
40 KiB
HTML
<!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.selected {
|
|
background: #e7f3ff;
|
|
color: #0066cc;
|
|
border-color: #b6d4ff;
|
|
}
|
|
.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;
|
|
}
|
|
}
|
|
.export-btn {
|
|
display: block;
|
|
width: 100%;
|
|
padding: 10px;
|
|
margin-top: 15px;
|
|
background: #28a745;
|
|
color: white;
|
|
border: none;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
font-weight: 600;
|
|
font-size: 0.9em;
|
|
transition: background 0.2s;
|
|
}
|
|
.export-btn:hover {
|
|
background: #218838;
|
|
}
|
|
</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>
|
|
<button @click="exportCSV" class="export-btn">
|
|
Export CSV
|
|
</button>
|
|
</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">Filter by Tags ({{ selectedTags.size }} selected)</div>
|
|
<div class="tag-filters">
|
|
<div
|
|
v-for="tag in availableTags"
|
|
:key="tag"
|
|
class="tag-filter"
|
|
:class="{ selected: selectedTags.has(tag) }"
|
|
@click="toggleTagSelection(tag)"
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
:checked="selectedTags.has(tag)"
|
|
@click.stop="toggleTagSelection(tag)"
|
|
>
|
|
{{ tag }}
|
|
</div>
|
|
</div>
|
|
<button
|
|
class="clear-filters"
|
|
@click="clearTagFilters"
|
|
v-if="selectedTags.size > 0"
|
|
>
|
|
Clear Tag 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: [],
|
|
selectedTags: 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.selectedTags.size === 0) {
|
|
return this.processedFamilies;
|
|
}
|
|
const needed = Array.from(this.selectedTags);
|
|
return this.processedFamilies.filter(family => {
|
|
const tags = family.tags || [];
|
|
return needed.every(tag => tags.includes(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 selected tags
|
|
const includedParam = urlParams.get('tags');
|
|
if (includedParam) {
|
|
const includedArray = includedParam.split(',').filter(tag => tag.trim());
|
|
this.selectedTags = new Set(includedArray);
|
|
}
|
|
},
|
|
groupByFamilyAndCalculateScores() {
|
|
// Build a map of non-Quality tags per family
|
|
const familyTagsMap = new Map();
|
|
for (const row of this.allCsvData) {
|
|
if (!row.category.includes('Quality')) {
|
|
if (!familyTagsMap.has(row.fontFamily)) familyTagsMap.set(row.fontFamily, new Set());
|
|
familyTagsMap.get(row.fontFamily).add(row.category);
|
|
}
|
|
}
|
|
|
|
const familyMap = new Map();
|
|
|
|
// Group scores by family and quality type
|
|
for (const row of this.filteredData) {
|
|
if (!familyMap.has(row.fontFamily)) {
|
|
const tags = Array.from(familyTagsMap.get(row.fontFamily) || []).sort();
|
|
familyMap.set(row.fontFamily, {
|
|
name: row.fontFamily,
|
|
fontFamily: this.formatFontFamily(row.fontFamily),
|
|
tags,
|
|
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);
|
|
},
|
|
toggleTagSelection(tag) {
|
|
if (this.selectedTags.has(tag)) {
|
|
this.selectedTags.delete(tag);
|
|
} else {
|
|
this.selectedTags.add(tag);
|
|
}
|
|
// Trigger reactivity
|
|
this.selectedTags = new Set(this.selectedTags);
|
|
|
|
// Update URL and reload fonts
|
|
this.updateUrl();
|
|
this.loadGoogleFonts();
|
|
},
|
|
clearTagFilters() {
|
|
this.selectedTags.clear();
|
|
this.selectedTags = 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 selected tags
|
|
if (this.selectedTags.size > 0) {
|
|
const selectedArray = Array.from(this.selectedTags);
|
|
url.searchParams.set('tags', selectedArray.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';
|
|
},
|
|
exportCSV() {
|
|
// Header
|
|
let csvContent = "data:text/csv;charset=utf-8,Font Family,Weighted Score,Tags\n";
|
|
|
|
// Loop through currently filtered families
|
|
this.filteredFamilies.forEach(family => {
|
|
// 1. Ensure tags are calculated (they might be null if not yet viewed)
|
|
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();
|
|
}
|
|
|
|
// 2. Safe-guard data for CSV format (escape quotes)
|
|
const safeName = `"${family.name.replace(/"/g, '""')}"`;
|
|
// Join tags with semicolons so they don't break the CSV columns
|
|
const safeTags = `"${family.tags.join(';').replace(/"/g, '""')}"`;
|
|
const score = family.weightedScore.toFixed(2);
|
|
|
|
// 3. Append row
|
|
csvContent += `${safeName},${score},${safeTags}\n`;
|
|
});
|
|
|
|
// Trigger download
|
|
const encodedUri = encodeURI(csvContent);
|
|
const link = document.createElement("a");
|
|
link.setAttribute("href", encodedUri);
|
|
link.setAttribute("download", "google_fonts_quality_export.csv");
|
|
document.body.appendChild(link); // Required for Firefox
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
}
|
|
|
|
},
|
|
mounted() {
|
|
this.fetchCSV();
|
|
}
|
|
}).mount('#app');
|
|
</script>
|
|
</body>
|
|
</html>
|