| import { useEffect, useRef } from 'react'; |
| import * as d3 from 'd3'; |
| import { useFontMapStore } from '../../../store/fontMapStore'; |
|
|
| const GLYPH_SCALE = 0.25; |
|
|
| const CATEGORY_COLORS = { |
| 'sans-serif': '#3498db', |
| 'serif': '#e74c3c', |
| 'display': '#f39c12', |
| 'handwriting':'#9b59b6', |
| 'monospace': '#2ecc71', |
| }; |
|
|
| function getGlyphColor(category, useCategoryColors, darkMode) { |
| if (useCategoryColors) { |
| return CATEGORY_COLORS[category] || '#95a5a6'; |
| } |
| return darkMode ? '#ffffff' : '#333333'; |
| } |
|
|
| function calculateMappingDimensions(fonts, width, height, padding = 40) { |
| const xValues = fonts.map(d => d.x); |
| const yValues = fonts.map(d => d.y); |
|
|
| const xMin = Math.min(...xValues); |
| const xMax = Math.max(...xValues); |
| const yMin = Math.min(...yValues); |
| const yMax = Math.max(...yValues); |
|
|
| const mapX = (x) => ((x - xMin) / (xMax - xMin)) * (width - 2 * padding) + padding; |
| const mapY = (y) => ((yMax - y) / (yMax - yMin)) * (height - 2 * padding) + padding; |
|
|
| return { mapX, mapY }; |
| } |
|
|
| function getOrCreateViewportGroup(svg) { |
| let vg = svg.select('.viewport-group'); |
| if (vg.empty()) { |
| vg = svg.append('g').attr('class', 'viewport-group'); |
| } |
| return vg; |
| } |
|
|
| |
| |
| |
| export function useMapRenderer({ svgRef, fonts, glyphPaths, filter, searchTerm, darkMode, loading, enabled = true, isMobile = false }) { |
| const mappingRef = useRef({ mapX: null, mapY: null }); |
| const dimensionsRef = useRef({ width: 0, height: 0 }); |
| const hasRenderedRef = useRef(false); |
| const selectedFontRef = useRef(null); |
|
|
| const { |
| selectedFont, |
| setSelectedFont, |
| setHoveredFont, |
| useCategoryColors, |
| } = useFontMapStore(); |
|
|
| selectedFontRef.current = selectedFont; |
|
|
| |
| useEffect(() => { |
| if (!enabled || !fonts || fonts.length === 0 || !svgRef.current) return; |
|
|
| const svg = d3.select(svgRef.current); |
| const parentEl = svgRef.current.parentElement; |
| if (!parentEl) return; |
|
|
| const width = parentEl.clientWidth || window.innerWidth; |
| const height = parentEl.clientHeight || window.innerHeight; |
| dimensionsRef.current = { width, height }; |
|
|
| svg |
| .attr('width', '100%') |
| .attr('height', '100%') |
| .attr('viewBox', `0 0 ${width} ${height}`); |
|
|
| const viewportGroup = getOrCreateViewportGroup(svg); |
| viewportGroup.selectAll('g.glyph-group').remove(); |
|
|
| const { mapX, mapY } = calculateMappingDimensions(fonts, width, height); |
| mappingRef.current = { mapX, mapY }; |
|
|
| const hasSprite = glyphPaths && Object.keys(glyphPaths).length > 0; |
| const vgNode = viewportGroup.node(); |
| const ns = 'http://www.w3.org/2000/svg'; |
|
|
| fonts.forEach(font => { |
| |
| |
| const imgKey = (font.imageName || font.name || '').toLowerCase(); |
| const pathD = hasSprite |
| ? (glyphPaths[`${font.id}_a`] |
| || glyphPaths[font.id] |
| || glyphPaths[`${imgKey}_a`] |
| || glyphPaths[imgKey]) |
| : null; |
| if (!pathD) return; |
|
|
| const x = mapX(font.x); |
| const y = mapY(font.y); |
| const color = getGlyphColor(font.family, useCategoryColors, darkMode); |
|
|
| const g = document.createElementNS(ns, 'g'); |
| g.setAttribute('transform', `translate(${x}, ${y}) scale(${GLYPH_SCALE})`); |
| g.setAttribute('data-original-transform', `translate(${x}, ${y})`); |
| g.setAttribute('data-font', font.name); |
| g.setAttribute('data-font-id', font.id); |
| g.setAttribute('data-font-name', font.name); |
| g.setAttribute('data-category', font.family); |
| g.setAttribute('class', 'glyph-group'); |
| g.style.cursor = 'pointer'; |
|
|
| const hitbox = document.createElementNS(ns, 'circle'); |
| hitbox.setAttribute('class', 'glyph-hitbox'); |
| hitbox.setAttribute('cx', '40'); |
| hitbox.setAttribute('cy', '40'); |
| hitbox.setAttribute('r', '44'); |
| hitbox.setAttribute('fill', 'transparent'); |
| hitbox.setAttribute('pointer-events', 'all'); |
| g.appendChild(hitbox); |
|
|
| const path = document.createElementNS(ns, 'path'); |
| path.setAttribute('d', pathD); |
| path.setAttribute('fill', color); |
| path.setAttribute('pointer-events', 'none'); |
| g.appendChild(path); |
|
|
| vgNode.appendChild(g); |
| }); |
|
|
| hasRenderedRef.current = true; |
| }, [enabled, fonts, glyphPaths, darkMode, useCategoryColors, svgRef]); |
|
|
| |
| |
| |
| useEffect(() => { |
| if (!svgRef.current) return; |
|
|
| svgRef.current.querySelectorAll('g.glyph-group').forEach(group => { |
| const category = group.getAttribute('data-category'); |
| const color = getGlyphColor(category, useCategoryColors, darkMode); |
| group.querySelectorAll('path').forEach(el => { |
| el.setAttribute('fill', color); |
| }); |
| }); |
| }, [darkMode, useCategoryColors, svgRef]); |
|
|
| |
| useEffect(() => { |
| if (!svgRef.current || !fonts || fonts.length === 0) return; |
| const svg = d3.select(svgRef.current); |
| const viewportGroup = svg.select('.viewport-group'); |
| if (viewportGroup.empty()) return; |
|
|
| |
| svg.selectAll('.centroids-group').remove(); |
|
|
| const { mapX, mapY } = mappingRef.current; |
| if (!mapX || !mapY) return; |
|
|
| const centroids = {}; |
| fonts.forEach(font => { |
| const cat = font.family; |
| if (!centroids[cat]) centroids[cat] = { x: 0, y: 0, n: 0 }; |
| centroids[cat].x += font.x; |
| centroids[cat].y += font.y; |
| centroids[cat].n += 1; |
| }); |
|
|
| |
| const currentTransform = viewportGroup.attr('transform') || ''; |
| const centroidsGroup = svg.append('g') |
| .attr('class', 'centroids-group') |
| .attr('transform', currentTransform) |
| .style('pointer-events', 'none') |
| .style('user-select', 'none'); |
|
|
| const fillColor = useCategoryColors |
| ? null |
| : (darkMode ? '#ffffff' : '#000000'); |
| const haloColor = darkMode ? '#000000' : '#ffffff'; |
|
|
| Object.entries(centroids).forEach(([cat, c]) => { |
| const x = mapX(c.x / c.n); |
| const y = mapY(c.y / c.n); |
| const color = fillColor || (CATEGORY_COLORS[cat] || '#95a5a6'); |
|
|
| centroidsGroup.append('text') |
| .attr('x', x) |
| .attr('y', y) |
| .attr('text-anchor', 'middle') |
| .attr('font-size', '16px') |
| .attr('font-weight', 'bold') |
| .attr('fill', color) |
| .attr('stroke', haloColor) |
| .attr('stroke-width', '8px') |
| .attr('paint-order', 'stroke fill') |
| .attr('class', 'centroid-label') |
| .text(cat); |
| }); |
| }, [fonts, useCategoryColors, darkMode, svgRef]); |
|
|
| |
| useEffect(() => { |
| if (!svgRef.current) return; |
| const svg = d3.select(svgRef.current); |
| const viewportGroup = svg.select('.viewport-group'); |
| if (viewportGroup.empty()) return; |
|
|
| |
| svg.selectAll('.highlight-group').remove(); |
|
|
| if (selectedFont) { |
| |
| viewportGroup.attr('opacity', 0.1); |
|
|
| |
| const selectedGlyph = viewportGroup.select( |
| `g.glyph-group[data-font-id="${selectedFont.id}"]` |
| ); |
|
|
| if (!selectedGlyph.empty()) { |
| const currentTransform = viewportGroup.attr('transform') || ''; |
| const highlightGroup = svg.append('g') |
| .attr('class', 'highlight-group') |
| .attr('transform', currentTransform) |
| .style('pointer-events', 'none'); |
|
|
| const clone = selectedGlyph.node().cloneNode(true); |
| clone.setAttribute('opacity', '1'); |
| highlightGroup.node().appendChild(clone); |
| } |
|
|
| |
| viewportGroup.selectAll('g.glyph-group') |
| .style('pointer-events', 'all'); |
| } else { |
| |
| viewportGroup.attr('opacity', 1); |
|
|
| const hasFilter = filter !== 'all' || searchTerm; |
|
|
| if (hasFilter) { |
| const searchLower = searchTerm ? searchTerm.toLowerCase() : ''; |
| viewportGroup.selectAll('g.glyph-group').each(function () { |
| const group = this; |
| const fontFamily = group.getAttribute('data-category'); |
| const fontName = group.getAttribute('data-font-name'); |
|
|
| const familyMatch = filter === 'all' || fontFamily === filter; |
| const searchMatch = !searchLower || |
| (fontName && fontName.toLowerCase().includes(searchLower)) || |
| (fontFamily && fontFamily.toLowerCase().includes(searchLower)); |
|
|
| const match = familyMatch && searchMatch; |
| group.setAttribute('opacity', match ? '1' : '0.06'); |
| group.style.pointerEvents = match ? 'all' : 'none'; |
| }); |
| } else { |
| |
| viewportGroup.selectAll('g.glyph-group').each(function () { |
| this.removeAttribute('opacity'); |
| this.style.pointerEvents = 'all'; |
| }); |
| } |
| } |
| }, [filter, searchTerm, selectedFont, svgRef]); |
|
|
| |
| useEffect(() => { |
| if (!svgRef.current || !fonts || fonts.length === 0) return; |
|
|
| const svg = svgRef.current; |
|
|
| const findGlyphGroup = (target) => { |
| let el = target; |
| while (el && el !== svg) { |
| if (el.classList && el.classList.contains('glyph-group')) return el; |
| el = el.parentElement; |
| } |
| return null; |
| }; |
|
|
| const getFontFromGroup = (group) => { |
| const fontId = group.getAttribute('data-font-id'); |
| return fonts.find(f => f.id === fontId) || null; |
| }; |
|
|
| const handleMouseOver = (e) => { |
| if (useFontMapStore.getState().isTransitioning) return; |
| const group = findGlyphGroup(e.target); |
| if (!group) return; |
| const font = getFontFromGroup(group); |
| if (font) setHoveredFont(font); |
| }; |
|
|
| const handleMouseOut = (e) => { |
| |
| |
| if (isMobile) return; |
| if (useFontMapStore.getState().isTransitioning) return; |
| const group = findGlyphGroup(e.target); |
| if (!group) return; |
| setHoveredFont(null); |
| }; |
|
|
| const handleClick = (e) => { |
| const group = findGlyphGroup(e.target); |
| if (!group) { |
| |
| if (isMobile) { |
| setHoveredFont(null); |
| } else if (selectedFontRef.current) { |
| setSelectedFont(null); |
| } |
| return; |
| } |
| const font = getFontFromGroup(group); |
| if (!font) return; |
| if (isMobile) { |
| |
| |
| setHoveredFont(font); |
| return; |
| } |
| setHoveredFont(null); |
| const cur = selectedFontRef.current; |
| setSelectedFont(cur && cur.id === font.id ? null : font); |
| }; |
|
|
| svg.addEventListener('mouseover', handleMouseOver); |
| svg.addEventListener('mouseout', handleMouseOut); |
| svg.addEventListener('click', handleClick); |
|
|
| return () => { |
| svg.removeEventListener('mouseover', handleMouseOver); |
| svg.removeEventListener('mouseout', handleMouseOut); |
| svg.removeEventListener('click', handleClick); |
| }; |
| }, [fonts, setSelectedFont, setHoveredFont, svgRef, isMobile]); |
|
|
| return { mappingRef, dimensionsRef }; |
| } |
|
|