Files
WLED/wled00/data/cpal/cpal.htm
Damian Schneider 2c4ed4249d New custom palettes editor (#5010)
* full refactoring, added live preview, better minifying in cdata.js
* update main UI buttons, support for gaps in cpal files, cpal UI cleanup
* fixed some layout issues, added un-ordered cpal deletion
* changed to tab indentation, paste button border color now holds stored color
* fix preview to work properly and some other fixes in UI
* always unfreeze
* new approach to loading iro.js, add harmonic random palette, many fixes.
* decoupling iro.j, update UI of cpal.htm
- load iro.js sequentially
- no parallel requests in cpal.htm
- update UI buttons
- fix showing sequential loading of palettes (using opacity)
- better UX for mobile (larger markers, larger editor)
- various fixes
* small change to buttons
* load iro.js dynamically, remove iro.js from index.htm, revert changes to cdata.js
* improved visibility for very dark/black palettes and markers
2026-01-30 20:35:15 +01:00

908 lines
27 KiB
HTML

<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
<title>WLED Palette Editor</title>
<!--* <link rel="stylesheet" href="style.css">
<script src="common.js"></script>
<script src="iro.js"></script>
<link rel="icon" href="data:,"> *-->
</head>
<body>
<div class="ctr">
<header><h1>WLED Palette Editor</h1></header>
<div id="pickerWrap">
<div id="picker"></div>
<div class="rgbi">
<label>R</label><input type="number" id="rInput" min="0" max="255" value="0">
<label>G</label><input type="number" id="gInput" min="0" max="255" value="0">
<label>B</label><input type="number" id="bInput" min="0" max="255" value="0">
</div>
</div>
<div class="bar">
<div class="bar">
<button id="btnNew" class="sml">Generate</button>
<button id="btnDist" class="sml">Distribute</button>
</div>
<div class="bar">
<button id="btnCopy" class="sml">Copy</button>
<button id="btnPaste" class="sml">Paste</button>
<button id="btnDel" class="sml">Delete</button>
</div>
</div>
<div id="editor">
<div id="gradWrap"><div id="grad"></div></div>
</div>
<label>
<input type="checkbox" id="chkPreview">
<span>preview on selected segments</span>
</label>
<div class="tbl" id="empty"></div>
<section>
<span id="memWarn">Warning: Adding many custom palettes might cause stability issues, create <a href="/settings/sec#backup">backups</a></span>
<div class="tbl"><div id="custom" class="lst"></div></div>
</section>
<section><div id="allCats" class="cats"></div></section>
<div>
<button id="btnFetchExt" class="btn">Download more palettes</button>
</div>
<div style="margin-bottom: 24px;">
<button class="btn" onclick="window.location.href = getURL('/');">Back to the controls</button>
</div>
<div style="font-size:12px;color:#666;">by @dedehai</div>
</div>
<script>
// State
let gr, wr, rc, w = 0, sc = 1, pl = 16;
let sel = null, pk = null;
let cpc = 0, cpm = 10, pnm = [], cpal = [], spal = []; // custom palette count, custom palette max, names, custom palettes, static palettes
let prvTmr = null, prvEn = false, palCache = [];
let isDragging = false;
let copyColor = '#000';
let ws = null;
let maxCol; // max colors to send out in one chunk, ESP8266 is limited to ~50 (500 bytes), ESP32 can do ~128 (1340 bytes)
// load external resources in sequence to avoid 503 errors if heap is low, repeats indefinitely until loaded
// load CSS
const l1 = document.createElement('link');
l1.rel = 'stylesheet';
l1.href = 'style.css';
l1.onload = () => {
// load iro.js
const l2 = document.createElement('script');
l2.src = 'iro.js';
l2.onload = () => {
// load common.js
const l3 = document.createElement('script');
l3.src = 'common.js';
// initialize when all documents are loaded
l3.onload = () => document.readyState === 'complete' ? init() : window.addEventListener('load', init);
l3.onerror = () => setTimeout(() => document.head.appendChild(l3), 100);
document.head.appendChild(l3);
};
l2.onerror = () => setTimeout(() => document.head.appendChild(l2), 100);
document.head.appendChild(l2);
};
l1.onerror = () => setTimeout(() => document.head.appendChild(l1), 100);
document.head.appendChild(l1);
// main init function, called when all resources are loaded
function init() {
// init iro color picker
pk = new iro.ColorPicker('#picker', {
width: 240,
wheelAngle: 270,
wheelDirection: 'clockwise',
layout: [
{component: iro.ui.Wheel},
{component: iro.ui.Slider, options: {sliderType: 'value'}}
]
});
// update color when picker changes
pk.on('color:change', (c) => { setCol(c.hexString); updRGB(c.hexString); });
const updFromRGB = () => {
const r = clamp(parseInt(gId('rInput').value) || 0, 0, 255);
const g = clamp(parseInt(gId('gInput').value) || 0, 0, 255);
const b = clamp(parseInt(gId('bInput').value) || 0, 0, 255);
pk.color.rgb = {r, g, b};
};
gId('rInput').addEventListener('change', updFromRGB);
gId('gInput').addEventListener('change', updFromRGB);
gId('bInput').addEventListener('change', updFromRGB);
gId('btnDist').onclick = dist;
gId('btnDel').onclick = deleteMarker;
gId('btnNew').onclick = rndPal;
gId('btnCopy').onclick = () => copypasteColor(0);
gId('btnPaste').onclick = () => copypasteColor(1);
gId('btnFetchExt').onclick = fetchExt;
gId('chkPreview').addEventListener('change', (e) => {
prvEn = e.target.checked;
if (prvEn) applyLED();
else requestJson({seg:{frz:false}});
});
gr = gId('grad');
wr = gId('gradWrap');
wr.addEventListener('pointerdown', (e) => {
const m = e.target.closest('.mk');
if (m) {
isDragging = false;
selMk(m);
if (m.dataset.lock === '1') return;
e.preventDefault();
const startT = +m.dataset.t;
const mv = (ev) => {
isDragging = true;
const minT = (startT === 0) ? 0 : 1;
const maxT = (startT === 255) ? 255 : 254;
const x = clamp(Math.round((ev.clientX - (rc.left + pl)) / sc), minT, maxT);
m.dataset.t = x; m.style.left = (pl + (x * sc)) + 'px'; draw();
};
d.addEventListener('pointermove', mv);
d.addEventListener('pointerup', () => d.removeEventListener('pointermove', mv), {once:1});
} else if (e.target === wr || e.target === gr) {
const t = clamp(Math.round((e.clientX - (rc.left + pl)) / sc), 1, 254);
if (canAdd() && t !== 0 && t !== 255) {
addMk(t, '#' + (palCache[t] || '000'));
// trigger drag immediately
const newM = sel;
if (newM) {
e.preventDefault();
const mv = (ev) => {
isDragging = true;
const x = clamp(Math.round((ev.clientX - (rc.left + pl)) / sc), 1, 254);
newM.dataset.t = x; newM.style.left = (pl + (x * sc)) + 'px'; draw();
};
d.addEventListener('pointermove', mv);
d.addEventListener('pointerup', () => d.removeEventListener('pointermove', mv), {once:1});
}
}
}
});
// keyboard nudge for selected marker (note: uses about 100 bytes of code)
d.addEventListener('keydown', (e) => {
if (!sel || sel.dataset.lock === '1') return;
let t = +sel.dataset.t;
if (e.key === 'ArrowLeft') t = Math.max(1, t - (e.shiftKey ? 8 : 1));
else if (e.key === 'ArrowRight') t = Math.min(254, t + (e.shiftKey ? 8 : 1));
else return;
sel.dataset.t = t; sel.style.left = (pl + (t * sc)) + 'px'; draw(); e.preventDefault();
});
getLoc(); // set base URL
ws = connectWs();
let extGrp = {};
const cached = tryCache();
if (!cached) gId('btnFetchExt').style.display = '';
// fetch info + palnames
Promise.all([fetch(getURL('/json/info')).then(r=>r.json()), fetch(getURL('/json/pal')).then(r=>r.json())])
.then(([inf, nm]) => {
pnm = nm; cpc = inf.cpalcount; cpm = inf.cpalmax;
fetchC(cpc);
if (inf.arch === 'esp8266') maxCol = 50; // TODO: test if this works.
else maxCol = 128;
// Extract WLED palettes from wledPalx cache from main UI
let cache; try { cache = JSON.parse(localStorage.getItem('wledPalx')); } catch {}
if (cache?.p) {
for (const k in cache.p) {
if (+k > 255 - cpm || !Array.isArray(cache.p[k])) { delete cache.p[k]; continue; }
const a = cache.p[k];
if (a[a.length-1][0] !== 255) a.push([255, ...a[a.length-1].slice(1)]);
cache.p[k].name = pnm[k];
}
spal = Object.entries(cache.p).filter(([k]) => +k >= 8).map(([k, v]) => ({[k]:v.flat(), name:v.name}));
}
try { const raw = localStorage.getItem('wledCptCityJson'); if (raw) extGrp = grpExt(JSON.parse(raw)); } catch(e) {}
bldCat(extGrp);
})
.catch(()=>{});
recalc();
rndPal();
}
// Utils
function clamp(v, a, b) { return Math.max(a, Math.min(b, v)); }
function rndHex() { return '#' + (Math.random() * 0xFFFFFF << 0).toString(16).padStart(6, '0'); }
function h2rgb(h) { h = h.replace('#', ''); const n = parseInt(h, 16); return [(n>>16)&255, (n>>8)&255, n&255]; }
function rgb2h(r, g, b) { return ((1<<24) + (r<<16) + (g<<8) + b).toString(16).slice(1); }
function lerp(a, b, t) { return a + (b-a)*t; }
function updRGB(h) { const [r,g,b] = h2rgb(h); gId('rInput').value=r; gId('gInput').value=g; gId('bInput').value=b; }
function isEmpty(a) { return Array.isArray(a) && a.length === 1 && a[0] === 255; }
// copy / paste color
function copypasteColor(paste = false) {
if (!sel) return;
if (paste) {
setCol(copyColor);
} else {
copyColor = sel.dataset.c;
gId('btnPaste').style.borderColor = copyColor;
}
}
// Geometry
function recalc() {
rc = wr.getBoundingClientRect();
const cs = getComputedStyle(wr);
pl = parseInt(cs.paddingLeft || 16, 10);
w = rc.width - pl - parseInt(cs.paddingRight || 16, 10);
sc = w / 255;
layout();
draw();
}
function layout() {
[...gr.querySelectorAll('.mk')].forEach(m => {
m.style.left = (pl + (+m.dataset.t * sc)) + 'px';
});
}
function stops() {
return [...gr.querySelectorAll('.mk')]
.map(m => ({ t: +m.dataset.t, c: m.dataset.c, lock: m.dataset.lock === '1' }))
.sort((a, b) => a.t - b.t);
}
/* Build a 256-entry cache of palette RGB hex (without #). This is used for previewing and sending to LEDs */
function bldCache() {
const s = stops();
palCache = new Array(256);
if (!s.length) { palCache.fill('000000'); return; }
for (let t = 0; t <= 255; t++) {
if (t <= s[0].t) { palCache[t] = s[0].c.slice(1); continue; }
if (t >= s[s.length - 1].t) { palCache[t] = s[s.length - 1].c.slice(1); continue; }
for (let i = 0; i < s.length - 1; i++) {
const a = s[i], b = s[i+1];
if (t >= a.t && t <= b.t) {
const f = (b.t === a.t) ? 0 : ((t - a.t) / (b.t - a.t));
const [ar,ag,ab] = h2rgb(a.c), [br,bg,bb] = h2rgb(b.c);
palCache[t] = rgb2h(Math.round(lerp(ar,br,f)), Math.round(lerp(ag,bg,f)), Math.round(lerp(ab,bb,f)));
break;
}
}
}
}
function draw() {
const s = stops();
gr.style.background = 'linear-gradient(to right,' + s.map(x => x.c + ' ' + Math.round(x.t * sc) + 'px').join(',') + ')';
bldCache();
if (prvEn && !prvTmr) {
let d = (ws && ws.readyState == 1) ? 50 : 500; // slower updates if using HTTP to not overwhelm the ESP
prvTmr = setTimeout(() => { prvTmr = null; applyLED(); }, d);
}
}
// Markers
function selMk(m) {
if (sel) sel.classList.remove('sel');
sel = m || null;
if (sel) {
sel.classList.add('sel');
if (pk) { pk.color.hexString = sel.dataset.c; updRGB(sel.dataset.c); }
}
}
function canAdd() { return gr.querySelectorAll('.mk').length < 16; }
function addMk(t, c, lock) {
// keep start and end markers at 0/255 locked
if (t < 0 || t > 255 || !canAdd() || gr.querySelector('.mk[data-t="' + t + '"]')) return;
const m = cE('div');
m.className = 'mk';
m.dataset.t = t;
m.dataset.c = c || rndHex();
m.dataset.lock = lock ? '1' : '0';
m.style.left = (pl + (t * sc)) + 'px';
m.style.background = m.dataset.c;
gr.appendChild(m);
selMk(m);
draw();
}
function deleteMarker() {
if (!sel || sel.dataset.lock === '1') return;
sel.remove();
selMk(null);
draw();
}
function setCol(h) {
if (!sel) return;
sel.dataset.c = h;
sel.style.background = h;
draw();
}
function dist() {
const s = stops();
if (s.length < 3) return;
const inner = s.slice(1, -1), step = Math.round(255 / (inner.length + 1));
inner.forEach((p, i) => {
// find node corresponding to this pos (skips locked)
const m = [...gr.querySelectorAll('.mk')].find(x => +x.dataset.t === p.t && x.dataset.lock !== '1');
if (m) { m.dataset.t = step * (i + 1); m.style.left = (pl + (m.dataset.t * sc)) + 'px'; }
});
draw();
}
/*
function rndPal() {
gr.innerHTML = '';
addMk(0, rndHex(), 1);
const cnt = Math.floor(Math.random() * 3) + 2, pos = new Set();
while (pos.size < cnt) pos.add(Math.floor(Math.random() * 254) + 1);
[...pos].sort((a,b)=>a-b).forEach(t => addMk(t, rndHex()));
addMk(255, rndHex(), 1);
}*/
// convert hsl to hex using canvas
function hslToHex(h, s, l) {
let ctx = cE("canvas").getContext("2d");
ctx.fillStyle = `hsl(${h},${s}%,${l}%)`;
return ctx.fillStyle;
}
// random or harmonic palette (uses 100 bytes extra compared to simple random palette)
function rndPal() {
gr.innerHTML = '';
const mode = Math.floor(Math.random() * 3); // 33% chance for random, 67% for harmonic
const cnt = Math.floor(Math.random() * 4) + 1; // 1-4 colors (+ start/end)
let pos = new Set();
while (pos.size < cnt) pos.add(Math.floor(Math.random() * 254) + 1);
const markers = [0, ...[...pos].sort((a,b)=>a-b), 255];
let colors;
if (mode === 0) { // random
//console.log('random');
colors = markers.map(() => hslToHex(
Math.random() * 360,
Math.random() * 100,
Math.random() * 60 + 10
));
}
else { // harmonic triadic/tetradic
//console.log('harmonic');
const hcount = Math.random() < 0.5 ? 3 : 4;
const base = Math.random() * 360;
colors = markers.map((_, i) => hslToHex(
(base + 360 * (i % hcount) / hcount) % 360,
100,
Math.random() * 60 + 25 // chance for pastel colors
));
}
markers.forEach((t, i) =>
addMk(t, colors[i], (i === 0 || i === markers.length - 1) ? 1 : 0)
);
}
// Data
function toJSON() { return JSON.stringify({ palette: stops().flatMap(s => [s.t, s.c.slice(1)]) }); }
// convert wled palette array to CSS gradient string
function cssArr(a) {
let out = [];
for (let i = 0; i < a.length; i += 2) {
const t = a[i], v = a[i + 1];
out.push(typeof v === 'string' ? `#${v} ${t/255*100}%` : `rgba(${v},${a[i+2]},${a[i+3]},1) ${t/255*100}%`);
if (typeof v !== 'string') i += 2;
}
return 'linear-gradient(to right,' + out.join(',') + ')';
}
// accepts stops array: [pos, color, pos, color, ...] where color can be 'rrggbb' or [r,g,b]
function loadArr(a) {
gr.innerHTML = '';
for (let i = 0; i < a.length; i += 2) {
const t = a[i], v = a[i + 1];
const h = typeof v === 'string' ? '#' + v : '#' + ((v << 16) | (a[i + 2] << 8) | a[i + 3]).toString(16).padStart(6, '0');
if (typeof v !== 'string') i += 2;
addMk(t, h, (t === 0 || t === 255));
}
}
function normCat(k) { const n = (k || '').toLowerCase(); return n === 'themed' ? 'thematic' : n; }
// group external palettes into categories
function grpExt(d) {
const g = {colorful:[], thematic:[], pastel:[], striped:[], gradient:[], monochrome:[]};
const add = (p) => { const k = normCat(p.category || p.class); if (g[k]) g[k].push(p); };
if (d?.categories && isO(d.categories)) {
for (const [k, l] of Object.entries(d.categories)) {
const nk = normCat(k);
if (Array.isArray(l)) l.forEach(p => { if (g[nk]) g[nk].push(p); });
}
} else if (Array.isArray(d?.palettes)) d.palettes.forEach(add);
else if (Array.isArray(d)) d.forEach(add);
return g;
}
// UI
function mkPalItem(name, author, css, arr, save, remove) {
const itm = cE('div'), nam = cE('div'), pvw = cE('div'), prow = cE('div'), pv = cE('div');
itm.className = 'pal';
nam.className = 'nam';
nam.textContent = name;
if (author) {
const a = cE('span');
a.className = 'by';
a.textContent = ' by ' + author;
nam.appendChild(a);
}
pvw.style.alignItems = 'center';
pvw.style.flex = '1';
prow.className = 'prow';
pv.className = 'prv';
pv.style.background = css;
pv.title = 'Click to load';
pv.onclick = () => loadArr(arr);
pvw.append(nam, pv);
prow.appendChild(pvw);
if (save) prow.appendChild(save);
if (remove) prow.appendChild(remove);
itm.appendChild(prow);
return itm;
}
// Build the custom palette list (custom slots + empty slot handling)
// input: slots larger than "loaded" are set to 50% opacity to indicate they are not refreshed yet
function bldLst(loaded) {
const cd = gId('custom'), fc = d.createDocumentFragment();
cd.innerHTML = '';
let emptyslot = gId('empty');
emptyslot.innerHTML = '';
let foundEmpty = false;
cpal.forEach((p, i) => {
const sv = cE('button');
sv.className = 'sml';
sv.innerHTML = '&#8678;'; // alternative arrows: '&#8617;' '&#8592;'
sv.title = 'Save to slot ' + i;
sv.onclick = () => upload(i);
const rm = cE('button');
rm.className = 'sml';
rm.title = 'Delete palette';
rm.innerHTML = '&#10006;';
rm.onclick = () => { requestJson({rmcpal:i}); setTimeout(refr, 500); };
const name = isEmpty(p.palette) ? 'Empty slot' : 'Custom' + i;
const css = isEmpty(p.palette) ? '#666' : cssArr(p.palette);
const item = mkPalItem(name, null, css, p.palette, sv, rm);
const prv = item.querySelector('.prv');
prv.style.opacity = i > loaded ? 0.5 : 1; // set opacity of palette preview
// disable loading for empty palettes
if (isEmpty(p.palette) && !foundEmpty) {
foundEmpty = true;
prv.style.cursor = 'not-allowed';
prv.onclick = null;
item.querySelector('.nam').style.transform = 'translateY(100%)'; // move name down to preview center
emptyslot.appendChild(item);
} else if (!isEmpty(p.palette)) {
fc.appendChild(item);
}
});
cd.appendChild(fc);
gId('memWarn').style.display = (cpc > 10) ? 'block' : 'none'; // show warning if 10 or more custom palettes
}
// build categories UI from grouped palettes
function bldCat(extGrp) {
const h = gId('allCats');
if (!h) return;
h.innerHTML = '';
const cats = [
{k:'wled', lbl:'WLED Palettes', items:spal},
{k:'colorful', lbl:'Colorful', items:extGrp.colorful||[]},
{k:'thematic', lbl:'Thematic', items:extGrp.thematic||[]},
{k:'pastel', lbl:'Pastel', items:extGrp.pastel||[]},
{k:'striped', lbl:'Striped', items:extGrp.striped||[]},
{k:'gradient', lbl:'Gradient', items:extGrp.gradient||[]},
{k:'monochrome', lbl:'Monochrome', items:extGrp.monochrome||[]}
];
cats.forEach(({k, lbl, items}) => {
if (!items.length && k !== 'wled') return; // skip external categories if empty
const det = cE('details'), sum = cE('summary'), body = cE('div');
det.className = 'cat';
sum.innerHTML = `&#9656; ${lbl} (${items.length})`;
det.appendChild(sum);
body.className = 'cbdy';
items.forEach(p => {
if (k === 'wled') {
const key = Object.keys(p)[0];
body.appendChild(mkPalItem(p.name || ' ', null, cssArr(p[key]), p[key]));
} else {
body.appendChild(mkPalItem(p.name || ' ', p.author, cssArr(p.colors), p.colors));
}
});
det.appendChild(body);
h.appendChild(det);
det.addEventListener('toggle', () => {
sum.innerHTML = det.open ? `&#9662; ${lbl} (${items.length})` : `&#9656; ${lbl} (${items.length})`;
});
});
}
// Network
function upload(i) {
const b = new Blob([toJSON()], {type:'application/json'});
const fakeFileObj = { files: [b] };
uploadFile(fakeFileObj, '/palette' + i + '.json');
localStorage.removeItem('wledPalx'); // invalidate main UI cache
setTimeout(refr, 300);
}
async function refr() {
try {
const inf = await fetch(getURL('/json/info'), {cache:'no-store'}).then(r=>r.json());
cpc = inf.cpalcount; cpm = inf.cpalmax;
await fetchC(cpc);
} catch(e) {}
}
// load custom palettes, loads from cache and updates in the background as palettes are coming in
async function fetchC(n) {
try {
const cached = localStorage.getItem('wledCustomPal');
if (cached) cpal = JSON.parse(cached);
if (cpal.length > n) cpal = cpal.slice(0, n);
} catch(e) {}
while (cpal.length < n) cpal.push({palette:[255]});
if (cpal.length < cpm) cpal.push({palette:[255]});
bldLst(0); // initial build, all previews at 50% opacity
// fetch and replace palettes as they load
for (let i = 0; i < n; i++) {
try {
const res = await fetch(getURL('/palette' + i + '.json'), {cache:'no-store'});
if (res.ok) {
cpal[i] = await res.json();
bldLst(i); // rebuild list, set opacity of this (and previous slots) to 100%
} else {
cpal[i] = {palette:[255]};
}
} catch(e) {
cpal[i] = {palette:[255]};
}
}
try {
localStorage.setItem('wledCustomPal', JSON.stringify(cpal));
} catch(e) {}
}
function tryCache() {
try {
const raw = localStorage.getItem('wledCptCityJson');
if (!raw) return false;
bldCat(grpExt(JSON.parse(raw)));
gId('btnFetchExt').style.display = 'none';
return true;
} catch (e) { localStorage.removeItem('wledCptCityJson'); return false; }
}
// download external palettes, these were hand picked from cpt-city (http://seaviewsensing.com/pub/cpt-city/)
// all palettes are licensed "free to use", converted to WLED JSON format by @dedehai
function fetchExt() {
fetch('https://dedehai.github.io/cpt_city_selection.json')
.then(r => { if (!r.ok) throw new Error(); return r.json(); })
.then(data => {
try { localStorage.setItem('wledCptCityJson', JSON.stringify(data)); } catch(e) {}
bldCat(grpExt(data));
gId('btnFetchExt').style.display = 'none';
})
.catch(() => { alert('Download failed'); });
}
// connect to WebSocket, use parent WS or open new
function connectWs(onOpen) {
try {
if (top.window.ws && top.window.ws.readyState === WebSocket.OPEN) {
if (onOpen) onOpen();
return top.window.ws;
}
} catch (e) {}
let url = loc ? getURL('/ws').replace("http","ws") : "ws://"+window.location.hostname+"/ws";
let w = new WebSocket(url);
w.binaryType = "arraybuffer";
if (onOpen) w.addEventListener('open', onOpen);
w.addEventListener('close', () => { ws = null; });
w.addEventListener('error', () => { ws = null; });
return w;
}
async function requestJson(cmd)
{
if (ws && ws.readyState == 1) {
try {
ws.send(JSON.stringify(cmd));
return 1;
} catch (e) {}
}
if (!window._httpQueue) {
window._httpQueue = [];
window._httpRun = 0;
}
if (_httpQueue.length >= 5) {
return Promise.resolve(-1); // reject if too many queued requests
}
return new Promise(resolve => {
_httpQueue.push({ cmd, resolve });
(async function run() {
if (_httpRun) return;
_httpRun = 1;
while (_httpQueue.length) {
let q = _httpQueue.shift();
try {
await fetch(getURL('/json'), {
method: 'post',
body: JSON.stringify(q.cmd),
cache: 'no-store'
});
} catch (e) {}
await new Promise(r => setTimeout(r, 120));
q.resolve(0);
}
_httpRun = 0;
})();
});
}
// apply palette preview to selected segments
async function applyLED()
{
if (!palCache.length) return;
try {
let st = await (await fetch(getURL('/json/state'), { cache: 'no-store' })).json();
if (!st.seg || !st.seg.length) return;
// get selected segments, use main segment if none selected
let segs = st.seg.filter(s => s.sel);
if (!segs.length) {
const mainSeg = st.seg.find(s => s.id === (st.mainseg || 0));
if (mainSeg) segs.push(mainSeg);
}
// show palette on each selected segment, 2D are treated as 1D to show better gradient
for (let s of segs) {
let len = (s.stop - s.start) * ((s.stopY - s.startY) || 1);
let arr = [];
for (let i = 0; i < len; i++)
arr.push(palCache[len > 1 ? Math.round(i * 255 / (len - 1)) : 0]);
// send colors in chunks
for (let j = 0; j < arr.length; j += maxCol) {
let chunk = [s.start + j, ...arr.slice(j, j + maxCol)];
await requestJson({ seg: { id: s.id, i: chunk } });
}
}
} catch (e) {}
}
// Events
window.addEventListener('resize', recalc);
// Unfreeze segments when page is unloaded
window.addEventListener('beforeunload', () => {
if (prvEn) requestJson({seg:{frz:false}});
});
</script>
<style>
:root {
--pad:16px;
--maxw:820px;
}
body {
background: #111;
min-width: 320px; /* prevent layout breakdown */
}
.ctr {
max-width: calc(var(--maxw) + 2*var(--pad));
margin: 20px auto;
padding: 0 var(--pad);
display: flex; flex-direction: column; align-items: center;
}
header {
text-align: center;
margin-bottom: 10px;
width: 100%;
}
header h1 {
margin: 0;
font-size: 20px;
font-weight: 600;
}
#pickerWrap {
display: flex;
flex-direction: column;
align-items: center;
margin: 8px 0 12px;
width: 100%;
}
.bar {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: center;
align-items: center;
margin: 8px 0;
width: 100%;
}
button.sml { min-width: 70px; }
#editor {
width: 100%;
display: flex;
justify-content: center;
position: sticky;
top: 0;
z-index: 5;
padding: 6px 0;
background: #111;
}
#gradWrap {
position: relative;
height: 34px;
width: 100%;
max-width: var(--maxw);
touch-action: none; /* fixes click&drag on touch devices */
}
#grad {
height: 100%;
border-radius: 5px;
}
#empty {
position: sticky;
top: 15px;
z-index: 4;
padding: 4px 0;
}
.mk {
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: 8px;
height: 34px;
border: 2px solid #5557;
border-radius: 5px;
touch-action: none;
}
.mk.sel {
outline: 2px solid #0bf;
border-color: #000;
}
@media (pointer: coarse) {
#gradWrap {
height: 60px; /* taller gradient for touch devices */
}
.mk {
height: 60px;
width: 20px; /* wider markers for touch devices */
}
#empty {
top: 40px; /* adjust for taller gradient */
}
}
section {
margin-top: 18px;
width: 100%;
}
.tbl {
background: #111;
width: 100%;
}
.lst {
display: flex;
flex-direction: column;
width: 100%;
}
.pal {
display: flex;
flex-direction: column;
position: relative;
margin-bottom: 5px;
}
.pal .nam {
text-align: center;
}
.pal .nam .by {
font-size: 11px;
margin-left: 4px;
}
.pal .prow {
display: flex;
align-items: flex-end;
gap: 10px;
}
.pal .prv {
flex: 1;
border-radius: 26px;
padding: 17px;
cursor: pointer;
}
.pal .sml {
min-width: 32px;
height: 32px;
padding: 0;
font-weight: 600;
}
.cats {
width: 100%;
}
details.cat {
border: 1px solid #444;
border-radius: 8px;
margin: 10px 0;
background: #222;
}
details.cat > summary {
cursor: pointer;
padding: 8px 10px;
display: flex;
align-items: center;
gap: 8px;
color: #fff;
list-style: none;
font-size: 18px;
font-weight: 500;
}
details.cat > summary::-webkit-details-marker {
display: none;
}
.cbdy {
padding: 8px 10px 12px;
display: flex;
flex-direction: column;
gap: 6px;
}
.rgbi {
display: flex;
gap: 6px;
margin-top: 8px;
align-items: center;
}
.rgbi input {
width: 60px;
border-radius: 6px;
}
#memWarn {
display: none;
color: #ff9900;
font-size: 16px;
margin-bottom: 8px;
text-align: center;
}
#memWarn a { color: #ffcc00; text-decoration: underline; }
</style>
</body>
</html>