Files
fonts/.ci/familyexplorer.html
2025-11-06 16:14:59 +00:00

598 lines
23 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 Family Explorer</title>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<style>
:root {
--bg: #f5f5f5;
--card: #ffffff;
--muted: #6c757d;
--border: #dee2e6;
--primary: #667eea;
--primary-2: #764ba2;
--danger: #dc3545;
}
* { box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
margin: 0;
padding: 20px;
background: var(--bg);
color: #212529;
}
.container {
max-width: 1200px;
margin: 0 auto;
background: var(--card);
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
overflow: hidden;
}
.header {
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-2) 100%);
color: white;
padding: 30px;
text-align: center;
}
.header h1 { margin: 0 0 8px; font-size: 2.2em; font-weight: 300; }
.header p { margin: 0; opacity: 0.9; }
.controls { padding: 20px; background: #f8f9fa; border-bottom: 1px solid var(--border); }
.section {
background: white;
padding: 16px;
border-radius: 8px;
margin-bottom: 16px;
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
}
.section-title { font-weight: 600; color: #495057; margin: 0 0 10px; }
.row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
@media (max-width: 900px) { .row { grid-template-columns: 1fr; } }
.chip-list { display: flex; flex-wrap: wrap; gap: 8px; max-height: 220px; overflow: auto; padding: 8px; border: 1px solid var(--border); border-radius: 6px; background: #f8f9fa; }
.chip { display: inline-flex; align-items: center; gap: 6px; padding: 6px 10px; border: 1px solid var(--border); border-radius: 12px; background: white; font-size: .85em; cursor: pointer; }
.chip input { margin: 0; }
.chip.selected { background: #e7f3ff; border-color: #b6dcff; }
.chip:hover { background: #fff5f5; border-color: var(--danger); }
.toolbar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-bottom: 10px; }
.btn { padding: 6px 10px; border: 1px solid var(--border); background: white; border-radius: 6px; cursor: pointer; font-size: .9em; }
.btn.primary { background: var(--primary); color: white; border-color: var(--primary); }
.btn.muted { background: #6c757d; color: white; border-color: #6c757d; }
.btn:disabled { opacity: .6; cursor: not-allowed; }
.segmented { display: inline-flex; border: 1px solid var(--border); border-radius: 6px; overflow: hidden; }
.segmented button { border: none; background: white; padding: 6px 10px; cursor: pointer; }
.segmented button.active { background: #e9ecef; font-weight: 600; }
.search { width: 100%; padding: 8px 10px; border: 1px solid var(--border); border-radius: 6px; font-size: .95em; }
.muted { color: var(--muted); font-size: .9em; }
.stats { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; }
.stat { background: white; padding: 12px 14px; border-radius: 6px; box-shadow: 0 1px 3px rgba(0,0,0,.06); }
.stat .label { color: var(--muted); font-size: .85em; margin-bottom: 4px; }
.stat .value { font-weight: 700; font-size: 1.2em; }
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; font-size: .95em; }
th, td { padding: 12px 16px; border-bottom: 1px solid var(--border); text-align: left; }
th { background: #f8f9fa; position: sticky; top: 0; z-index: 1; }
tr:hover { background: #fafafa; }
.family-name { font-weight: 600; color: #495057; font-size: 1.05em; }
.font-preview { margin-top: 6px; color: #343a40; }
.tag { background: #f8f9fa; border: 1px solid var(--border); border-radius: 12px; padding: 2px 8px; font-size: .75em; margin: 0 6px 6px 0; display: inline-block; }
.floating-controls { position: fixed; top: 20px; right: 20px; background: white; padding: 16px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,.15); z-index: 5; min-width: 280px; }
.floating-controls .group { margin-bottom: 10px; }
.floating-controls label { display: block; font-size: .9em; font-weight: 600; color: #495057; margin-bottom: 6px; }
.floating-controls .control-input { width: 100%; padding: 8px 10px; border: 1px solid var(--border); border-radius: 6px; font-size: .95em; }
.floating-controls .size { display: flex; align-items: center; gap: 10px; }
.size input[type="range"] { flex: 1; }
.size .val { min-width: 40px; text-align: right; font-weight: 600; color: #495057; }
.loading, .error, .empty { text-align: center; padding: 60px; }
.error { color: var(--danger); }
</style>
<!-- Preload commonly used APIs -->
</head>
<body>
<div id="app">
<!-- Floating Preview Controls -->
<div class="floating-controls">
<div class="group">
<label>Custom Text</label>
<input class="control-input" type="text" v-model="previewText" placeholder="Type to preview…" />
</div>
<div class="size">
<label style="margin:0;">Size</label>
<input type="range" min="12" max="96" v-model.number="previewSize" />
<span class="val">{{ previewSize }}px</span>
</div>
</div>
<div class="container">
<div class="header">
<h1>Google Fonts Family Explorer</h1>
<p>Select families by Tags and OpenType features</p>
</div>
<div class="controls">
<div class="row">
<!-- Tags filter -->
<div class="section">
<div class="section-title">Tags</div>
<div class="toolbar">
<input class="search" type="search" v-model="tagQuery" placeholder="Search tags…" />
<div class="segmented" role="tablist" aria-label="Tag match mode">
<button :class="{active: tagMatchMode==='any'}" @click="tagMatchMode='any'; persistToUrl()">Any</button>
<button :class="{active: tagMatchMode==='all'}" @click="tagMatchMode='all'; persistToUrl()">All</button>
</div>
<button class="btn" @click="selectAllTags" :disabled="filteredTags.length===0">Select All</button>
<button class="btn muted" @click="clearTags" :disabled="selectedTags.size===0">Clear</button>
</div>
<div class="chip-list" aria-label="Selectable tags">
<label v-for="tag in filteredTags" :key="tag" class="chip" :class="{selected: selectedTags.has(tag)}">
<input type="checkbox" :checked="selectedTags.has(tag)" @change="toggleTag(tag)" />
<span>{{ tag }}</span>
</label>
</div>
<div class="muted" style="margin-top:6px;">{{ selectedTags.size }} selected</div>
</div>
<!-- OpenType features filter -->
<div class="section">
<div class="section-title">OpenType Features</div>
<div class="toolbar">
<input class="search" type="search" v-model="featureQuery" placeholder="Search features (e.g. smcp, ss01)…" />
<div class="segmented" role="tablist" aria-label="Feature match mode">
<button :class="{active: featureMatchMode==='any'}" @click="featureMatchMode='any'; persistToUrl()">Any</button>
<button :class="{active: featureMatchMode==='all'}" @click="featureMatchMode='all'; persistToUrl()">All</button>
</div>
<button class="btn muted" @click="clearFeatures" :disabled="selectedFeatures.size===0">Clear</button>
</div>
<div class="chip-list" aria-label="Selectable OpenType features">
<label v-for="feat in filteredFeatures" :key="feat" class="chip" :class="{selected: selectedFeatures.has(feat)}">
<input type="checkbox" :checked="selectedFeatures.has(feat)" @change="toggleFeature(feat)" />
<span>{{ feat }}</span>
</label>
</div>
<div class="muted" style="margin-top:6px;">{{ selectedFeatures.size }} selected</div>
</div>
</div>
<div class="section">
<div class="stats">
<div class="stat">
<div class="label">Visible Families</div>
<div class="value">{{ filteredFamilies.length }}</div>
</div>
<div class="stat">
<div class="label">Total Families</div>
<div class="value">{{ totalFamilies }}</div>
</div>
<div class="stat">
<div class="label">Selected Tags</div>
<div class="value">{{ selectedTags.size }}</div>
</div>
<div class="stat">
<div class="label">Selected Features</div>
<div class="value">{{ selectedFeatures.size }}</div>
</div>
<button class="btn primary" @click="exportActiveFamilies" :disabled="filteredFamilies.length===0">Export visible families (.txt)</button>
</div>
</div>
</div>
<div v-if="loading" class="loading">Loading data…</div>
<div v-else-if="error" class="error">{{ error }}</div>
<div v-else-if="filteredFamilies.length === 0" class="empty">No families match the current filters.</div>
<div v-else class="table-wrap">
<table>
<thead>
<tr>
<th style="width: 28%;">Family</th>
<th>Preview</th>
<th style="width: 30%;">Tags</th>
<th style="width: 18%;">Features</th>
</tr>
</thead>
<tbody>
<tr v-for="fam in filteredFamilies" :key="fam">
<td>
<div class="family-name">{{ fam }}</div>
<div class="muted">{{ fontSourceLabel(fam) }}</div>
</td>
<td>
<div :style="previewStyle(fam)" class="font-preview">
{{ previewText }}
</div>
</td>
<td>
<span v-for="tag in (familyTags.get(fam) || [])" :key="fam + tag" class="tag">{{ tag }}</span>
</td>
<td>
<span v-for="feat in (familyFeatures.get(fam) || [])" :key="fam + feat" class="tag">{{ feat }}</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<script>
const { createApp } = Vue;
createApp({
data() {
return {
loading: true,
error: null,
// Raw inputs
csvText: '',
featuresJson: null,
// Built data
allTags: [], // array of unique tag strings
familyTags: new Map(), // Map<family, string[]>
familyFeatures: new Map(), // Map<family, string[]>
familyFontUrls: new Map(), // Map<family, string> - GitHub raw URLs
allFeatures: [], // unique features
allFamilies: [], // union of families across tags and features // Controls
previewText: 'The quick brown fox jumps over the lazy dog',
previewSize: 24,
tagQuery: '',
featureQuery: '',
tagMatchMode: 'any', // 'any' | 'all'
featureMatchMode: 'any', // 'any' | 'all'
selectedTags: new Set(),
selectedFeatures: new Set(),
}
},
computed: {
totalFamilies() { return this.allFamilies.length; },
filteredTags() {
const q = this.tagQuery.trim().toLowerCase();
if (!q) return this.allTags;
return this.allTags.filter(t => t.toLowerCase().includes(q));
},
filteredFeatures() {
const q = this.featureQuery.trim().toLowerCase();
if (!q) return this.allFeatures;
return this.allFeatures.filter(f => f.toLowerCase().includes(q));
},
filteredFamilies() {
const tagSel = Array.from(this.selectedTags);
const featSel = Array.from(this.selectedFeatures);
return this.allFamilies.filter(fam => {
const famTags = this.familyTags.get(fam) || [];
const famFeats = this.familyFeatures.get(fam) || [];
// Tag filter
if (tagSel.length) {
const tagMatchCount = tagSel.filter(t => famTags.includes(t)).length;
const tagOk = this.tagMatchMode === 'all' ? tagMatchCount === tagSel.length : tagMatchCount > 0;
if (!tagOk) return false;
}
// Feature filter
if (featSel.length) {
const featMatchCount = featSel.filter(ft => famFeats.includes(ft)).length;
const featOk = this.featureMatchMode === 'all' ? featMatchCount === featSel.length : featMatchCount > 0;
if (!featOk) return false;
}
return true;
});
}
},
methods: {
async init() {
try {
await Promise.all([
this.fetchFamiliesCsv(),
this.fetchFamilyFeaturesJson(),
]);
this.buildDataStructures();
this.restoreFromUrl();
// Initial font load for visible families (batched)
this.batchLoadGoogleFonts(this.filteredFamilies);
} catch (e) {
this.error = e?.message || String(e);
console.error(e);
} finally {
this.loading = false;
}
},
async fetchFamiliesCsv() {
const url = 'https://raw.githubusercontent.com/google/fonts/refs/heads/main/tags/all/families.csv';
const res = await fetch(url);
if (!res.ok) throw new Error(`Failed to fetch families.csv (${res.status})`);
this.csvText = await res.text();
},
async fetchFamilyFeaturesJson() {
// Try multiple relative paths to be robust when opened from /.ci
const candidates = [
'family_features.json',
'../family_features.json',
'./family_features.json',
];
let lastErr = null;
for (const p of candidates) {
try {
const res = await fetch(p);
if (res.ok) {
this.featuresJson = await res.json();
return;
} else {
lastErr = new Error(`HTTP ${res.status} for ${p}`);
}
} catch (e) {
lastErr = e;
}
}
// If not found, gracefully degrade with empty features
console.warn('family_features.json not found. Proceeding without features.', lastErr);
this.featuresJson = {};
},
buildDataStructures() {
// 1) Parse CSV into Map<family, Set<tag>> and collect tags
const famTagMap = new Map();
const tagsSet = new Set();
const rows = this.parseCsvRows(this.csvText);
// Heuristic: find family column (first) and the first column that looks like a tag path (contains '/')
let startIndex = 0;
// Skip header if it contains non-family keyword
if (rows.length && rows[0].length) {
const header = rows[0].map(x => (x || '').toLowerCase());
if (header.some(h => h.includes('family') || h.includes('tag') || h.includes('category'))) {
startIndex = 1;
}
}
for (let i = startIndex; i < rows.length; i++) {
const cols = rows[i];
if (!cols || cols.length === 0) continue;
const family = (cols[0] || '').trim();
if (!family) continue;
// Find a tag-like column
let tagPath = '';
for (let c = 1; c < cols.length; c++) {
const val = (cols[c] || '').trim();
if (!val) continue;
if (val.includes('/')) { tagPath = val; break; }
}
if (!tagPath) continue;
// Normalize tag path (remove duplicate spaces)
const tag = tagPath.replace(/\s+/g, ' ').trim();
if (!famTagMap.has(family)) famTagMap.set(family, new Set());
famTagMap.get(family).add(tag);
tagsSet.add(tag);
}
// 2) Parse features JSON as Map<family, Set<feature>>
const famFeatMap = new Map();
const famUrlMap = new Map();
if (this.featuresJson && typeof this.featuresJson === 'object') {
// New structure: { features: [...], families: { "Family": { features: [...], fp: "..." } } }
const familiesData = this.featuresJson.families || {};
const allFeaturesFromJson = this.featuresJson.features || [];
for (const [family, data] of Object.entries(familiesData)) {
if (data && Array.isArray(data.features)) {
famFeatMap.set(family, new Set(data.features.map(f => String(f).trim()).filter(Boolean)));
}
// Store the font URL
if (data && data.fp) {
famUrlMap.set(family, data.fp);
}
}
// Use the top-level features array if available
if (allFeaturesFromJson.length > 0) {
this.allFeatures = allFeaturesFromJson.map(f => String(f).trim()).filter(Boolean).sort();
}
} // 3) Finalize collections
this.familyTags = new Map(Array.from(famTagMap.entries(), ([k, v]) => [k, Array.from(v).sort()]));
this.familyFontUrls = famUrlMap;
// Build familyFeatures map and collect unique features if not from JSON
const featureSet = new Set();
this.familyFeatures = new Map(Array.from(famFeatMap.entries(), ([k, v]) => {
const arr = Array.from(v).sort();
arr.forEach(x => featureSet.add(x));
return [k, arr];
}));
// If we didn't get allFeatures from JSON, build from collected features
if (!this.allFeatures || this.allFeatures.length === 0) {
this.allFeatures = Array.from(featureSet).sort();
}
this.allTags = Array.from(tagsSet).sort((a,b)=>a.localeCompare(b));
const names = new Set([...this.familyTags.keys(), ...this.familyFeatures.keys()]);
this.allFamilies = Array.from(names).sort((a,b)=>a.localeCompare(b));
},
parseCsvRows(text) {
// Simple CSV parser that handles quotes and commas
const rows = [];
let row = [];
let cur = '';
let inQuotes = false;
for (let i = 0; i < text.length; i++) {
const ch = text[i];
if (ch === '"') {
if (inQuotes && text[i+1] === '"') { cur += '"'; i++; }
else { inQuotes = !inQuotes; }
} else if (ch === ',' && !inQuotes) {
row.push(cur); cur = '';
} else if ((ch === '\n' || ch === '\r') && !inQuotes) {
if (cur.length || row.length) { row.push(cur); rows.push(row); }
row = []; cur = '';
// swallow \r\n pairs
if (ch === '\r' && text[i+1] === '\n') i++;
} else {
cur += ch;
}
}
if (cur.length || row.length) { row.push(cur); rows.push(row); }
return rows;
},
toggleTag(tag) {
if (this.selectedTags.has(tag)) this.selectedTags.delete(tag); else this.selectedTags.add(tag);
// force reactivity on Set
this.selectedTags = new Set(this.selectedTags);
this.persistToUrl();
this.batchLoadGoogleFonts(this.filteredFamilies);
},
toggleFeature(feat) {
if (this.selectedFeatures.has(feat)) this.selectedFeatures.delete(feat); else this.selectedFeatures.add(feat);
this.selectedFeatures = new Set(this.selectedFeatures);
this.persistToUrl();
this.batchLoadGoogleFonts(this.filteredFamilies);
},
clearTags() {
this.selectedTags.clear();
this.selectedTags = new Set();
this.persistToUrl();
this.batchLoadGoogleFonts(this.filteredFamilies);
},
selectAllTags() {
// Select all tags currently visible after filtering by query
this.selectedTags = new Set(this.filteredTags);
this.persistToUrl();
this.batchLoadGoogleFonts(this.filteredFamilies);
},
clearFeatures() {
this.selectedFeatures.clear();
this.selectedFeatures = new Set();
this.persistToUrl();
this.batchLoadGoogleFonts(this.filteredFamilies);
},
cssFontFamily(name) { return `"${name}", system-ui, -apple-system, Segoe UI, Roboto, sans-serif`; },
previewStyle(name) {
const style = {
fontFamily: this.cssFontFamily(name),
fontSize: this.previewSize + 'px',
};
if (this.selectedFeatures && this.selectedFeatures.size > 0) {
const feats = Array.from(this.selectedFeatures)
.map(f => `'${f}' 1`)
.join(', ');
style.fontFeatureSettings = feats;
// For broader compatibility
style['-webkit-font-feature-settings'] = feats;
}
return style;
},
fontSourceLabel(name) {
const hasTags = this.familyTags.has(name);
const hasFeats = this.familyFeatures.has(name);
if (hasTags && hasFeats) return 'Tags + Features';
if (hasTags) return 'Tags only';
if (hasFeats) return 'Features only';
return '—';
},
persistToUrl() {
const url = new URL(window.location.href);
url.search = '';
if (this.selectedTags.size) url.searchParams.set('tags', Array.from(this.selectedTags).join('|'));
if (this.selectedFeatures.size) url.searchParams.set('features', Array.from(this.selectedFeatures).join('|'));
if (this.tagMatchMode !== 'any') url.searchParams.set('tagMode', this.tagMatchMode);
if (this.featureMatchMode !== 'any') url.searchParams.set('featMode', this.featureMatchMode);
if (this.previewText) url.searchParams.set('text', this.previewText);
if (this.previewSize !== 24) url.searchParams.set('size', String(this.previewSize));
window.history.replaceState({}, '', url.toString());
},
exportActiveFamilies() {
try {
const lines = this.filteredFamilies.join('\n');
const blob = new Blob([lines], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
const stamp = new Date().toISOString().replace(/[:T]/g, '-').slice(0, 16);
a.href = url;
a.download = `families-${stamp}.txt`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (e) {
console.error('Export failed', e);
}
},
restoreFromUrl() {
const qs = new URLSearchParams(window.location.search);
const tags = qs.get('tags');
const feats = qs.get('features');
const tagMode = qs.get('tagMode');
const featMode = qs.get('featMode');
const txt = qs.get('text');
const size = qs.get('size');
if (tags) this.selectedTags = new Set(tags.split('|').filter(Boolean));
if (feats) this.selectedFeatures = new Set(feats.split('|').filter(Boolean));
if (tagMode === 'all' || tagMode === 'any') this.tagMatchMode = tagMode;
if (featMode === 'all' || featMode === 'any') this.featureMatchMode = featMode;
if (txt) this.previewText = txt;
if (size && !Number.isNaN(Number(size))) this.previewSize = Number(size);
},
batchLoadGoogleFonts(families) {
// load in small batches to avoid hammering requests
const BATCH = 16;
families = Array.from(new Set(families));
const batches = [];
for (let i = 0; i < families.length; i += BATCH) batches.push(families.slice(i, i+BATCH));
batches.forEach((batch, idx) => setTimeout(() => {
batch.forEach(name => this.loadFontFromGitHub(name));
}, idx * 150));
},
loadFontFromGitHub(name) {
// Check if font URL exists in our map
const fontUrl = this.familyFontUrls.get(name);
if (!fontUrl) {
console.warn('No font URL found for', fontUrl);
return;
}
// Create a unique ID for this font-face rule
const fontFaceId = `font-face-${name.replace(/\s+/g, '-')}`;
// Skip if already loaded
if (document.getElementById(fontFaceId)) return;
// Create @font-face rule
const style = document.createElement('style');
style.id = fontFaceId;
style.textContent = `
@font-face {
font-family: "${name}";
src: url("${fontUrl}") format("truetype");
font-weight: 100 900;
font-stretch: 25% 200%;
font-style: normal;
}
`;
document.head.appendChild(style);
}
},
watch: {
// Keep URL in sync when these change via UI typing
previewText() { this.persistToUrl(); },
previewSize() { this.persistToUrl(); },
tagQuery() { /* no-op */ },
featureQuery() { /* no-op */ },
filteredFamilies(newList) { this.batchLoadGoogleFonts(newList); }
},
mounted() {
this.init();
}
}).mount('#app');
</script>
</body>
</html>