The grade check and the layout preview run on short timers. When the item went to the cart inside that window, no product was open and both threw in the customer's browser, which also failed the browser tests intermittently. Each now returns when no product is open. The cart test waits for the empty state, which is painted on the next animation frame. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
363 lines
16 KiB
JavaScript
363 lines
16 KiB
JavaScript
/* Site DTF — Masks, the nesting engine, and drawing the assembled film.
|
||
Extracted verbatim from the single inline script in web/index.html.
|
||
Loaded as classic scripts in the order listed there: they share one global
|
||
scope and run top to bottom, exactly as the original did. */
|
||
// ── imagens e prévia
|
||
function carregarImagem(file){
|
||
return new Promise(res=>{
|
||
if(!RENDERIZA.test(file.name)) return res(null);
|
||
const fr=new FileReader();
|
||
fr.onload=()=>{ const im=new Image(); im.onload=()=>res(im); im.onerror=()=>res(null); im.src=fr.result; };
|
||
fr.onerror=()=>res(null);
|
||
fr.readAsDataURL(file);
|
||
});
|
||
}
|
||
// A folha precisa parecer uma folha: borda, largura marcada e régua de metros.
|
||
// Sem isso o cliente não tem noção de escala e acha que está mal encaixado.
|
||
function molduraFolha(ctx,W,H,larguraCm,metrosReais){
|
||
const REG=34; // faixa da régua, à direita
|
||
ctx.save();
|
||
// fundo da régua
|
||
ctx.fillStyle='#FAFAF8'; ctx.fillRect(W,0,REG,H);
|
||
ctx.strokeStyle='#D5DAE3'; ctx.lineWidth=1;
|
||
ctx.beginPath(); ctx.moveTo(W+.5,0); ctx.lineTo(W+.5,H); ctx.stroke();
|
||
// marcas a cada 10 cm, número a cada metro
|
||
const pxPorM = H/Math.max(metrosReais,0.01);
|
||
ctx.font='9px "Roboto", sans-serif'; ctx.textAlign='left';
|
||
for(let cm=0; cm<=metrosReais*100; cm+=10){
|
||
const y=cm/100*pxPorM;
|
||
if(y>H) break;
|
||
const metro = cm%100===0;
|
||
ctx.strokeStyle = metro? '#B4BAC5' : '#E1E5EC';
|
||
ctx.beginPath(); ctx.moveTo(W+1,y+.5); ctx.lineTo(W+(metro?12:6),y+.5); ctx.stroke();
|
||
if(metro && cm>0){
|
||
ctx.fillStyle='#8A8F98';
|
||
ctx.fillText((cm/100)+' m', W+14, y+3);
|
||
}
|
||
}
|
||
// borda da folha
|
||
ctx.strokeStyle='#B4BAC5'; ctx.lineWidth=1;
|
||
ctx.strokeRect(.5,.5,W-1,H-1);
|
||
// cota da largura, no topo
|
||
ctx.fillStyle='#FFFFFFDD'; ctx.fillRect(0,0,W,18);
|
||
ctx.strokeStyle='#8A8F98'; ctx.lineWidth=1;
|
||
ctx.beginPath();
|
||
ctx.moveTo(8,9); ctx.lineTo(W-8,9);
|
||
ctx.moveTo(8,5); ctx.lineTo(8,13);
|
||
ctx.moveTo(W-8,5); ctx.lineTo(W-8,13);
|
||
ctx.stroke();
|
||
const t=String(larguraCm).replace('.',',')+' cm';
|
||
ctx.font='600 10px "Roboto", sans-serif'; ctx.textAlign='center';
|
||
const tw=ctx.measureText(t).width;
|
||
ctx.fillStyle='#FAFAF8'; ctx.fillRect(W/2-tw/2-6,2,tw+12,14);
|
||
ctx.fillStyle='#5A6570'; ctx.fillText(t,W/2,13);
|
||
ctx.restore();
|
||
}
|
||
|
||
const LOGO_URL='https://cdn.vnda.com.br/dropstar/2025/12/17/logo-header-15125700-16129080.png?v=1766009286';
|
||
const logoImg=new Image(); logoImg.crossOrigin='anonymous'; logoImg.src=LOGO_URL;
|
||
function marcaDagua(ctx,w,h){
|
||
ctx.save(); ctx.globalAlpha=.14;
|
||
const passo=Math.max(90,w/2.1);
|
||
ctx.translate(w/2,h/2); ctx.rotate(-Math.PI/7); ctx.translate(-w/2,-h/2);
|
||
if(logoImg.complete && logoImg.naturalWidth){
|
||
const lw=Math.max(70,w/3.4), lh=lw*(logoImg.naturalHeight/logoImg.naturalWidth);
|
||
for(let y=-h;y<h*2;y+=passo) for(let x=-w;x<w*2;x+=passo*1.7)
|
||
ctx.drawImage(logoImg,x,y,lw,lh);
|
||
}else{
|
||
ctx.fillStyle='#111';
|
||
ctx.font='700 '+Math.max(13,Math.round(w/13))+'px Inter, sans-serif'; ctx.textAlign='center';
|
||
for(let y=-h;y<h*2;y+=passo) for(let x=-w;x<w*2;x+=passo*1.5) ctx.fillText('PRÉVIA',x,y);
|
||
}
|
||
ctx.restore();
|
||
}
|
||
const CEL=GAP_CM; // célula da máscara, em cm = a folga de 5 mm
|
||
// Dilata 1 célula para a direita e para baixo. Como toda peça é dilatada igual,
|
||
// duas vizinhas ficam separadas por exatamente 1 célula — os 5 mm da regra.
|
||
function dilatar(bruto,cw,ch,cheias){
|
||
const cw2=cw+1, ch2=ch+1;
|
||
const out=new Uint8Array(cw2*ch2);
|
||
for(let y=0;y<ch;y++) for(let x=0;x<cw;x++){
|
||
if(!bruto[y*cw+x]) continue;
|
||
out[y*cw2+x]=1; out[y*cw2+x+1]=1; out[(y+1)*cw2+x]=1; out[(y+1)*cw2+x+1]=1;
|
||
}
|
||
return {cw:cw2,ch:ch2,bits:out,cheias}; // cheias = área de arte, sem a folga
|
||
}
|
||
function mascara(img,w,h,rot,esp=false){
|
||
const cw=Math.max(1,Math.ceil(w/CEL)), ch=Math.max(1,Math.ceil(h/CEL));
|
||
const cv=document.createElement('canvas');
|
||
cv.width=cw; cv.height=ch;
|
||
const ctx=cv.getContext('2d',{willReadFrequently:true});
|
||
let bruto, cheias;
|
||
if(!img){ // sem imagem: caixa cheia
|
||
bruto=new Uint8Array(cw*ch).fill(1); cheias=cw*ch;
|
||
return dilatar(bruto,cw,ch,cheias);
|
||
}
|
||
ctx.save();
|
||
ctx.translate(cw/2,ch/2);
|
||
ctx.rotate(rot*Math.PI/180);
|
||
if(esp) ctx.scale(-1,1);
|
||
const dw=rot%180===90?ch:cw, dh=rot%180===90?cw:ch;
|
||
ctx.drawImage(img,-dw/2,-dh/2,dw,dh);
|
||
ctx.restore();
|
||
const d=ctx.getImageData(0,0,cw,ch).data;
|
||
bruto=new Uint8Array(cw*ch); cheias=0;
|
||
for(let i=0,j=3;i<bruto.length;i++,j+=4){ bruto[i] = d[j]>16 ? 1 : 0; cheias+=bruto[i]; }
|
||
return dilatar(bruto,cw,ch,cheias);
|
||
}
|
||
|
||
// Encaixe pela forma, como o motor do servidor: mapa de ocupação real,
|
||
// máscara por arte e candidatos numa grade ao longo da largura.
|
||
// pecas em centímetros: {w,h,img}. W é a largura do filme em cm.
|
||
function encaixar(pecas,W){
|
||
const cols=Math.ceil(W/CEL)+1; // +1 = a coluna de folga da dilatação
|
||
let linhas=0; const mapa=[];
|
||
function garantir(ate){ while(linhas<ate){ mapa.push(new Uint8Array(cols)); linhas++; } }
|
||
|
||
function colide(m,cx,cy){
|
||
if(cx<0||cy<0||cx+m.cw>cols) return true;
|
||
for(let y=0;y<m.ch;y++){
|
||
const gy=cy+y;
|
||
if(gy>=linhas) continue; // vazio abaixo do que já existe
|
||
const L=mapa[gy], off=y*m.cw;
|
||
for(let x=0;x<m.cw;x++)
|
||
if(m.bits[off+x] && L[cx+x]) return true;
|
||
}
|
||
return false;
|
||
}
|
||
function ocupar(m,cx,cy){
|
||
garantir(cy+m.ch);
|
||
for(let y=0;y<m.ch;y++){
|
||
const L=mapa[cy+y], off=y*m.cw;
|
||
for(let x=0;x<m.cw;x++) if(m.bits[off+x]) L[cx+x]=1;
|
||
}
|
||
}
|
||
|
||
const postas=[];
|
||
if(pecas.some(p=>!Number.isFinite(p.w) || p.w<=0 || p.w>W))
|
||
throw new RangeError('A largura solicitada não cabe no filme.');
|
||
const ordem=[...pecas].sort((a,b)=>(b.w*b.h)-(a.w*a.h));
|
||
|
||
ordem.forEach(p=>{
|
||
const oris=[{w:p.w,h:p.h,rot:0}];
|
||
if(!p.mPronta && !p.travado && Math.abs(p.w-p.h)>1 && p.h<=W) oris.push({w:p.h,h:p.w,rot:90});
|
||
|
||
let melhor=null;
|
||
oris.forEach(o=>{
|
||
if(o.w>W) return;
|
||
// a máscara segue a medida da orientação escolhida · girada, largura e altura trocam
|
||
const m = (p.mPronta && o.rot===0) ? p.mPronta : mascara(p.img, o.w, o.h,
|
||
o.rot+(p.a?.giro||0), p.a?.esp);
|
||
const xs=new Set([0]), ys=new Set([0]);
|
||
postas.forEach(q=>{ xs.add(q.cx+q.m.cw); xs.add(q.cx); ys.add(q.cy+q.m.ch); ys.add(q.cy); });
|
||
const passoX=Math.max(1,Math.round(cols/34));
|
||
for(let x=0;x+m.cw<=cols;x+=passoX) xs.add(x);
|
||
xs.add(cols-m.cw);
|
||
const passoY=Math.max(1,Math.round(Math.max(linhas,1)/50));
|
||
for(let y=0;y<=linhas;y+=passoY) ys.add(y);
|
||
const XS=[...xs].filter(x=>x>=0 && x+m.cw<=cols && x*CEL+o.w<=W+0.01).sort((a,b)=>a-b);
|
||
const YS=[...ys].filter(y=>y>=0).sort((a,b)=>a-b);
|
||
|
||
for(const cy of YS){
|
||
if(melhor && cy*CEL > melhor.y + CEL) break;
|
||
for(const cx of XS){
|
||
if(cx*CEL + o.w > W + 0.01) continue; // não deixa a peça sair do filme
|
||
if(colide(m,cx,cy)) continue;
|
||
const cand={cx,cy,m,x:cx*CEL,y:cy*CEL,w:o.w,h:o.h,rot:o.rot,ref:p};
|
||
// menor y, depois menor x · em empate, mantém a orientação original.
|
||
// Desempatar por largura gira tudo, e texto deitado na prévia parece erro.
|
||
const ganha = !melhor || cand.y<melhor.y ||
|
||
(cand.y===melhor.y && cand.x<melhor.x) ||
|
||
(cand.y===melhor.y && cand.x===melhor.x && cand.rot===0 && melhor.rot!==0);
|
||
if(ganha) melhor=cand;
|
||
break;
|
||
}
|
||
}
|
||
});
|
||
if(!melhor){
|
||
const m=mascara(p.img,p.w,p.h,p.a?.giro||0,p.a?.esp);
|
||
melhor={cx:0,cy:linhas,m,x:0,y:linhas*CEL,w:p.w,h:p.h,rot:0,ref:p};
|
||
}
|
||
// a folga só existe entre peças, não é folha cobrada
|
||
ocupar(melhor.m,melhor.cx,melhor.cy);
|
||
postas.push(melhor);
|
||
});
|
||
|
||
const altura=Math.max(...postas.map(p=>p.y+p.h),1);
|
||
return {pos:postas, altura};
|
||
}
|
||
|
||
function desenhaMontagem(area,lista,mts,W,fora,aoTerminar){
|
||
const versao=area.montagemVersao=(area.montagemVersao||0)+1;
|
||
const filme=larguraFilme(), PX_CM=W/filme;
|
||
const itens=lista.map((a,sourceIndex)=>({...a,sourceIndex}));
|
||
if(fora) fora.innerHTML='';
|
||
if(!itens.length){
|
||
area.innerHTML='<div class="semmont"><b>A montagem aparece aqui</b>'+
|
||
'<span>informe a largura de cada arte e a folha se desenha sozinha</span></div>';
|
||
if(aoTerminar) aoTerminar({alturaCm:0, uso:0, girou:0});
|
||
return;
|
||
}
|
||
|
||
Promise.all(itens.map(a=>carregarImagem(a.f).then(img=>({a,img})))).then(imagens=>{
|
||
if(area.montagemVersao!==versao) return;
|
||
const cargas=imagens.flatMap(c=>Array.from({length:c.a.q||1},(_,copyIndex)=>({...c,copyIndex})));
|
||
cargas.forEach(c=>{
|
||
c.w = c.a.cm; // o motor trabalha em centímetros
|
||
c.h = c.w * (c.img ? (c.a.giro? c.img.width/c.img.height : c.img.height/c.img.width) : 1);
|
||
c.travado = !!c.a.giro; // giro do cliente manda no encaixe
|
||
});
|
||
const enc=encaixar(cargas,filme);
|
||
const alturaCm=enc.altura;
|
||
const pos=enc.pos.map(p=>({...p, x:p.x*PX_CM, y:p.y*PX_CM, w:p.w*PX_CM, h:p.h*PX_CM}));
|
||
const altura=alturaCm*PX_CM;
|
||
const usado=Math.max(altura,10);
|
||
const REG=34;
|
||
const cv=document.createElement('canvas');
|
||
cv.width=W+REG; cv.height=Math.round(usado);
|
||
const ctx=cv.getContext('2d');
|
||
ctx.fillStyle='#fff'; ctx.fillRect(0,0,cv.width,cv.height);
|
||
const pal=['#C3CEDD','#B7A9C8','#A9B7C8','#A8BFA9','#C8C0A0','#CBB6B6'];
|
||
let girou=0;
|
||
pos.forEach((p,j)=>{
|
||
const c=p.ref;
|
||
if(p.rot===90) girou++;
|
||
ctx.save();
|
||
const giro = (p.rot===90?90:0) + (c.a && c.a.giro? 90:0);
|
||
ctx.translate(p.x+p.w/2, p.y+p.h/2);
|
||
ctx.rotate(giro*Math.PI/180);
|
||
if(c.a && c.a.esp) ctx.scale(-1,1);
|
||
const dw = giro%180===90 ? p.h : p.w, dh = giro%180===90 ? p.w : p.h;
|
||
if(c.img) ctx.drawImage(c.img,-dw/2,-dh/2,dw,dh);
|
||
else{ ctx.fillStyle=pal[j%pal.length]; ctx.fillRect(-dw/2,-dh/2,dw,dh); }
|
||
ctx.restore();
|
||
if(!c.img){
|
||
ctx.fillStyle='#5A6570'; ctx.font='11px "Roboto", sans-serif'; ctx.textAlign='center';
|
||
ctx.fillText(c.a.f.name.split('.').pop().toUpperCase(),p.x+p.w/2,p.y+p.h/2+4);
|
||
}
|
||
});
|
||
molduraFolha(ctx,W,usado,filme,alturaCm/100);
|
||
marcaDagua(ctx,W,cv.height);
|
||
area.innerHTML=''; area.appendChild(cv);
|
||
|
||
// O que virou arte é a área pintada, não a soma das caixas: com encaixe por forma
|
||
// as caixas se sobrepõem de propósito e a soma delas passa de 100%.
|
||
const areaArteCm=enc.pos.reduce((s,p)=>
|
||
s + (p.m && p.m.cheias!=null ? p.m.cheias*CEL*CEL : p.w*p.h), 0);
|
||
const uso=Math.max(1,Math.min(100,
|
||
Math.round(areaArteCm/(filme*Math.max(alturaCm,0.1))*100)));
|
||
const el=document.createElement('div');
|
||
el.className = uso>=70? 'usoBom' : 'usoRuim';
|
||
el.innerHTML = (uso>=70
|
||
? '<b>'+uso+'% da folha virou arte</b> bom aproveitamento'
|
||
: '<b>'+uso+'% da folha virou arte</b> dá para melhorar: artes de larguras parecidas '+
|
||
'se encaixam melhor lado a lado')
|
||
+ (girou? ' · <b style="display:inline">'+girou+
|
||
(girou>1?' artes giradas':' arte girada')+' 90°</b> para caber melhor':'');
|
||
(fora||area).appendChild(el);
|
||
if(aoTerminar) aoTerminar({alturaCm, uso, girou,
|
||
placements:enc.pos.map(p=>({source_index:p.ref.a.sourceIndex,
|
||
copy_index:p.ref.copyIndex,x_cm:p.x,y_cm:p.y,
|
||
width_cm:+p.w.toFixed(4),length_cm:+p.h.toFixed(4),
|
||
rotation_degrees:(p.rot+(p.ref.a.giro||0))%360,
|
||
mirrored:!!p.ref.a.esp}))});
|
||
});
|
||
}
|
||
|
||
function previaArtes(){ desenhaMontagem($('pArea'), artes, metros, 560); }
|
||
|
||
// montagem ao vivo, ao lado da fila · redesenha 140 ms depois da ultima mexida
|
||
let tMont=null;
|
||
function previaAoVivo(){
|
||
clearTimeout(tMont);
|
||
montagemLayout=null;
|
||
$('vArea').montagemVersao=($('vArea').montagemVersao||0)+1;
|
||
if(ehFolha()){
|
||
$('vUso').innerHTML='';
|
||
const tot=folhaTotalM();
|
||
$('vMt').textContent = tot>0 ? fmtM(cobrar(tot))+' m' : '—';
|
||
const medidas=folhas.filter(x=>x.med);
|
||
const previews=folhas.filter(x=>x.previewSrc);
|
||
if(previews.length){
|
||
const painel=document.createElement('div'); painel.className='folhaPrevias';
|
||
previews.forEach((x,index)=>{
|
||
const figura=document.createElement('figure'); figura.className='folhaPrevia';
|
||
const imagem=new Image(); imagem.src=x.previewSrc; imagem.alt=x.f.name;
|
||
const legenda=document.createElement('figcaption');
|
||
legenda.textContent=(previews.length>1 ? (index+1)+'. ' : '')+x.f.name+
|
||
(x.med ? ' · '+n1(x.med.larg)+' × '+n1(x.med.alt)+' cm' : '');
|
||
figura.append(imagem,legenda); painel.append(figura);
|
||
});
|
||
$('vArea').replaceChildren(painel);
|
||
}else{
|
||
$('vArea').innerHTML = medidas.length
|
||
? '<div class="semmont"><b>'+(medidas.length>1
|
||
? medidas.length+' folhas · '+fmtM(tot)+' m'
|
||
: n1(medidas[0].med.larg)+' × '+n1(medidas[0].med.alt)+' cm')+'</b>'+
|
||
'<span>'+(medidas.length>1? 'medidas lidas de cada arquivo' : medidas[0].med.fonte)+
|
||
' · vão para a máquina do jeito que você montou</span></div>'
|
||
: '<div class="semmont"><b>Sua folha aparece aqui</b>'+
|
||
'<span>a medida é lida do próprio arquivo, não do peso dele</span></div>';
|
||
}
|
||
return;
|
||
}
|
||
tMont=setTimeout(()=>{
|
||
// o produto pode ter sido fechado nesses 140 ms (item foi para o carrinho)
|
||
if(!modo) return;
|
||
const prontas=artes.filter(a=>a.cm>0 && a.cm<=larguraFilme() && a.src);
|
||
const W=Math.round(300*ZOOMS[zoomI]);
|
||
$('vArea').classList.toggle('ampliado', zoomI>0);
|
||
desenhaMontagem($('vArea'), prontas, 0, W, $('vUso'), r=>{
|
||
montagemCm = r.alturaCm; // altura real da folha montada
|
||
montagemLayout=r.placements ? {height_cm:+r.alturaCm.toFixed(4),
|
||
placements:r.placements} : null;
|
||
$('vMt').textContent = r.alturaCm? fmtM(cobrar(r.alturaCm/100))+' m' : '—';
|
||
resumoArtes(); // o resumo lê a mesma folha
|
||
agendaAvaliacao(); // a nota só sai com a metragem pronta
|
||
});
|
||
$('vArea').scrollTop=0;
|
||
},140);
|
||
}
|
||
|
||
function motivoNota(){
|
||
if(!nota) return 'sem nota ainda · a conferência roda na sala antes de imprimir';
|
||
if(!ehFolha()){
|
||
const ruins=artes.filter(a=>a.cm>0 && dpiDe(a)<300);
|
||
if(!ruins.length) return 'nota '+nota+' · todas as artes em 300 DPI ou mais';
|
||
const pior=ruins.reduce((p,a)=>dpiDe(a)<dpiDe(p)?a:p);
|
||
return 'nota '+nota+' · '+ruins.length+(ruins.length>1?' artes abaixo':' arte abaixo')+
|
||
' de 300 DPI · a menor é "'+pior.f.name+'" com '+
|
||
Math.round(dpiDe(pior))+' DPI em '+pior.cm.toFixed(1).replace('.',',')+' cm';
|
||
}
|
||
return nota>=90 ? 'nota '+nota+' · resolução ótima em toda a folha'
|
||
: 'nota '+nota+' · a resolução é o único ponto — o resto corrigimos por você';
|
||
}
|
||
function previa(){
|
||
// Na folha pronta o painel não tem o que mostrar: a folha vem fechada, ninguém
|
||
// olhou dentro dela. Largura e metragem — o que de fato medimos — já aparecem
|
||
// na coluna ao lado e no carrinho.
|
||
if(ehFolha()){ $('prev').classList.remove('on'); return; }
|
||
const cob=cobrar(metros), sobra=+(cob-metros).toFixed(2);
|
||
$('pMt').innerHTML=fmtM(cob)+'<small> m</small>';
|
||
$('pSub').textContent='folha montada por nós com as artes que você enviou';
|
||
const linhas=[
|
||
['var(--verde)','✓','Largura',larguraFilme()+' cm · dentro do padrão da máquina'],
|
||
['var(--verde)','✓','Espaço entre artes','5 mm garantidos'],
|
||
[nota>=75?'var(--verde)':'var(--laranja)', nota>=75?'✓':'!','Qualidade da folha',motivoNota()],
|
||
['var(--verde)','✓','Metragem proporcional',
|
||
'sua folha tem '+fmtM(metros)+' m e você paga '+fmtM(cob)+
|
||
' m · arredondamos só até os 10 cm mais próximos'],
|
||
];
|
||
linhas.push(['var(--verde)','+','Identificação do pedido',
|
||
'aplicamos seu nome e o número do pedido no fim da folha · por nossa conta']);
|
||
$('pInfo').innerHTML=linhas.map(l=>
|
||
'<div class="li"><span class="c" style="background:'+l[0]+
|
||
(l[0]==='var(--laranja)'?';color:#241703':'')+'">'+l[1]+'</span>'+
|
||
'<div><b>'+escapeHTML(l[2])+'</b><span>'+escapeHTML(l[3])+'</span></div></div>').join('');
|
||
$('prev').classList.add('on');
|
||
$('pArea').innerHTML='<div class="carregando">montando a prévia…</div>';
|
||
try{ previaArtes(); }
|
||
catch(e){ $('pArea').innerHTML='<div class="semprev"><b>Prévia indisponível</b>'+
|
||
'O arquivo será processado normalmente.</div>'; }
|
||
}
|