|
|
<div class="typography-umap-2d"> |
|
|
<svg class="main-svg" xmlns="http://www.w3.org/2000/svg"> |
|
|
<defs id="font-defs"></defs> |
|
|
<g class="viewport-group"></g> |
|
|
<g class="ui-group"></g> |
|
|
</svg> |
|
|
<div class="ui-layer"></div> |
|
|
</div> |
|
|
<style> |
|
|
.typography-umap-2d { |
|
|
position: relative; |
|
|
min-height: 280px; |
|
|
max-height: 450px; |
|
|
width: 100%; |
|
|
overflow: hidden; |
|
|
cursor: grab; |
|
|
contain: layout style paint; |
|
|
} |
|
|
|
|
|
.main-svg { |
|
|
width: 100%; |
|
|
height: 100%; |
|
|
display: block; |
|
|
cursor: grab; |
|
|
|
|
|
shape-rendering: optimizeLegibility; |
|
|
text-rendering: optimizeLegibility; |
|
|
image-rendering: optimizeLegibility; |
|
|
|
|
|
will-change: transform; |
|
|
transform: translateZ(0); |
|
|
} |
|
|
|
|
|
.typography-umap-2d:active { |
|
|
cursor: grabbing; |
|
|
} |
|
|
|
|
|
|
|
|
.viewport { |
|
|
position: absolute; |
|
|
top: 0; |
|
|
left: 0; |
|
|
width: 100%; |
|
|
height: 100%; |
|
|
will-change: transform; |
|
|
} |
|
|
|
|
|
|
|
|
.font-glyph-group { |
|
|
cursor: pointer; |
|
|
} |
|
|
|
|
|
.font-hit-area { |
|
|
cursor: pointer; |
|
|
} |
|
|
|
|
|
.font-glyph { |
|
|
pointer-events: none; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
.centroid-label { |
|
|
font-weight: 700; |
|
|
font-size: 14px; |
|
|
pointer-events: none; |
|
|
paint-order: stroke fill; |
|
|
stroke: var(--page-bg); |
|
|
stroke-width: 3px; |
|
|
stroke-linejoin: round; |
|
|
stroke-linecap: round; |
|
|
} |
|
|
|
|
|
|
|
|
.ui-layer { |
|
|
position: absolute; |
|
|
top: 0; |
|
|
left: 0; |
|
|
width: 100%; |
|
|
height: 100%; |
|
|
pointer-events: none; |
|
|
z-index: 100; |
|
|
} |
|
|
|
|
|
|
|
|
.svg-tooltip { |
|
|
pointer-events: none; |
|
|
opacity: 0; |
|
|
transition: opacity 0.2s ease; |
|
|
} |
|
|
|
|
|
.tooltip-html { |
|
|
background: var(--surface-bg); |
|
|
border: 1px solid var(--border-color); |
|
|
border-radius: 8px; |
|
|
padding: 12px; |
|
|
font-size: 12px; |
|
|
line-height: 1.4; |
|
|
color: var(--text-color); |
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); |
|
|
white-space: nowrap; |
|
|
font-family: var(--font-sans); |
|
|
min-width: 140px; |
|
|
filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.08)); |
|
|
} |
|
|
|
|
|
.tooltip-title { |
|
|
font-weight: 600; |
|
|
font-size: 13px; |
|
|
margin-bottom: 4px; |
|
|
color: var(--text-color); |
|
|
} |
|
|
|
|
|
.tooltip-category { |
|
|
font-size: 11px; |
|
|
color: var(--muted-color); |
|
|
margin-bottom: 2px; |
|
|
} |
|
|
|
|
|
|
|
|
.zoom-controls { |
|
|
position: absolute; |
|
|
top: 10px; |
|
|
right: 10px; |
|
|
display: flex; |
|
|
flex-direction: column; |
|
|
gap: 4px; |
|
|
pointer-events: all; |
|
|
} |
|
|
|
|
|
.zoom-controls button { |
|
|
background: var(--surface-bg); |
|
|
border: 1px solid var(--border-color); |
|
|
border-radius: 6px; |
|
|
width: 32px; |
|
|
height: 32px; |
|
|
display: flex; |
|
|
align-items: center; |
|
|
justify-content: center; |
|
|
cursor: pointer; |
|
|
font-size: 16px; |
|
|
font-weight: bold; |
|
|
color: var(--text-color); |
|
|
transition: all 0.2s ease; |
|
|
} |
|
|
|
|
|
.zoom-controls button:hover { |
|
|
background: var(--primary-color); |
|
|
color: white; |
|
|
transform: scale(1.1); |
|
|
} |
|
|
</style> |
|
|
|
|
|
<script> |
|
|
(() => { |
|
|
|
|
|
const ensureD3 = (cb) => { |
|
|
if (window.d3) return cb(); |
|
|
const script = document.createElement('script'); |
|
|
script.src = 'https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js'; |
|
|
script.onload = cb; |
|
|
document.head.appendChild(script); |
|
|
}; |
|
|
|
|
|
const bootstrap = () => { |
|
|
|
|
|
const containers = document.querySelectorAll('.typography-umap-2d'); |
|
|
const container = containers[containers.length - 1]; |
|
|
if (!container || container.dataset.mounted) return; |
|
|
container.dataset.mounted = 'true'; |
|
|
|
|
|
|
|
|
const mainSvg = container.querySelector('.main-svg'); |
|
|
let fontDefs = container.querySelector('#font-defs'); |
|
|
let viewportGroup = container.querySelector('.viewport-group'); |
|
|
const uiGroup = container.querySelector('.ui-group'); |
|
|
const uiLayer = container.querySelector('.ui-layer'); |
|
|
|
|
|
|
|
|
if (!viewportGroup) { |
|
|
viewportGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g'); |
|
|
viewportGroup.className = 'viewport-group'; |
|
|
mainSvg.appendChild(viewportGroup); |
|
|
} |
|
|
|
|
|
|
|
|
if (!fontDefs) { |
|
|
fontDefs = document.createElementNS('http://www.w3.org/2000/svg', 'defs'); |
|
|
fontDefs.id = 'font-defs'; |
|
|
mainSvg.insertBefore(fontDefs, mainSvg.firstChild); |
|
|
} |
|
|
|
|
|
|
|
|
let width = 800, height = 500; |
|
|
const margin = 20; |
|
|
let data = []; |
|
|
let visibleGlyphs = new Map(); |
|
|
let centroids = new Map(); |
|
|
let transform = d3.zoomIdentity; |
|
|
let isZooming = false; |
|
|
|
|
|
|
|
|
let tooltipGroup = null; |
|
|
let currentTooltipFont = null; |
|
|
let hoverTimeout = null; |
|
|
|
|
|
|
|
|
const x = d3.scaleLinear(); |
|
|
const y = d3.scaleLinear(); |
|
|
const color = d3.scaleOrdinal(d3.schemeTableau10); |
|
|
|
|
|
|
|
|
const families = { |
|
|
'serif': 'Serif', |
|
|
'sans-serif': 'Sans Serif', |
|
|
'monospace': 'Monospace', |
|
|
'display': 'Display', |
|
|
'handwriting': 'Handwriting', |
|
|
'geometric': 'Geometric' |
|
|
}; |
|
|
|
|
|
|
|
|
let fontMapping = {}; |
|
|
|
|
|
|
|
|
const initSprite = async () => { |
|
|
try { |
|
|
|
|
|
const mappingPaths = ['/data/font-sprite-mapping.json', './assets/data/font-sprite-mapping.json', '../assets/data/font-sprite-mapping.json']; |
|
|
let mappingResponse; |
|
|
for (const path of mappingPaths) { |
|
|
try { |
|
|
mappingResponse = await fetch(path); |
|
|
if (mappingResponse.ok) break; |
|
|
} catch (e) { } |
|
|
} |
|
|
if (!mappingResponse?.ok) throw new Error('Mapping not found'); |
|
|
fontMapping = await mappingResponse.json(); |
|
|
|
|
|
|
|
|
const spritePaths = ['/data/font-sprite.svg', './assets/sprites/font-sprite.svg', '../assets/sprites/font-sprite.svg']; |
|
|
let spriteResponse; |
|
|
for (const path of spritePaths) { |
|
|
try { |
|
|
spriteResponse = await fetch(path); |
|
|
if (spriteResponse.ok) break; |
|
|
} catch (e) { } |
|
|
} |
|
|
if (!spriteResponse?.ok) throw new Error('Sprite not found'); |
|
|
const spriteContent = await spriteResponse.text(); |
|
|
|
|
|
|
|
|
|
|
|
if (!document.getElementById('global-font-sprite')) { |
|
|
const spriteContainer = document.createElement('div'); |
|
|
spriteContainer.id = 'global-font-sprite'; |
|
|
spriteContainer.innerHTML = spriteContent; |
|
|
spriteContainer.style.display = 'none'; |
|
|
spriteContainer.style.position = 'absolute'; |
|
|
spriteContainer.style.width = '0'; |
|
|
spriteContainer.style.height = '0'; |
|
|
document.body.insertBefore(spriteContainer, document.body.firstChild); |
|
|
} |
|
|
|
|
|
} catch (error) { |
|
|
console.error('Sprite loading error:', error); |
|
|
} |
|
|
}; |
|
|
|
|
|
const getFontSymbolId = (fontName) => { |
|
|
const mapped = fontMapping[fontName]; |
|
|
if (mapped) return mapped; |
|
|
|
|
|
|
|
|
return fontName.replace(/\s+/g, '_').replace(/[^a-zA-Z0-9_]/g, '').toLowerCase() + '_a'; |
|
|
}; |
|
|
|
|
|
const createFontUse = (fontName, x, y) => { |
|
|
const symbolId = getFontSymbolId(fontName); |
|
|
|
|
|
|
|
|
const group = document.createElementNS('http://www.w3.org/2000/svg', 'g'); |
|
|
group.setAttribute('class', 'font-glyph-group'); |
|
|
group.setAttribute('data-font', fontName); |
|
|
|
|
|
|
|
|
const hitArea = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); |
|
|
hitArea.setAttribute('x', x - 12); |
|
|
hitArea.setAttribute('y', y - 12); |
|
|
hitArea.setAttribute('width', '24'); |
|
|
hitArea.setAttribute('height', '24'); |
|
|
hitArea.setAttribute('fill', 'transparent'); |
|
|
hitArea.setAttribute('class', 'font-hit-area'); |
|
|
hitArea.setAttribute('data-font', fontName); |
|
|
|
|
|
|
|
|
const use = document.createElementNS('http://www.w3.org/2000/svg', 'use'); |
|
|
use.setAttributeNS('http://www.w3.org/1999/xlink', 'href', `#${symbolId}`); |
|
|
use.setAttribute('x', x - 8); |
|
|
use.setAttribute('y', y - 8); |
|
|
use.setAttribute('width', '16'); |
|
|
use.setAttribute('height', '16'); |
|
|
use.setAttribute('class', 'font-glyph'); |
|
|
use.setAttribute('data-font', fontName); |
|
|
|
|
|
|
|
|
group.addEventListener('click', (e) => { |
|
|
if (!isZooming) { |
|
|
|
|
|
setTimeout(() => { |
|
|
if (!isZooming) { |
|
|
e.preventDefault(); |
|
|
e.stopPropagation(); |
|
|
|
|
|
const fontData = data.find(d => d.label === fontName); |
|
|
if (fontData && fontData.googleFontsUrl) { |
|
|
window.open(fontData.googleFontsUrl, '_blank'); |
|
|
} |
|
|
} |
|
|
}, 10); |
|
|
} |
|
|
}); |
|
|
|
|
|
group.appendChild(hitArea); |
|
|
group.appendChild(use); |
|
|
|
|
|
return group; |
|
|
}; |
|
|
|
|
|
|
|
|
const createTooltipGroup = () => { |
|
|
if (tooltipGroup) return tooltipGroup; |
|
|
|
|
|
tooltipGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g'); |
|
|
tooltipGroup.setAttribute('class', 'svg-tooltip'); |
|
|
|
|
|
|
|
|
const foreignObject = document.createElementNS('http://www.w3.org/2000/svg', 'foreignObject'); |
|
|
foreignObject.setAttribute('width', '160'); |
|
|
foreignObject.setAttribute('height', '60'); |
|
|
|
|
|
|
|
|
const htmlDiv = document.createElement('div'); |
|
|
htmlDiv.className = 'tooltip-html'; |
|
|
htmlDiv.innerHTML = ` |
|
|
<div class="tooltip-title"></div> |
|
|
<div class="tooltip-category"> |
|
|
<span class="tooltip-category-text"></span> |
|
|
</div> |
|
|
`; |
|
|
|
|
|
foreignObject.appendChild(htmlDiv); |
|
|
tooltipGroup.appendChild(foreignObject); |
|
|
|
|
|
uiGroup.appendChild(tooltipGroup); |
|
|
return tooltipGroup; |
|
|
}; |
|
|
|
|
|
const showTooltip = (fontData, mouseX, mouseY) => { |
|
|
if (isZooming) return; |
|
|
|
|
|
|
|
|
if (hoverTimeout) { |
|
|
clearTimeout(hoverTimeout); |
|
|
hoverTimeout = null; |
|
|
} |
|
|
|
|
|
|
|
|
if (currentTooltipFont === fontData.label) { |
|
|
updateTooltipPosition(mouseX, mouseY); |
|
|
return; |
|
|
} |
|
|
|
|
|
currentTooltipFont = fontData.label; |
|
|
const tooltip = createTooltipGroup(); |
|
|
const clr = color(fontData.group); |
|
|
|
|
|
|
|
|
const titleEl = tooltip.querySelector('.tooltip-title'); |
|
|
const categoryTextEl = tooltip.querySelector('.tooltip-category-text'); |
|
|
|
|
|
titleEl.textContent = fontData.label; |
|
|
categoryTextEl.textContent = families[fontData.group] || fontData.group; |
|
|
|
|
|
|
|
|
const { x: svgX, y: svgY } = screenToSvg(mouseX, mouseY); |
|
|
|
|
|
|
|
|
const tooltipWidth = 160; |
|
|
const tooltipHeight = 60; |
|
|
|
|
|
let tooltipX = svgX + 15; |
|
|
let tooltipY = svgY - tooltipHeight - 10; |
|
|
|
|
|
|
|
|
if (tooltipX + tooltipWidth > width) tooltipX = svgX - tooltipWidth - 15; |
|
|
if (tooltipY < 0) tooltipY = svgY + 15; |
|
|
|
|
|
tooltip.setAttribute('transform', `translate(${tooltipX}, ${tooltipY})`); |
|
|
tooltip.style.opacity = '1'; |
|
|
}; |
|
|
|
|
|
const hideTooltip = () => { |
|
|
if (!tooltipGroup || hoverTimeout) return; |
|
|
|
|
|
hoverTimeout = setTimeout(() => { |
|
|
if (tooltipGroup) { |
|
|
tooltipGroup.style.opacity = '0'; |
|
|
} |
|
|
currentTooltipFont = null; |
|
|
hoverTimeout = null; |
|
|
}, 100); |
|
|
}; |
|
|
|
|
|
const updateTooltipPosition = (mouseX, mouseY) => { |
|
|
if (!tooltipGroup || !currentTooltipFont) return; |
|
|
|
|
|
const { x: svgX, y: svgY } = screenToSvg(mouseX, mouseY); |
|
|
|
|
|
const tooltipWidth = 160; |
|
|
const tooltipHeight = 60; |
|
|
|
|
|
let tooltipX = svgX + 15; |
|
|
let tooltipY = svgY - tooltipHeight - 10; |
|
|
|
|
|
if (tooltipX + tooltipWidth > width) tooltipX = svgX - tooltipWidth - 15; |
|
|
if (tooltipY < 0) tooltipY = svgY + 15; |
|
|
|
|
|
tooltipGroup.setAttribute('transform', `translate(${tooltipX}, ${tooltipY})`); |
|
|
}; |
|
|
|
|
|
const createFallbackSvg = () => { |
|
|
return ` |
|
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 80" width="16" height="16"> |
|
|
<rect width="80" height="80" fill="var(--surface-bg)" stroke="var(--border-color)" stroke-width="1"/> |
|
|
<text x="40" y="50" text-anchor="middle" dominant-baseline="middle" font-family="monospace" font-size="32" fill="currentColor">A</text> |
|
|
</svg> |
|
|
`; |
|
|
}; |
|
|
|
|
|
|
|
|
const updateTransform = () => { |
|
|
const { k, x: tx, y: ty } = transform; |
|
|
viewportGroup.setAttribute('transform', `translate(${tx}, ${ty}) scale(${k})`); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
}; |
|
|
|
|
|
|
|
|
const render = () => { |
|
|
if (!data.length) return; |
|
|
|
|
|
|
|
|
data.forEach((d, i) => { |
|
|
if (visibleGlyphs.has(i)) return; |
|
|
|
|
|
const useElement = createFontUse(d.label, x(d.x), y(d.y)); |
|
|
viewportGroup.appendChild(useElement); |
|
|
visibleGlyphs.set(i, useElement); |
|
|
}); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
}; |
|
|
|
|
|
|
|
|
const handleZoom = (event) => { |
|
|
transform = event.transform; |
|
|
updateTransform(); |
|
|
}; |
|
|
|
|
|
|
|
|
const getConstraints = () => { |
|
|
|
|
|
if (!data.length) { |
|
|
return { |
|
|
scaleExtent: [0.25, 10], |
|
|
translateExtent: [[-width * 0.5, -height * 0.5], [width * 1.5, height * 1.5]] |
|
|
}; |
|
|
} |
|
|
|
|
|
|
|
|
const xExtent = d3.extent(data, d => d.x); |
|
|
const yExtent = d3.extent(data, d => d.y); |
|
|
|
|
|
|
|
|
const screenLeft = x(xExtent[0]); |
|
|
const screenRight = x(xExtent[1]); |
|
|
const screenTop = y(yExtent[1]); |
|
|
const screenBottom = y(yExtent[0]); |
|
|
|
|
|
const contentWidth = screenRight - screenLeft; |
|
|
const contentHeight = screenBottom - screenTop; |
|
|
|
|
|
|
|
|
const paddingFactor = 1.1; |
|
|
const minScaleX = width / (contentWidth * paddingFactor); |
|
|
const minScaleY = height / (contentHeight * paddingFactor); |
|
|
const minScale = Math.min(minScaleX, minScaleY) * 0.9; |
|
|
|
|
|
|
|
|
const maxScale = 12; |
|
|
|
|
|
|
|
|
|
|
|
const buffer = Math.min(width, height) * 0.3; |
|
|
|
|
|
const translateExtent = [ |
|
|
|
|
|
[screenLeft - width + buffer, screenTop - height + buffer], |
|
|
|
|
|
[screenRight - buffer, screenBottom - buffer] |
|
|
]; |
|
|
|
|
|
const constraints = { |
|
|
scaleExtent: [Math.max(0.1, minScale), maxScale], |
|
|
translateExtent |
|
|
}; |
|
|
|
|
|
return constraints; |
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const zoom = d3.zoom() |
|
|
.scaleExtent([1, 4]) |
|
|
|
|
|
.on('start', () => { |
|
|
isZooming = true; |
|
|
if (tooltipGroup) { |
|
|
tooltipGroup.style.opacity = '0'; |
|
|
} |
|
|
currentTooltipFont = null; |
|
|
}) |
|
|
.on('zoom', (event) => { |
|
|
const { transform } = event; |
|
|
|
|
|
|
|
|
d3.select(viewportGroup).attr('transform', transform.toString()); |
|
|
}) |
|
|
.on('end', () => { |
|
|
setTimeout(() => { isZooming = false; }, 100); |
|
|
}); |
|
|
d3.select(mainSvg).call(zoom); |
|
|
|
|
|
|
|
|
const screenToSvg = (clientX, clientY) => { |
|
|
const rect = mainSvg.getBoundingClientRect(); |
|
|
const svgX = ((clientX - rect.left) / rect.width) * width; |
|
|
const svgY = ((clientY - rect.top) / rect.height) * height; |
|
|
return { x: svgX, y: svgY }; |
|
|
}; |
|
|
|
|
|
|
|
|
mainSvg.addEventListener('mousemove', (e) => { |
|
|
if (isZooming) return; |
|
|
|
|
|
|
|
|
const element = document.elementFromPoint(e.clientX, e.clientY); |
|
|
|
|
|
|
|
|
let fontName = null; |
|
|
if (element && (element.classList.contains('font-glyph') || element.classList.contains('font-hit-area'))) { |
|
|
fontName = element.getAttribute('data-font'); |
|
|
} else if (element && element.closest('.font-glyph-group')) { |
|
|
fontName = element.closest('.font-glyph-group').getAttribute('data-font'); |
|
|
} |
|
|
|
|
|
if (fontName) { |
|
|
const fontData = data.find(d => d.label === fontName); |
|
|
if (fontData) { |
|
|
showTooltip(fontData, e.clientX, e.clientY); |
|
|
} |
|
|
} else { |
|
|
hideTooltip(); |
|
|
} |
|
|
}); |
|
|
|
|
|
mainSvg.addEventListener('mouseleave', () => { |
|
|
hideTooltip(); |
|
|
}); |
|
|
|
|
|
|
|
|
|
|
|
const controls = document.createElement('div'); |
|
|
controls.className = 'zoom-controls'; |
|
|
controls.innerHTML = ` |
|
|
<button title="Zoom In">+</button> |
|
|
<button title="Zoom Out">−</button> |
|
|
<button title="Reset">⌂</button> |
|
|
`; |
|
|
|
|
|
const [zoomIn, zoomOut, reset] = controls.querySelectorAll('button'); |
|
|
zoomIn.onclick = () => d3.select(mainSvg).transition().call(zoom.scaleBy, 1.5); |
|
|
zoomOut.onclick = () => d3.select(mainSvg).transition().call(zoom.scaleBy, 1 / 1.5); |
|
|
|
|
|
|
|
|
reset.onclick = () => { |
|
|
if (!data.length) { |
|
|
d3.select(mainSvg).transition().call(zoom.transform, d3.zoomIdentity); |
|
|
return; |
|
|
} |
|
|
|
|
|
const constraints = getConstraints(); |
|
|
const optimalScale = constraints.scaleExtent[0] * 1.05; |
|
|
|
|
|
|
|
|
const xExtent = d3.extent(data, d => d.x); |
|
|
const yExtent = d3.extent(data, d => d.y); |
|
|
const centerX = (x(xExtent[0]) + x(xExtent[1])) / 2; |
|
|
const centerY = (y(yExtent[0]) + y(yExtent[1])) / 2; |
|
|
|
|
|
const targetX = width / 2 - centerX * optimalScale; |
|
|
const targetY = height / 2 - centerY * optimalScale; |
|
|
|
|
|
const resetTransform = d3.zoomIdentity.translate(targetX, targetY).scale(optimalScale); |
|
|
|
|
|
d3.select(mainSvg).transition().duration(750).call(zoom.transform, resetTransform); |
|
|
}; |
|
|
|
|
|
uiLayer.appendChild(controls); |
|
|
|
|
|
|
|
|
const updateScales = () => { |
|
|
width = container.clientWidth || 800; |
|
|
height = Math.max(280, Math.min(450, Math.round(width * 0.4))); |
|
|
|
|
|
|
|
|
mainSvg.setAttribute('viewBox', `0 0 ${width} ${height}`); |
|
|
|
|
|
const xExtent = d3.extent(data, d => d.x); |
|
|
const yExtent = d3.extent(data, d => d.y); |
|
|
|
|
|
x.domain(xExtent).range([margin, width - margin]); |
|
|
y.domain(yExtent).range([height - margin, margin]); |
|
|
}; |
|
|
|
|
|
|
|
|
const updateColors = () => { |
|
|
try { |
|
|
if (window.ColorPalettes?.getColors) { |
|
|
const colors = window.ColorPalettes.getColors('categorical', 6); |
|
|
if (colors?.length) color.range(colors); |
|
|
} |
|
|
} catch (e) { } |
|
|
}; |
|
|
|
|
|
|
|
|
const loadData = async () => { |
|
|
try { |
|
|
|
|
|
await initSprite(); |
|
|
|
|
|
const paths = ['/data/typography_data.json', './assets/data/typography_data.json', '../assets/data/typography_data.json']; |
|
|
let response; |
|
|
for (const path of paths) { |
|
|
try { |
|
|
response = await fetch(path); |
|
|
if (response.ok) break; |
|
|
} catch (e) { } |
|
|
} |
|
|
|
|
|
if (!response?.ok) throw new Error('Data not found'); |
|
|
|
|
|
const raw = await response.json(); |
|
|
const fontsData = raw.fonts || raw; |
|
|
data = fontsData.map((d, i) => ({ |
|
|
id: i, |
|
|
originalId: d.id, |
|
|
googleFontsUrl: d.google_fonts_url, |
|
|
x: d.x, |
|
|
y: d.y, |
|
|
group: d.family || 'sans-serif', |
|
|
label: d.name |
|
|
})).filter(d => Number.isFinite(d.x) && Number.isFinite(d.y)); |
|
|
|
|
|
color.domain([...new Set(data.map(d => d.group))]); |
|
|
updateColors(); |
|
|
updateScales(); |
|
|
|
|
|
|
|
|
const xExtent = d3.extent(data, d => d.x); |
|
|
const yExtent = d3.extent(data, d => d.y); |
|
|
|
|
|
|
|
|
const contentLeft = x(xExtent[0]); |
|
|
const contentRight = x(xExtent[1]); |
|
|
const contentTop = y(yExtent[1]); |
|
|
const contentBottom = y(yExtent[0]); |
|
|
|
|
|
const contentWidth = contentRight - contentLeft; |
|
|
const contentHeight = contentBottom - contentTop; |
|
|
|
|
|
|
|
|
const padding = 1; |
|
|
const panLeft = contentLeft - padding; |
|
|
const panRight = contentRight + padding; |
|
|
const panTop = contentTop - padding; |
|
|
const panBottom = contentBottom + padding; |
|
|
|
|
|
|
|
|
zoom.translateExtent([[panLeft, panTop], [panRight, panBottom]]); |
|
|
|
|
|
render(); |
|
|
} catch (e) { |
|
|
console.error('Failed to load data:', e); |
|
|
container.innerHTML = '<div style="color:red;padding:20px;">Failed to load typography data</div>'; |
|
|
} |
|
|
}; |
|
|
|
|
|
|
|
|
updateColors(); |
|
|
document.addEventListener('palettes:updated', updateColors); |
|
|
|
|
|
|
|
|
let resizeTimer; |
|
|
const handleResize = () => { |
|
|
clearTimeout(resizeTimer); |
|
|
resizeTimer = setTimeout(() => { |
|
|
updateScales(); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
render(); |
|
|
}, 100); |
|
|
}; |
|
|
|
|
|
new ResizeObserver(handleResize).observe(container); |
|
|
|
|
|
loadData(); |
|
|
}; |
|
|
|
|
|
|
|
|
if (document.readyState === 'loading') { |
|
|
document.addEventListener('DOMContentLoaded', () => ensureD3(bootstrap)); |
|
|
} else { |
|
|
ensureD3(bootstrap); |
|
|
} |
|
|
})(); |
|
|
</script> |