import React, { useState, useRef, useEffect } from 'react'; import { motion } from 'framer-motion'; import { Header } from './components/Header.jsx'; import { Button } from './components/Button.jsx'; import { PageTransition } from './components/PageTransition.jsx'; import { useNavigate } from 'react-router-dom'; import dutchPlate from './assets/kentekentv.png'; import belgianPlate from './assets/belgischekentekenv2.png'; import germanPlate from './assets/duitskenteken.png'; import dutchFlag from './assets/dutchflag.png'; import belgiumFlag from './assets/belgiumflag.png'; import germanFlag from './assets/duitsevlag.svg'; import logolangLogo from './assets/logolang.png'; import kentekenFontUrl from './assets/Kenteken.ttf'; import { loadCars } from './carStorage.js'; // Browsers assume 96 DPI when printing images on A4 paper. Converting // centimeters with this resolution ensures the generated plates have the // intended physical dimensions when downloaded or printed. export const DEFAULT_DPI = 96; export function mmToPx(mm, dpi = DEFAULT_DPI) { return (mm / 25.4) * dpi; } export function cmToPx(cm, dpi = DEFAULT_DPI) { return mmToPx(cm * 10, dpi); } export default function LicensePlateApp() { const [options, setOptions] = useState([]); const [plates, setPlates] = useState([]); const [previews, setPreviews] = useState([]); const frontRefs = useRef([]); const rearRefs = useRef([]); const navigate = useNavigate(); const countryOptions = [ { code: 'NL', label: 'Nederland', flag: dutchFlag, alt: 'Nederlandse vlag' }, { code: 'BE', label: 'Belgiƫ', flag: belgiumFlag, alt: 'Belgische vlag' }, { code: 'DE', label: 'Duitsland', flag: germanFlag, alt: 'Duitse vlag' }, ]; useEffect(() => { loadCars().then((opts) => { const sorted = [...opts].sort((a, b) => a.name.localeCompare(b.name)); setOptions(sorted); setPlates([ { carName: sorted[0]?.name || '', front: 'AB-123-CD', rear: 'AB-123-CD', country: 'NL', }, ]); setPreviews([{ front: '', rear: '' }]); }); }, []); const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); const measureTextWidth = (text, size, fontWeightValue) => { ctx.font = `${fontWeightValue} ${size}px KentekenFont, monospace`; return ctx.measureText(text).width; }; const getFontSize = (text, baseFontSize, maxTextWidth, fontWeightValue) => { let size = baseFontSize; const processed = text.replace(/ /g, '\u00A0'); while ( measureTextWidth(processed, size, fontWeightValue) > maxTextWidth && size > 1 ) { size -= 1; } return size; }; const svgToPng = async (svg) => { if (!svg) return ''; await document.fonts.ready; const clone = svg.cloneNode(true); const images = clone.querySelectorAll('image'); await Promise.all( Array.from(images).map(async (img) => { const href = img.getAttribute('href'); const response = await fetch(href); const blob = await response.blob(); const dataUrl = await new Promise((resolve) => { const reader = new FileReader(); reader.onloadend = () => resolve(reader.result); reader.readAsDataURL(blob); }); img.setAttribute('href', dataUrl); }) ); const fontResponse = await fetch(kentekenFontUrl); const fontBlob = await fontResponse.blob(); const fontDataUrl = await new Promise((resolve) => { const reader = new FileReader(); reader.onloadend = () => resolve(reader.result); reader.readAsDataURL(fontBlob); }); const style = document.createElement('style'); style.textContent = `@font-face { font-family: 'KentekenFont'; src: url(${fontDataUrl}) format('truetype'); }`; clone.insertBefore(style, clone.firstChild); const xml = new XMLSerializer().serializeToString(clone); const svg64 = btoa(unescape(encodeURIComponent(xml))); const image64 = `data:image/svg+xml;base64,${svg64}`; return await new Promise((resolve) => { const imgEl = new Image(); imgEl.onload = () => { const canvas = document.createElement('canvas'); canvas.width = imgEl.width; canvas.height = imgEl.height; const ctx = canvas.getContext('2d'); ctx.drawImage(imgEl, 0, 0); resolve(canvas.toDataURL('image/png')); }; imgEl.src = image64; }); }; const downloadCombinedPlates = async () => { const frontUrls = []; const rearUrls = []; const dims = []; for (let i = 0; i < plates.length; i++) { const car = options.find((o) => o.name === plates[i].carName) || options[0]; const hasRear = car.rearWidth && car.rearHeight; const frontWidth = cmToPx(car.width); const frontHeight = cmToPx(car.height); const rearWidth = hasRear ? cmToPx(car.rearWidth) : frontWidth; const rearHeight = hasRear ? cmToPx(car.rearHeight) : frontHeight; frontUrls.push(await svgToPng(frontRefs.current[i])); rearUrls.push(await svgToPng(rearRefs.current[i])); dims.push({ frontWidth, frontHeight, rearWidth, rearHeight }); } const a4Width = cmToPx(21); const a4Height = cmToPx(29.7); const margin = cmToPx(1.5); const gap = 15; const maxWidth = Math.max( ...dims.map((d) => Math.max(d.frontWidth, d.rearWidth)) ); const totalHeight = dims.reduce( (sum, d) => sum + d.frontHeight + d.rearHeight, 0 ); const nImages = dims.length * 2; const scale = Math.min( 1, (a4Width - margin) / maxWidth, (a4Height - gap * (nImages - 1)) / totalHeight ); const canvas = document.createElement('canvas'); canvas.width = a4Width; canvas.height = a4Height; const ctx = canvas.getContext('2d'); const loadImage = (url) => new Promise((resolve) => { const img = new Image(); img.onload = () => resolve(img); img.src = url; }); let y = 0; for (let i = 0; i < plates.length; i++) { const fImg = await loadImage(frontUrls[i]); ctx.drawImage( fImg, margin, y, fImg.width * scale, fImg.height * scale ); y += dims[i].frontHeight * scale + gap; const rImg = await loadImage(rearUrls[i]); ctx.drawImage( rImg, margin, y, rImg.width * scale, rImg.height * scale ); y += dims[i].rearHeight * scale; if (i < plates.length - 1) y += gap; } const dataUrl = canvas.toDataURL('image/png'); const a = document.createElement('a'); a.download = 'kentekens.png'; a.href = dataUrl; a.click(); }; const openPrintPreview = async () => { const items = []; for (let i = 0; i < plates.length; i++) { const car = options.find((o) => o.name === plates[i].carName) || options[0]; const hasRear = car.rearWidth && car.rearHeight; const frontWidth = car.width * 10; const frontHeight = car.height * 10; const rearWidth = hasRear ? car.rearWidth * 10 : frontWidth; const rearHeight = hasRear ? car.rearHeight * 10 : frontHeight; const frontUrl = await svgToPng(frontRefs.current[i]); const rearUrl = await svgToPng(rearRefs.current[i]); items.push({ src: frontUrl, width: frontWidth, height: frontHeight }); items.push({ src: rearUrl, width: rearWidth, height: rearHeight }); } navigate('/print', { state: { items } }); }; useEffect(() => { const renderPreviews = async () => { const newPreviews = []; for (let i = 0; i < plates.length; i++) { const frontUrl = frontRefs.current[i] ? await svgToPng(frontRefs.current[i]) : ''; const rearUrl = rearRefs.current[i] ? await svgToPng(rearRefs.current[i]) : ''; newPreviews.push({ front: frontUrl, rear: rearUrl }); } setPreviews(newPreviews); }; renderPreviews(); }, [plates, options]); const addPlate = () => { setPlates((pls) => [ ...pls, { carName: options[0]?.name || '', front: 'AB-123-CD', rear: 'AB-123-CD', country: 'NL', }, ]); setPreviews((prv) => [...prv, { front: '', rear: '' }]); }; const updatePlate = (index, field, value) => { setPlates((pls) => { const updated = [...pls]; updated[index] = { ...updated[index], [field]: value }; return updated; }); }; const removePlate = (index) => { setPlates((pls) => pls.filter((_, i) => i !== index)); setPreviews((prv) => prv.filter((_, i) => i !== index)); frontRefs.current.splice(index, 1); rearRefs.current.splice(index, 1); }; return (
window.location.reload()} onHome={() => navigate('/')} />
{plates.map((plate, i) => { const car = options.find((o) => o.name === plate.carName) || options[0]; const hasRear = car.rearWidth && car.rearHeight; const frontWidth = cmToPx(car.width); const frontHeight = cmToPx(car.height); const rearWidthPx = hasRear ? cmToPx(car.rearWidth) : frontWidth; const rearHeightPx = hasRear ? cmToPx(car.rearHeight) : frontHeight; const country = plate.country; const countryConfig = { NL: { baseWidth: 624, baseHeight: 139, plateImage: dutchPlate, flag: dutchFlag, euWidthRatio: 0.096, fontWeight: 'bold', textColor: '#000', shadowColor: '#ffeb3b', dropShadow: { dx: 3, dy: 3, stdDeviation: 1 }, hasBottomBar: true, allowsCentering: true, }, BE: { baseWidth: 1848, baseHeight: 382, plateImage: belgianPlate, flag: belgiumFlag, euWidthRatio: 0.098, fontWeight: 'normal', textColor: '#a51f1f', shadowColor: '#a51f1f', dropShadow: { dx: 0.5, dy: 0.5, stdDeviation: 0.2 }, hasBottomBar: false, allowsCentering: true, }, DE: { baseWidth: 1668, baseHeight: 360, plateImage: germanPlate, flag: germanFlag, euWidthRatio: 0.11, fontWeight: 'bold', textColor: '#000', shadowColor: 'transparent', dropShadow: { dx: 0, dy: 0, stdDeviation: 0 }, hasBottomBar: false, allowsCentering: true, }, }; const selectedCountry = countryConfig[country] || countryConfig.NL; const { baseWidth, baseHeight, plateImage, euWidthRatio, fontWeight, textColor, shadowColor, dropShadow, hasBottomBar, allowsCentering, } = selectedCountry; const euWidth = baseWidth * euWidthRatio; const baseFontSize = baseHeight * 0.6; const blackHeight = hasBottomBar ? baseHeight * 0.1 : 0; const logoHeight = blackHeight * 0.9; const logoWidth = logoHeight * (1481 / 240); const logoX = (baseWidth - logoWidth) / 2; const logoY = baseHeight - blackHeight + (blackHeight - logoHeight) / 2; const maxTextWidth = baseWidth - euWidth - 20; const fontWeightValue = fontWeight; const textY = hasBottomBar ? (baseHeight - blackHeight) / 2 : baseHeight / 2; const maxCenteredTextWidth = baseWidth - 2 * (euWidth + 10); const textShadowValue = dropShadow.stdDeviation === 0 ? 'none' : `${dropShadow.dx}px ${dropShadow.dy}px ${dropShadow.stdDeviation}px ${shadowColor}`; const frontFontSize = getFontSize( plate.front, baseFontSize, maxTextWidth, fontWeightValue ); const rearFontSize = getFontSize( plate.rear, baseFontSize, maxTextWidth, fontWeightValue ); const frontTextWidth = measureTextWidth( plate.front.replace(/ /g, '\u00A0'), frontFontSize, fontWeightValue ); const rearTextWidth = measureTextWidth( plate.rear.replace(/ /g, '\u00A0'), rearFontSize, fontWeightValue ); const shouldCenterFront = allowsCentering && frontTextWidth <= maxCenteredTextWidth; const shouldCenterRear = allowsCentering && rearTextWidth <= maxCenteredTextWidth; const frontTextAnchor = allowsCentering && (country === 'BE' || shouldCenterFront) ? 'middle' : 'start'; const rearTextAnchor = allowsCentering && (country === 'BE' || shouldCenterRear) ? 'middle' : 'start'; const frontTextX = country === 'BE' ? euWidth + (baseWidth - euWidth) / 2 : shouldCenterFront ? baseWidth / 2 : euWidth + 10; const rearTextX = country === 'BE' ? euWidth + (baseWidth - euWidth) / 2 : shouldCenterRear ? baseWidth / 2 : euWidth + 10; return (
{countryOptions.map((option) => ( ))}
updatePlate(i, 'front', e.target.value.toUpperCase()) } aria-label={`Plate text front ${i + 1}`} className="font-[KentekenFont] uppercase" />
updatePlate(i, 'rear', e.target.value.toUpperCase()) } aria-label={`Plate text rear ${i + 1}`} className="font-[KentekenFont] uppercase" />
{plates.length > 1 && ( )}
(frontRefs.current[i] = el)} width={frontWidth} height={frontHeight} className="hidden" viewBox={`0 0 ${baseWidth} ${baseHeight}`} > {country === 'NL' && ( <> )} {plate.front.replace(/ /g, '\u00A0')} {previews[i] && previews[i].front && ( )} (rearRefs.current[i] = el)} width={rearWidthPx} height={rearHeightPx} className="hidden" viewBox={`0 0 ${baseWidth} ${baseHeight}`} > {country === 'NL' && ( <> )} {plate.rear.replace(/ /g, '\u00A0')} {previews[i] && previews[i].rear && ( )}
); })}
); }