| <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> |