diff options
Diffstat (limited to 'scripts/theme-studio/palette-generator-core.js')
| -rw-r--r-- | scripts/theme-studio/palette-generator-core.js | 263 |
1 files changed, 263 insertions, 0 deletions
diff --git a/scripts/theme-studio/palette-generator-core.js b/scripts/theme-studio/palette-generator-core.js new file mode 100644 index 000000000..6ad2bf44f --- /dev/null +++ b/scripts/theme-studio/palette-generator-core.js @@ -0,0 +1,263 @@ +// Pure palette-generator planner. It depends on the shared palette-column model +// from app-core.js, but owns candidate hue selection, naming, contrast filtering, +// and conversion from preview columns to palette entries. +import { normHex } from './app-util.js'; +import { oklch2hex, contrast, deltaE, oklchOf, isPureEndpointHex } from './colormath.js'; +import { columnsFromPalette } from './app-core.js'; + +function generatedExistingNames(palette){ + return new Set((palette||[]).map(p=>(p&&p[1]||'').toLowerCase()).filter(Boolean)); +} +const DEFAULT_COLOR_NAMES=[['black','#000000'],['white','#ffffff'],['red','#ff0000'],['green','#008000'],['blue','#0000ff'],['yellow','#ffff00'],['cyan','#00ffff'],['magenta','#ff00ff'],['gray','#808080']]; +function nearestColorName(hex,colorNames){ + const h=typeof hex==='string'?normHex(hex):null; + if(!h)return 'generated'; + let best=(colorNames&&colorNames[0]&&colorNames[0][0])||DEFAULT_COLOR_NAMES[0][0],bd=Infinity; + for(const [name,nhex] of (colorNames&&colorNames.length?colorNames:DEFAULT_COLOR_NAMES)){const d=deltaE(h,nhex);if(d<bd){bd=d;best=name;}} + return best; +} +function uniqueGeneratedName(base,used){ + let name=base||'generated',i=2; + if(!used.has(name.toLowerCase())){used.add(name.toLowerCase());return name;} + while(used.has((name+'-alt'+i).toLowerCase()))i++; + const out=name+'-alt'+i;used.add(out.toLowerCase());return out; +} +function hueOfHex(hex,fallback){ + const h=typeof hex==='string'?normHex(hex):null; + if(!h)return fallback; + const lch=oklchOf(h); + return Number.isFinite(lch.H)?lch.H:fallback; +} +function generatorSourceHue(palette,ground,cfg){ + const fallback=((cfg&&typeof cfg.baseHue==='number'&&isFinite(cfg.baseHue))?cfg.baseHue:250)%360; + if(cfg&&cfg.sourceMode==='palette'){const hs=paletteBaseHues(palette,ground);return hs.length?hs[0]:(fallback+360)%360;} + if(cfg&&cfg.sourceMode==='selected'&&typeof cfg.selectedHex==='string'&&normHex(cfg.selectedHex))return hueOfHex(cfg.selectedHex,fallback); + const bg=hueOfHex(ground&&ground.bg,fallback),fg=hueOfHex(ground&&ground.fg,fallback); + if(Math.abs(bg-fallback)>0.001||Math.abs(fg-fallback)>0.001)return ((bg+fg)/2+360)%360; + return (fallback+360)%360; +} +function generatorHues(baseHue,scheme,count,rng){ + const n=Math.max(1,Math.min(12,Math.round(count||8))), b=((baseHue%360)+360)%360; + if(scheme==='random'){ + const rnd=typeof rng==='function'?rng:Math.random; + return Array.from({length:n},()=>Math.floor(rnd()*360)); + } + if(scheme==='analogous'){ + const spread=Math.min(120,Math.max(30,n*14)), start=b-spread/2; + return Array.from({length:n},(_,i)=>(start+(n===1?0:(spread*i)/(n-1))+360)%360); + } + if(scheme==='triadic'){ + const offsets=[0,120,240,30,150,270,60,180,300,90,210,330]; + return offsets.slice(0,n).map(o=>(b+o)%360); + } + if(scheme==='manual')return Array.from({length:n},(_,i)=>(b+(i*360)/n)%360); + return Array.from({length:n},(_,i)=>(b+(i*360)/n)%360); +} +function generatorChroma(mode){ + return mode==='subdued'?0.055:mode==='vivid'?0.13:0.085; +} +function generatorTarget(mode){return mode==='aaa'?7:mode==='none'?0:4.5;} +function jitterHue(h,rng,spread){ + const rnd=typeof rng==='function'?rng:Math.random; + return (h+(rnd()*2-1)*spread+360)%360; +} +function paletteBaseHues(palette,ground){ + const cols=columnsFromPalette(palette||[],ground||{}).columns; + return cols.map(c=>hueOfHex(c.base,0)).filter(Number.isFinite); +} +function paletteBaseHexes(palette,ground){ + return columnsFromPalette(palette||[],ground||{}).columns.map(c=>normHex(c.base)).filter(Boolean); +} +function sourceAnchorHues(palette,ground,cfg,baseHue){ + const mode=cfg&&cfg.sourceMode||'bg-fg'; + if(mode==='none')return []; + if(mode==='palette')return paletteBaseHues(palette,ground); + if(mode==='selected'&&typeof (cfg&&cfg.selectedHex)==='string'&&normHex(cfg.selectedHex))return [hueOfHex(cfg.selectedHex,baseHue)]; + const anchors=[]; + if(ground&&ground.bg)anchors.push(hueOfHex(ground.bg,baseHue)); + if(ground&&ground.fg)anchors.push(hueOfHex(ground.fg,baseHue)); + return anchors.filter(Number.isFinite); +} +function sourceAnchorHexes(palette,ground,cfg){ + const mode=cfg&&cfg.sourceMode||'bg-fg'; + if(mode==='none')return []; + if(mode==='palette')return paletteBaseHexes(palette,ground); + if(mode==='selected'&&typeof (cfg&&cfg.selectedHex)==='string'&&normHex(cfg.selectedHex))return [normHex(cfg.selectedHex)]; + return [ground&&ground.bg,ground&&ground.fg].map(h=>typeof h==='string'?normHex(h):null).filter(Boolean); +} +function bridgeHues(anchors,count,rng){ + if(anchors.length<2)return generatorHues(anchors[0]||250,'random',count,rng); + const sorted=[...anchors].sort((a,b)=>a-b),pairs=[]; + for(let i=0;i<sorted.length;i++){ + const a=sorted[i],b=sorted[(i+1)%sorted.length]+(i===sorted.length-1?360:0); + pairs.push(((a+b)/2)%360); + } + return Array.from({length:count},(_,i)=>jitterHue(pairs[i%pairs.length],rng,10)); +} +function repeatOffsets(base,offsets,count){ + return Array.from({length:count},(_,i)=>(base+offsets[i%offsets.length]+360)%360); +} +function harmonyHues(intent,src,baseHue,count,rng){ + const b=src&&src.length?src[0]:baseHue, n=Math.max(1,Math.min(12,Math.round(count||8))); + if(intent==='complementary')return repeatOffsets(b,[180],n); + if(intent==='analogous')return repeatOffsets(b,[-30,30,-60,60,0],n); + if(intent==='split-complementary')return repeatOffsets(b,[150,210,0],n); + if(intent==='triadic')return repeatOffsets(b,[0,120,240],n); + if(intent==='tetradic')return repeatOffsets(b,[0,60,180,240],n); + if(intent==='square')return repeatOffsets(b,[0,90,180,270],n); + if(intent==='monochromatic')return Array.from({length:n},()=>jitterHue(b,rng,3)); + if(intent==='rainbow')return Array.from({length:n},(_,i)=>(b+(i*360)/n)%360); + return null; +} +function intentHues(intent,anchors,baseHue,count,rng){ + const n=Math.max(1,Math.min(12,Math.round(count||8))), src=anchors&&anchors.length?anchors:[baseHue]; + const harmony=harmonyHues(intent,src,baseHue,n,rng); + if(harmony)return harmony; + if(intent==='near-palette'||intent==='near-selected')return Array.from({length:n},(_,i)=>jitterHue(src[i%src.length],rng,18)); + if(intent==='fill-gaps')return generatorHues(src[0]||baseHue,'random',n,rng); + if(intent==='complements')return Array.from({length:n},(_,i)=>jitterHue((src[i%src.length]+180)%360,rng,18)); + if(intent==='bridges')return bridgeHues(src,n,rng); + return generatorHues(baseHue,'random',n,rng); +} +function vibeHueBias(hues,vibe,rng){ + const rnd=typeof rng==='function'?rng:Math.random; + const pick=bands=>bands[Math.floor(rnd()*bands.length)]; + if(vibe==='warm')return hues.map(()=>jitterHue(pick([12,28,44,58]),rng,14)); + if(vibe==='cool')return hues.map(()=>jitterHue(pick([170,195,220,250,278]),rng,16)); + if(vibe==='earthy')return hues.map(h=>jitterHue([28,42,58,82,112].reduce((a,b)=>Math.abs(b-h)<Math.abs(a-h)?b:a,42),rng,12)); + return hues; +} +function candidateLightnesses(bgHex,vibe){ + const bgL=typeof bgHex==='string'&&normHex(bgHex)?oklchOf(bgHex).L:0; + if(vibe==='pastel')return bgL>0.55?[0.74,0.68,0.80,0.62,0.86,0.56,0.50]:[0.82,0.88,0.76,0.92,0.70,0.64]; + if(vibe==='deep'||vibe==='jewel')return bgL>0.55?[0.30,0.24,0.36,0.18,0.42,0.48]:[0.56,0.62,0.50,0.68,0.44,0.74]; + return bgL>0.55 + ? [0.34,0.28,0.40,0.22,0.46,0.16,0.52,0.10,0.58] + : [0.70,0.76,0.64,0.82,0.58,0.88,0.52,0.94,0.46]; +} +function randomChroma(rng){ + const rnd=typeof rng==='function'?rng:Math.random; + return 0.10+rnd()*0.09; +} +function vibeChroma(vibe,rng){ + const rnd=typeof rng==='function'?rng:Math.random; + // [base, range]: chroma is base + rnd()*range. Table, not an if-ladder, so a + // vibe is one row to read or tune. The default covers unknown vibes. + const t={muted:[0.045,0.035],pastel:[0.035,0.045],deep:[0.085,0.055], + jewel:[0.12,0.075],earthy:[0.055,0.04],warm:[0.08,0.06], + cool:[0.08,0.06],neon:[0.18,0.09],strange:[0.145,0.095], + balanced:[0.075,0.045]}; + const [base,range]=t[vibe]||[0.12,0.07]; + return base+rnd()*range; +} +function accentCandidateForHue(hue,ground,cfg){ + const C=cfg&&cfg.vibe?vibeChroma(cfg.vibe,cfg.rng):(cfg&&cfg.scheme==='random'?randomChroma(cfg.rng):generatorChroma(cfg&&cfg.chromaMode)), target=generatorTarget(cfg&&cfg.contrastMode), bg=ground&&ground.bg; + let best=null; + for(const L of candidateLightnesses(bg,cfg&&cfg.vibe)){ + const c=oklch2hex(L,C,hue), r=bg?contrast(c.hex,bg):Infinity; + const item={hex:c.hex,L,C,hue,contrast:r,clamped:c.clamped}; + if(!best||r>best.contrast)best=item; + if(r>=target&&!isPureEndpointHex(c.hex))return item; + } + return best&&best.contrast>=target&&!isPureEndpointHex(best.hex)?best:null; +} +function candidateForHueLightness(hue,L,C,ground,cfg){ + const target=generatorTarget(cfg&&cfg.contrastMode),bg=ground&&ground.bg,c=oklch2hex(L,C,hue),r=bg?contrast(c.hex,bg):Infinity; + return r>=target&&!isPureEndpointHex(c.hex)?{hex:c.hex,L,C,hue,contrast:r,clamped:c.clamped}:null; +} +function minDistanceToSet(hex,set){ + return set.length?Math.min(...set.map(h=>deltaE(hex,h))):Infinity; +} +function hueDistance(a,b){return Math.abs((((a-b+540)%360)-180));} +function anchorHueSet(hexes){ + return hexes.map(hex=>oklchOf(hex)).filter(lch=>lch.C>0.025&&Number.isFinite(lch.H)).map(lch=>lch.H); +} +function minHueDistance(hue,hues){ + return hues.length?Math.min(...hues.map(h=>hueDistance(hue,h))):180; +} +function perceptualGapCandidates(palette,ground,cfg,sourceMode,baseHue,count,scheme,intent,hueAware){ + const anchors=sourceAnchorHexes(palette,ground,Object.assign({},cfg,{sourceMode})); + if(anchors.length<2){ + return intentHues('fill-gaps',sourceAnchorHues(palette,ground,Object.assign({},cfg,{sourceMode}),baseHue),baseHue,count,cfg.rng) + .map(hue=>accentCandidateForHue(hue,ground,Object.assign({},cfg,{scheme,intent}))).filter(Boolean); + } + const C=cfg&&cfg.vibe?vibeChroma(cfg.vibe,cfg.rng):(scheme==='random'?randomChroma(cfg.rng):generatorChroma(cfg&&cfg.chromaMode)); + const hueStep=10,hueOffset=(typeof cfg.rng==='function'?cfg.rng():Math.random())*hueStep; + const pool=[],seen=new Set(); + for(let hue=hueOffset;hue<360;hue+=hueStep){ + for(const L of candidateLightnesses(ground&&ground.bg,cfg&&cfg.vibe)){ + const cand=candidateForHueLightness(hue,L,C,ground,cfg); + if(!cand)continue; + const key=cand.hex.toLowerCase(); + if(seen.has(key))continue; + seen.add(key);pool.push(cand); + } + } + const picked=[],occupied=[...anchors]; + const occupiedHues=anchorHueSet(anchors); + while(picked.length<count&&pool.length){ + let bestI=-1,bestScore=-1,bestContrast=-1; + for(let i=0;i<pool.length;i++){ + const cand=pool[i],perceptual=minDistanceToSet(cand.hex,occupied); + const hueBonus=hueAware?0.10*(minHueDistance(cand.hue,occupiedHues)/180):0; + const score=perceptual+hueBonus; + if(score>bestScore+1e-9||(Math.abs(score-bestScore)<1e-9&&cand.contrast>bestContrast)){ + bestI=i;bestScore=score;bestContrast=cand.contrast; + } + } + const cand=pool.splice(bestI,1)[0]; + picked.push(cand);occupied.push(cand.hex);occupiedHues.push(cand.hue); + } + return picked; +} +function generatedMembers(baseHex,baseName,spanCount,columnId){ + const hex=typeof baseHex==='string'?normHex(baseHex):null; + return hex?[{hex,name:baseName,offset:0,clamped:false,columnId}]:[]; +} +function planPaletteGenerator(palette,ground,config){ + const cfg=config||{}; + const requestedSource=cfg.sourceMode||'bg-fg', resolvedSource=requestedSource==='selected' + ? (typeof cfg.selectedHex==='string'&&normHex(cfg.selectedHex)?'selected':'bg-fg') + : requestedSource; + const sourceMode=['selected','palette','none','bg-fg'].includes(resolvedSource)?resolvedSource:'bg-fg'; + const scheme=cfg.scheme||'random', intent=cfg.intent||(cfg.scheme&&cfg.scheme!=='random'?'scheme':'random'), count=Math.max(1,Math.min(12,Math.round(cfg.accentCount??5))); + const spanCount=Math.max(0,Math.min(8,Math.round(cfg.spanCount??2))); + const used=generatedExistingNames(palette), baseHue=generatorSourceHue(palette,ground,Object.assign({},cfg,{sourceMode})); + const anchors=sourceAnchorHues(palette,ground,Object.assign({},cfg,{sourceMode}),baseHue); + const columns=[], rejected=[]; + const candidates=(intent==='fill-gaps'||intent==='fill-hue-gaps') + ? perceptualGapCandidates(palette,ground,cfg,sourceMode,baseHue,count,scheme,intent,intent==='fill-hue-gaps').map(cand=>({cand,hue:cand&&cand.hue})) + : vibeHueBias(intent&&intent!=='manual'&&intent!=='scheme'?intentHues(intent,anchors,baseHue,count,cfg.rng):generatorHues(baseHue,scheme,count,cfg.rng),cfg.vibe,cfg.rng) + .map(hue=>({hue,cand:accentCandidateForHue(hue,ground,Object.assign({},cfg,{scheme,intent}))})); + for(const {cand,hue} of candidates){ + if(!cand){rejected.push({hue,reason:'contrast'});continue;} + const name=uniqueGeneratedName(nearestColorName(cand.hex,cfg.colorNames),used), columnId=name; + const members=generatedMembers(cand.hex,name,spanCount,columnId); + columns.push({name,columnId,baseHex:cand.hex,L:cand.L,C:cand.C,hue:cand.hue,contrast:cand.contrast,clamped:cand.clamped,members}); + } + const contrasts=columns.map(c=>c.contrast).filter(Number.isFinite); + return { + sourceMode, + scheme, + intent, + vibe: cfg.vibe||null, + baseHue, + accentCount:count, + spanCount, + columns, + rejected, + summary:{ + generated:columns.length, + rejected:rejected.length, + clamped:columns.reduce((n,c)=>n+(c.clamped?1:0)+c.members.filter(m=>m.clamped).length,0), + minContrast:contrasts.length?Math.min(...contrasts):null, + }, + }; +} +function entriesForGeneratedColumn(column){ + if(!column||!Array.isArray(column.members))return []; + const columnId=column.columnId||column.name||'generated'; + return column.members.map(m=>[m.hex,m.name,columnId]); +} + +export { planPaletteGenerator, entriesForGeneratedColumn }; |
