import React, { useState, useEffect, useCallback } from 'react'; import { UploadCloud, FileType, CheckCircle, AlertTriangle, Download, Settings, Trash2, X, RefreshCw, Layers } from 'lucide-react'; export default function App() { const [files, setFiles] = useState([]); const [jsZipLoaded, setJsZipLoaded] = useState(false); const [status, setStatus] = useState('idle'); const [progressMsg, setProgressMsg] = useState(''); const [progressPct, setProgressPct] = useState(0); const [downloadUrl, setDownloadUrl] = useState(null); const [stats, setStats] = useState(null); const [resolution, setResolution] = useState(4096); const resetApp = () => { setFiles([]); setStatus('idle'); setProgressMsg(''); setProgressPct(0); if (downloadUrl) URL.revokeObjectURL(downloadUrl); setDownloadUrl(null); setStats(null); }; useEffect(() => { if (window.JSZip) { setJsZipLoaded(true); return; } const script = document.createElement('script'); script.src = 'https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js'; script.async = true; script.onload = () => setJsZipLoaded(true); document.body.appendChild(script); }, []); const handleDragOver = useCallback((e) => { e.preventDefault(); e.stopPropagation(); }, []); const handleDrop = useCallback((e) => { e.preventDefault(); e.stopPropagation(); if (e.dataTransfer.files && e.dataTransfer.files.length > 0) { const droppedFiles = Array.from(e.dataTransfer.files).filter( f => f.name.toLowerCase().endsWith('.kmz') || f.name.toLowerCase().endsWith('.kml') ); setFiles(prev => [...prev, ...droppedFiles]); } }, []); const handleFileSelect = (e) => { if (e.target.files && e.target.files.length > 0) { const selectedFiles = Array.from(e.target.files); setFiles(prev => [...prev, ...selectedFiles]); } }; const removeFile = (indexToRemove) => { setFiles(files.filter((_, index) => index !== indexToRemove)); }; // Retorna a cor 100% SÓLIDA (rgb) para evitar manchas se houver blocos sobrepostos no arquivo original const parseKmlColor = (kmlColor) => { if (!kmlColor) return null; let cleanColor = kmlColor.replace('#', '').trim(); if (cleanColor.length === 6) cleanColor = 'FF' + cleanColor; if (cleanColor.length !== 8) return null; const b = parseInt(cleanColor.substring(2, 4), 16); const g = parseInt(cleanColor.substring(4, 6), 16); const r = parseInt(cleanColor.substring(6, 8), 16); return `rgb(${r}, ${g}, ${b})`; }; const extractTagContent = (xml, tagName, startOffset = 0) => { const startTag = `<${tagName}`; const endTag = ``; const startIdx = xml.indexOf(startTag, startOffset); if (startIdx === -1) return null; const closeBracketIdx = xml.indexOf('>', startIdx); if (closeBracketIdx === -1) return null; const endIdx = xml.indexOf(endTag, closeBracketIdx); if (endIdx === -1) return null; return { content: xml.substring(closeBracketIdx + 1, endIdx), nextIndex: endIdx + endTag.length, fullMatch: xml.substring(startIdx, endIdx + endTag.length) }; }; const parseCoordinates = (str) => { const normalized = str.replace(/[\n\r]/g, ' ').replace(/,\s+/g, ',').trim(); const tuples = normalized.split(/\s+/); const points = []; for (let i = 0; i < tuples.length; i++) { const parts = tuples[i].split(','); if (parts.length >= 2) { const lon = parseFloat(parts[0]); const lat = parseFloat(parts[1]); if (!isNaN(lon) && !isNaN(lat)) { if (lon === 0 && lat === 0) continue; if (lon < -180 || lon > 180 || lat < -90 || lat > 90) continue; points.push({ lon, lat }); } } } return points; }; const yieldToBrowser = () => new Promise(resolve => setTimeout(resolve, 0)); const processFiles = async () => { if (!jsZipLoaded || files.length === 0) return; setStatus('processing'); setDownloadUrl(null); setStats(null); setProgressPct(0); try { let globalMinLat = 90, globalMaxLat = -90, globalMinLon = 180, globalMaxLon = -180; let totalPolygonsProcessed = 0; // ============================================================================== // PASSO 1: LEITURA RÁPIDA (CALCULAR CAIXA GLOBAL DA COBERTURA) // ============================================================================== for (let fIndex = 0; fIndex < files.length; fIndex++) { const file = files[fIndex]; setProgressPct(Math.round((fIndex / files.length) * 20)); setProgressMsg(`[Passo 1/2] Mapeando limites: ${fIndex + 1}/${files.length}`); await yieldToBrowser(); let kmlText = ''; try { if (file.name.toLowerCase().endsWith('.kmz')) { const zip = new window.JSZip(); const loadedZip = await zip.loadAsync(file); const kmlFile = Object.values(loadedZip.files).find(f => f.name.toLowerCase().endsWith('.kml')); if (kmlFile) kmlText = await kmlFile.async("string"); } else { kmlText = await file.text(); } } catch (err) { continue; } if (!kmlText) continue; let searchIdx = 0; while (true) { const tag = extractTagContent(kmlText, 'coordinates', searchIdx); if (!tag) break; searchIdx = tag.nextIndex; const pts = parseCoordinates(tag.content); for (let k = 0; k < pts.length; k++) { const pt = pts[k]; if (pt.lat < globalMinLat) globalMinLat = pt.lat; if (pt.lat > globalMaxLat) globalMaxLat = pt.lat; if (pt.lon < globalMinLon) globalMinLon = pt.lon; if (pt.lon > globalMaxLon) globalMaxLon = pt.lon; } } kmlText = null; } if (globalMinLat === 90 || globalMaxLat === -90) { throw new Error("Nenhuma coordenada válida encontrada nos arquivos."); } globalMinLat -= 0.0005; globalMaxLat += 0.0005; globalMinLon -= 0.0005; globalMaxLon += 0.0005; // ============================================================================== // PREPARAR O CANVAS NÍTIDO // ============================================================================== const widthDeg = globalMaxLon - globalMinLon; const heightDeg = globalMaxLat - globalMinLat; const ratio = widthDeg > 0 && heightDeg > 0 ? widthDeg / heightDeg : 1; let canvasWidth = resolution; let canvasHeight = Math.round(resolution / ratio); if (canvasHeight > resolution) { canvasHeight = resolution; canvasWidth = Math.round(resolution * ratio); } canvasWidth = Math.max(1, canvasWidth); canvasHeight = Math.max(1, canvasHeight); const canvas = document.createElement('canvas'); canvas.width = canvasWidth; canvas.height = canvasHeight; const ctx = canvas.getContext('2d', { alpha: true }); // DESATIVA A SUAVIZAÇÃO PARA DEIXAR OS PIXELS TOTALMENTE NÍTIDOS ctx.imageSmoothingEnabled = false; ctx.clearRect(0, 0, canvasWidth, canvasHeight); const safeWidthDeg = widthDeg > 0 ? widthDeg : 0.0001; const safeHeightDeg = heightDeg > 0 ? heightDeg : 0.0001; // ============================================================================== // PASSO 2: PINTURA SÓLIDA COM FILLRECT (O SEGREDO DA NITIDEZ) // ============================================================================== for (let fIndex = 0; fIndex < files.length; fIndex++) { const file = files[fIndex]; setProgressPct(20 + Math.round((fIndex / files.length) * 70)); setProgressMsg(`[Passo 2/2] Pintando blocos nítidos: ${fIndex + 1}/${files.length}`); await yieldToBrowser(); let kmlText = ''; try { if (file.name.toLowerCase().endsWith('.kmz')) { const zip = new window.JSZip(); const loadedZip = await zip.loadAsync(file); const kmlFile = Object.values(loadedZip.files).find(f => f.name.toLowerCase().endsWith('.kml')); if (kmlFile) kmlText = await kmlFile.async("string"); } else { kmlText = await file.text(); } } catch (err) { continue; } if (!kmlText) continue; const styles = {}; const styleMaps = {}; let sIdx = 0; while (true) { const styleRes = extractTagContent(kmlText, 'Style', sIdx); if (!styleRes) break; sIdx = styleRes.nextIndex; const idMatch = styleRes.fullMatch.match(/id=["']([^"']+)["']/); if (idMatch) { const id = idMatch[1]; let color = null; const polyMatch = styleRes.content.match(/[\s\S]*?([^<]+)<\/color>[\s\S]*?<\/PolyStyle>/); if (polyMatch) color = polyMatch[1]; else { const lineMatch = styleRes.content.match(/[\s\S]*?([^<]+)<\/color>[\s\S]*?<\/LineStyle>/); if (lineMatch) color = lineMatch[1]; } if (color) styles['#' + id] = parseKmlColor(color.trim()); } } let smIdx = 0; while (true) { const smRes = extractTagContent(kmlText, 'StyleMap', smIdx); if (!smRes) break; smIdx = smRes.nextIndex; const idMatch = smRes.fullMatch.match(/id=["']([^"']+)["']/); if (idMatch) { const id = idMatch[1]; const pairRegex = /[\s\S]*?normal<\/key>[\s\S]*?([^<]+)<\/styleUrl>[\s\S]*?<\/Pair>/; const pairMatch = smRes.content.match(pairRegex); if (pairMatch) { let sUrl = pairMatch[1].trim(); styleMaps['#' + id] = sUrl.startsWith('#') ? sUrl : '#' + sUrl; } } } const getColor = (url) => { let k = url.startsWith('#') ? url : '#' + url; if (styleMaps[k]) k = styleMaps[k]; return styles[k] || 'rgb(255, 0, 0)'; }; let pmIdx = 0; let blocksDrawn = 0; while (true) { const pmRes = extractTagContent(kmlText, 'Placemark', pmIdx); if (!pmRes) break; pmIdx = pmRes.nextIndex; const pmBlock = pmRes.content; let blockColor = 'rgb(255, 0, 0)'; const styleUrlMatch = pmBlock.match(/([^<]+)<\/styleUrl>/); if (styleUrlMatch) { blockColor = getColor(styleUrlMatch[1].trim()); } else { const colorMatch = pmBlock.match(/([^<]+)<\/color>/); if (colorMatch) { const parsed = parseKmlColor(colorMatch[1].trim()); if (parsed) blockColor = parsed; } } let coordIdx = 0; while (true) { const cRes = extractTagContent(pmBlock, 'coordinates', coordIdx); if (!cRes) break; coordIdx = cRes.nextIndex; const pts = parseCoordinates(cRes.content); if (pts.length > 0) { let minLon = 180, maxLon = -180, minLat = 90, maxLat = -90; for (let k = 0; k < pts.length; k++) { const pt = pts[k]; if (pt.lon < minLon) minLon = pt.lon; if (pt.lon > maxLon) maxLon = pt.lon; if (pt.lat < minLat) minLat = pt.lat; if (pt.lat > maxLat) maxLat = pt.lat; } if (minLon !== 180) { // Usa fillRect para criar quadrados perfeitos. Sem linhas (stroke) ou paths. // O Math.round trava os cálculos nos pixels inteiros e elimina borrões. const x1 = Math.round(((minLon - globalMinLon) / safeWidthDeg) * canvasWidth); const x2 = Math.round(((maxLon - globalMinLon) / safeWidthDeg) * canvasWidth); const y1 = Math.round(canvasHeight - (((maxLat - globalMinLat) / safeHeightDeg) * canvasHeight)); // Y topo const y2 = Math.round(canvasHeight - (((minLat - globalMinLat) / safeHeightDeg) * canvasHeight)); // Y base const x = x1; const y = y1; const w = Math.max(1, x2 - x1); const h = Math.max(1, y2 - y1); ctx.fillStyle = blockColor; ctx.fillRect(x, y, w, h); totalPolygonsProcessed++; } } } blocksDrawn++; if (blocksDrawn % 1000 === 0) await yieldToBrowser(); } kmlText = null; } if (totalPolygonsProcessed === 0) throw new Error("Nenhum bloco desenhado."); // ============================================================================== // PASSO 3: GERAÇÃO DO KMZ COM OPACIDADE GLOBAL // ============================================================================== setProgressPct(95); setProgressMsg('Convertendo binários da Imagem (Aguarde)...'); await yieldToBrowser(); const imageBlob = await new Promise(resolve => canvas.toBlob(resolve, 'image/png')); if (!imageBlob) { throw new Error("Erro de renderização: a carga excedeu os limites gráficos."); } const imageArrayBuffer = await imageBlob.arrayBuffer(); const overlayImageName = `overlay_${Date.now()}.png`; // B3FFFFFF = O Google Earth aplica 70% de opacidade à imagem perfeitamente sólida, // deixando a cobertura limpa, com os pixels definidos e o mapa do chão visível. const kmlContent = ` Cobertura Consolidada Otimizado em Pixel-Perfect. Malha de Cobertura Global 1 B3FFFFFF clampToGround 99 ${overlayImageName} ${globalMaxLat.toFixed(6)} ${globalMinLat.toFixed(6)} ${globalMaxLon.toFixed(6)} ${globalMinLon.toFixed(6)} `; const resultZip = new window.JSZip(); resultZip.file("doc.kml", kmlContent); resultZip.file(overlayImageName, imageArrayBuffer); const content = await resultZip.generateAsync({ type: "blob", compression: "DEFLATE" }); const url = URL.createObjectURL(content); setProgressPct(100); setDownloadUrl(url); setStats({ originalFiles: files.length, polygonsProcessed: totalPolygonsProcessed, dimensions: `${canvasWidth} x ${canvasHeight} px`, fileSize: (content.size / 1024 / 1024).toFixed(2) + ' MB' }); setStatus('done'); } catch (error) { console.error(error); setProgressMsg(error.message || "Erro crítico durante o processamento."); setStatus('error'); } }; return (

Otimizador Extremo KMZ

Unifique centenas de grids em uma única imagem de pixels nítidos.

{status === 'done' && stats ? (

Fusão Concluída!

Seus {stats.originalFiles} arquivos geraram uma única imagem perfeitamente otimizada.

{stats.polygonsProcessed.toLocaleString()} Blocos Sólidos
{stats.dimensions.split(' ')[0]}x{stats.dimensions.split(' ')[2]} Resolução Nítida
{stats.fileSize} Tamanho Final
Baixar KMZ Único
) : (

Arraste seus arquivos para cá

Suporta múltiplos Gigabytes. Otimização imune a travamentos.

{files.length > 0 && (
Fila de Arquivos {files.length}
{files.map((f, i) => (
{f.name} {(f.size / 1024 / 1024).toFixed(2)} MB
))}
)}

Imagens 4096px são altamente seguras contra falhas no Google Earth.

{status === 'processing' ? (
Processando Lote {progressMsg}
{progressPct}%
) : ( )}
{status === 'error' && (

Ocorreu um problema

{progressMsg}
)}
)}
); }