import type { TestResult } from './types'; type ThemeId=keyof typeof POSTER_THEMES;type LayoutId='symmetric'|'single';type ViewId='overview'|'left'|'right'|'top16'|'top8'|'final'; interface TreeNode{id:string;name:string;round:number;children:TreeNode[];x:number;y:number} interface Box{x:number;y:number;width:number;height:number}interface Theme{page:string;paper:string;ink:string;muted:string;grid:string;soft:string;accent:string} interface PosterOptions{themeId?:ThemeId;layout?:LayoutId;view?:ViewId;caption?:string;sticker?:boolean;background?:HTMLImageElement|null;adjust?:Partial>} export const POSTER_THEMES = { clean: { page:'#f4f4f4', paper:'#ffffff', ink:'#151515', muted:'#929292', grid:'#d8d8d8', soft:'#f7f7f7', accent:'#ff2442' }, cream: { page:'#eee9df', paper:'#fbf8f2', ink:'#24211d', muted:'#8f887e', grid:'#d5cec3', soft:'#f1ece4', accent:'#e34d35' }, night: { page:'#111114', paper:'#1b1b1f', ink:'#f7f7f7', muted:'#888891', grid:'#414148', soft:'#26262b', accent:'#ff3855' } }; export function drawPoster(ctx:CanvasRenderingContext2D,result:TestResult,{themeId='clean',layout='symmetric',view='overview',caption='',sticker=true,background=null,adjust={}}:PosterOptions={}):void{ const theme=POSTER_THEMES[themeId]||POSTER_THEMES.clean,W=1080,H=1440; ctx.clearRect(0,0,W,H);ctx.fillStyle=theme.page;ctx.fillRect(0,0,W,H); if(background){ctx.save();ctx.filter=`brightness(${adjust.brightness??100}%) contrast(${adjust.contrast??100}%) saturate(${adjust.saturate??100}%)`;cover(ctx,background,0,0,W,H);ctx.restore();ctx.fillStyle=themeId==='night'?'#111114e8':'#f4f4f4e8';ctx.fillRect(0,0,W,H);} ctx.fillStyle=theme.ink;ctx.font='700 22px system-ui';ctx.fillText('PK LAB / MY BRACKET',54,54); ctx.font='800 48px system-ui';ctx.fillText(result.test_title,54,115); ctx.fillStyle=theme.muted;ctx.font='400 20px system-ui';ctx.fillText('每一次选择,都留在这张晋级路线里',54,151); rounded(ctx,48,184,984,1110,18);ctx.fillStyle=theme.paper;ctx.fill(); const tree=buildTree(result),selected=selectTree(tree,view); drawBracketTree(ctx,selected,{x:72,y:250,width:936,height:980},theme,layout,view); ctx.fillStyle=theme.ink;ctx.font='700 26px system-ui';ctx.fillText(caption||`最终选择 ${result.champion_name}`,54,1350); ctx.fillStyle=theme.muted;ctx.font='400 17px system-ui';ctx.fillText('保存图片,分享你的完整晋级路线',54,1385); if(sticker){rounded(ctx,884,50,142,48,24);ctx.fillStyle=theme.accent;ctx.fill();ctx.fillStyle='#fff';ctx.font='800 17px system-ui';ctx.textAlign='center';ctx.fillText('NO.1 PICK',955,81);ctx.textAlign='left';} } export function buildTree(result:TestResult):TreeNode{ const decisions=[...(result.decisions||[])],names=result.candidate_names||{},maxRound=Math.max(1,...decisions.map(item=>item.round)); const build=(id:string,before:number):TreeNode=>{const decision=decisions.filter(item=>item.winner_id===id&&item.roundb.round-a.round)[0];if(!decision)return{id,name:names[id]||id,round:0,children:[],x:0,y:0};return{id,name:names[id]||id,round:decision.round,children:[build(decision.left_id,decision.round),build(decision.right_id,decision.round)],x:0,y:0};}; return build(result.champion_id||decisions.at(-1)?.winner_id||'',maxRound+1); } function selectTree(tree:TreeNode,view:ViewId):TreeNode{if(view==='left'&&tree.children[0])return tree.children[0];if(view==='right'&&tree.children[1])return tree.children[1];const levels:Partial>={top16:3,top8:2,final:1};const level=levels[view];if(!level)return tree;const minimum=Math.max(0,tree.round-level);const prune=(node:TreeNode):TreeNode=>node.round<=minimum?{...node,children:[]}:{...node,children:node.children.map(prune)};return prune(tree);} function drawBracketTree(ctx:CanvasRenderingContext2D,tree:TreeNode,box:Box,theme:Theme,layout:LayoutId,view:ViewId):void{ if(!tree){ctx.fillStyle=theme.muted;ctx.font='24px system-ui';ctx.fillText('暂无晋级数据',box.x,box.y+60);return;} const symmetric=layout==='symmetric'&&view!=='left'&&view!=='right'&&tree.children.length===2; if(symmetric)drawSymmetric(ctx,tree,box,theme);else drawSingle(ctx,tree,box,theme); } function drawSingle(ctx:CanvasRenderingContext2D,tree:TreeNode,box:Box,theme:Theme):void{ const leaves:TreeNode[]=[];collectLeaves(tree,leaves);const minRound=Math.min(...leaves.map(node=>node.round)),maxRound=Math.max(tree.round,minRound+1);assignLeafY(leaves,box.y+48,box.y+box.height-38);assignInternalY(tree); walk(tree,node=>{node.x=box.x+22+(node.round-minRound)/(maxRound-minRound)*(box.width-154);}); roundHeaders(ctx,box,minRound,maxRound,theme); drawEdges(ctx,tree,theme);drawNodes(ctx,tree,theme,{width:122,align:'left'}); } function drawSymmetric(ctx:CanvasRenderingContext2D,tree:TreeNode,box:Box,theme:Theme):void{ const left=tree.children[0],right=tree.children[1],middle=box.x+box.width/2,sideWidth=box.width/2-46; if(!left||!right)return; const leftLeaves:TreeNode[]=[],rightLeaves:TreeNode[]=[];collectLeaves(left,leftLeaves);collectLeaves(right,rightLeaves);assignLeafY(leftLeaves,box.y+48,box.y+box.height-38);assignLeafY(rightLeaves,box.y+48,box.y+box.height-38);assignInternalY(left);assignInternalY(right);tree.y=(left.y+right.y)/2;tree.x=middle; const depth=Math.max(1,tree.round);walk(left,node=>{node.x=middle-32-(tree.round-node.round)/depth*sideWidth;});walk(right,node=>{node.x=middle+32+(tree.round-node.round)/depth*sideWidth;}); ctx.fillStyle=theme.muted;ctx.font='600 13px system-ui';ctx.fillText('LEFT BRACKET',box.x+4,box.y+17);ctx.textAlign='right';ctx.fillText('RIGHT BRACKET',box.x+box.width-4,box.y+17);ctx.textAlign='left'; drawEdges(ctx,tree,theme);drawNodes(ctx,tree,theme,{width:76,align:'center'}); } function roundHeaders(ctx:CanvasRenderingContext2D,box:Box,minRound:number,maxRound:number,theme:Theme):void{ctx.fillStyle=theme.muted;ctx.font='600 12px system-ui';for(let round=minRound;round<=maxRound;round+=1){const x=box.x+22+(round-minRound)/(maxRound-minRound)*(box.width-154);ctx.fillText(round===minRound?'候选':round===maxRound?'冠军':`第 ${round} 轮`,x,box.y+17);}} function drawEdges(ctx:CanvasRenderingContext2D,tree:TreeNode,theme:Theme):void{ctx.strokeStyle=theme.grid;ctx.lineWidth=1.4;walk(tree,parent=>{for(const child of parent.children){const turn=child.x+(parent.x-child.x)*.52;ctx.beginPath();ctx.moveTo(child.x,child.y);ctx.lineTo(turn,child.y);ctx.lineTo(turn,parent.y);ctx.lineTo(parent.x,parent.y);ctx.stroke();}});} function drawNodes(ctx:CanvasRenderingContext2D,tree:TreeNode,theme:Theme,{width,align}:{width:number;align:'left'|'center'}):void{walk(tree,node=>{const champion=node===tree,h=24,x=align==='center'?node.x-width/2:node.x,y=node.y-h/2;ctx.fillStyle=champion?theme.accent:theme.soft;ctx.strokeStyle=champion?theme.accent:theme.grid;ctx.lineWidth=1;ctx.fillRect(x,y,width,h);ctx.strokeRect(x,y,width,h);ctx.fillStyle=champion?'#fff':theme.ink;ctx.font=`${champion?'700':'500'} ${width<90?10:12}px system-ui`;ctx.textAlign='center';ctx.textBaseline='middle';ctx.fillText(clip(node.name,width<90?5:8),x+width/2,node.y+.5);ctx.textAlign='left';ctx.textBaseline='alphabetic';});} function assignLeafY(leaves:TreeNode[],start:number,end:number):void{const gap=(end-start)/Math.max(1,leaves.length-1);leaves.forEach((node,index)=>{node.y=leaves.length===1?(start+end)/2:start+index*gap;});} function assignInternalY(node:TreeNode):number{if(!node.children.length)return node.y;node.children.forEach(assignInternalY);node.y=node.children.reduce((sum,child)=>sum+child.y,0)/node.children.length;return node.y;} function collectLeaves(node:TreeNode,out:TreeNode[]):void{if(!node.children.length)out.push(node);else node.children.forEach(child=>collectLeaves(child,out));} function walk(node:TreeNode,visit:(node:TreeNode)=>void):void{visit(node);node.children.forEach(child=>walk(child,visit));} function clip(value:string,length:number):string{return value.length>length?`${value.slice(0,length-1)}…`:value;} function rounded(ctx:CanvasRenderingContext2D,x:number,y:number,w:number,h:number,r:number):void{ctx.beginPath();ctx.roundRect(x,y,w,h,r);} function cover(ctx:CanvasRenderingContext2D,image:HTMLImageElement,x:number,y:number,w:number,h:number):void{const ratio=Math.max(w/image.width,h/image.height),dw=image.width*ratio,dh=image.height*ratio;ctx.drawImage(image,x+(w-dw)/2,y+(h-dh)/2,dw,dh);}