11 lines
7.0 KiB
TypeScript
11 lines
7.0 KiB
TypeScript
import type{TestResult}from'./types';
|
|
export const POSTER_THEMES={clean:{page:'#f4f4f4',paper:'#fff',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'}}as const;
|
|
type ThemeId=keyof typeof POSTER_THEMES;type LayoutId='symmetric'|'single';type ViewId='overview'|'left'|'right'|'top16'|'top8'|'final';type Theme=(typeof POSTER_THEMES)[ThemeId];interface Node{id:string;name:string;round:number;children:Node[];x:number;y:number}interface Box{x:number;y:number;width:number;height:number}interface Options{themeId?:ThemeId;layout?:LayoutId;view?:ViewId;caption?:string;sticker?:boolean;background?:HTMLImageElement|null;adjust?:Partial<Record<'brightness'|'contrast'|'saturate',number>>}
|
|
export function drawPoster(ctx:CanvasRenderingContext2D,result:TestResult,{themeId='clean',layout='symmetric',view='overview',caption='',sticker=true,background=null,adjust={}}:Options={}):void{const theme=POSTER_THEMES[themeId],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('RICE PK / 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=selectTree(buildTree(result),view);if(layout==='symmetric'&&view!=='left'&&view!=='right'&&tree.children.length===2)drawSymmetric(ctx,tree,{x:72,y:250,width:936,height:980},theme);else drawSingle(ctx,tree,{x:72,y:250,width:936,height:980},theme);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';}}
|
|
function buildTree(result:TestResult):Node{const decisions=[...(result.decisions||[])],names=result.candidate_names||{},max=Math.max(1,...decisions.map(item=>item.round));const build=(id:string,before:number):Node=>{const decision=decisions.filter(item=>item.winner_id===id&&item.round<before).sort((a,b)=>b.round-a.round)[0];return decision?{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}:{id,name:names[id]||id,round:0,children:[],x:0,y:0};};return build(result.champion_id||decisions.at(-1)?.winner_id||'',max+1);}
|
|
function selectTree(tree:Node,view:ViewId):Node{if(view==='left'&&tree.children[0])return tree.children[0];if(view==='right'&&tree.children[1])return tree.children[1];const levels:Partial<Record<ViewId,number>>={top16:3,top8:2,final:1},level=levels[view];if(!level)return tree;const min=Math.max(0,tree.round-level),prune=(node:Node):Node=>node.round<=min?{...node,children:[]}:{...node,children:node.children.map(prune)};return prune(tree);}
|
|
function drawSingle(ctx:CanvasRenderingContext2D,tree:Node,box:Box,theme:Theme):void{const leaves:Node[]=[];collect(tree,leaves);const min=Math.min(...leaves.map(n=>n.round)),max=Math.max(tree.round,min+1);leafY(leaves,box.y+48,box.y+box.height-38);innerY(tree);walk(tree,n=>{n.x=box.x+22+(n.round-min)/(max-min)*(box.width-154);});ctx.fillStyle=theme.muted;ctx.font='600 12px system-ui';for(let round=min;round<=max;round+=1){const x=box.x+22+(round-min)/(max-min)*(box.width-154);ctx.fillText(round===min?'候选':round===max?'冠军':`第 ${round} 轮`,x,box.y+17);}edges(ctx,tree,theme);nodes(ctx,tree,theme,122,'left');}
|
|
function drawSymmetric(ctx:CanvasRenderingContext2D,tree:Node,box:Box,theme:Theme):void{const[left,right]=tree.children;if(!left||!right)return;const middle=box.x+box.width/2,side=box.width/2-46,ll:Node[]=[],rl:Node[]=[];collect(left,ll);collect(right,rl);leafY(ll,box.y+48,box.y+box.height-38);leafY(rl,box.y+48,box.y+box.height-38);innerY(left);innerY(right);tree.y=(left.y+right.y)/2;tree.x=middle;const depth=Math.max(1,tree.round);walk(left,n=>{n.x=middle-32-(tree.round-n.round)/depth*side;});walk(right,n=>{n.x=middle+32+(tree.round-n.round)/depth*side;});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';edges(ctx,tree,theme);nodes(ctx,tree,theme,76,'center');}
|
|
function edges(ctx:CanvasRenderingContext2D,tree:Node,theme:Theme):void{ctx.strokeStyle=theme.grid;ctx.lineWidth=1.4;walk(tree,parent=>parent.children.forEach(child=>{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 nodes(ctx:CanvasRenderingContext2D,tree:Node,theme:Theme,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.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);ctx.textAlign='left';ctx.textBaseline='alphabetic';});}
|
|
function leafY(leaves:Node[],start:number,end:number):void{const gap=(end-start)/Math.max(1,leaves.length-1);leaves.forEach((n,i)=>{n.y=leaves.length===1?(start+end)/2:start+i*gap;});}function innerY(node:Node):number{if(!node.children.length)return node.y;node.children.forEach(innerY);node.y=node.children.reduce((sum,n)=>sum+n.y,0)/node.children.length;return node.y;}function collect(node:Node,out:Node[]):void{if(!node.children.length)out.push(node);else node.children.forEach(n=>collect(n,out));}function walk(node:Node,visit:(node:Node)=>void):void{visit(node);node.children.forEach(n=>walk(n,visit));}function clip(text:string,n:number):string{return text.length>n?`${text.slice(0,n-1)}…`:text;}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);}
|