aboutsummaryrefslogtreecommitdiff
path: root/scripts/theme-studio/palette-generator-core.js
blob: 6bce0d59a8f5bae96332d5f766c5a3e225f6036e (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
// 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, srgb2oklab, oklab2oklch, contrast, deltaE } from './colormath.js';
import { columnsFromPalette } from './app-core.js';

function oklchOf(hex){return oklab2oklch(srgb2oklab(hex));}
function isPureEndpointHex(hex){const h=(hex||'').toLowerCase();return h==='#ffffff'||h==='#000000';}
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=oklab2oklch(srgb2oklab(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;
  if(vibe==='muted')return 0.045+rnd()*0.035;
  if(vibe==='pastel')return 0.035+rnd()*0.045;
  if(vibe==='deep')return 0.085+rnd()*0.055;
  if(vibe==='jewel')return 0.12+rnd()*0.075;
  if(vibe==='earthy')return 0.055+rnd()*0.04;
  if(vibe==='warm'||vibe==='cool')return 0.08+rnd()*0.06;
  if(vibe==='neon')return 0.18+rnd()*0.09;
  if(vibe==='strange')return 0.145+rnd()*0.095;
  if(vibe==='balanced')return 0.075+rnd()*0.045;
  return 0.12+rnd()*0.07;
}
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 };