split
This commit is contained in:
parent
88b5669961
commit
7fd7ba2e76
8 changed files with 512 additions and 147 deletions
58
js/palette.js
Normal file
58
js/palette.js
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
(function () {
|
||||
'use strict';
|
||||
|
||||
const root = window.PixelIslandModules ||= {};
|
||||
const BASE_COLOR_CODES = '0123456789abcdefghij';
|
||||
const ADVANCED_COLOR_CODES = 'klmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ' + "!#$%&'()*+,-/:;<=>?@[]^_`{|}~";
|
||||
const COLOR_CODES = BASE_COLOR_CODES + ADVANCED_COLOR_CODES;
|
||||
const BASIC_PALETTE_COUNT = BASE_COLOR_CODES.length;
|
||||
const DEFAULT_SELECTED_COLOR_CODE = BASE_COLOR_CODES.includes('a') ? 'a' : (BASE_COLOR_CODES[0] || '0');
|
||||
|
||||
function hslToHex(h, s, l) {
|
||||
s /= 100; l /= 100;
|
||||
const k = (n) => (n + h / 30) % 12;
|
||||
const a = s * Math.min(l, 1 - l);
|
||||
const f = (n) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
|
||||
const toHex = (value) => Math.round(255 * value).toString(16).padStart(2, '0');
|
||||
return `#${toHex(f(0))}${toHex(f(8))}${toHex(f(4))}`;
|
||||
}
|
||||
|
||||
function buildPalette() {
|
||||
const basicColors = [
|
||||
'#111827', '#374151', '#6b7280', '#d1d5db', '#fff7ed',
|
||||
'#7f1d1d', '#dc2626', '#f97316', '#facc15', '#84cc16',
|
||||
'#16a34a', '#14b8a6', '#06b6d4', '#2563eb', '#4f46e5',
|
||||
'#7c3aed', '#c026d3', '#ec4899', '#f5d0a9', '#7c4a2d'
|
||||
];
|
||||
const advancedColors = [
|
||||
'#fafafa', '#f5f5f4', '#e7e5e4', '#d6d3d1', '#a8a29e', '#78716c', '#57534e', '#292524', '#0c0a09',
|
||||
'#fef3c7', '#fde68a', '#d6a25f', '#9a6a3a', '#5c3b24',
|
||||
'#fee2e2', '#fca5a5', '#ef4444', '#b91c1c', '#7f1d1d',
|
||||
'#ffedd5', '#fdba74', '#f97316', '#c2410c', '#7c2d12',
|
||||
'#fef9c3', '#fde047', '#eab308', '#a16207', '#713f12',
|
||||
'#ecfccb', '#bef264', '#84cc16', '#4d7c0f', '#365314', '#dcfce7', '#86efac', '#15803d',
|
||||
'#ccfbf1', '#5eead4', '#14b8a6', '#0f766e', '#134e4a',
|
||||
'#cffafe', '#67e8f9', '#22d3ee', '#06b6d4', '#0e7490', '#e0f2fe', '#38bdf8',
|
||||
'#dbeafe', '#93c5fd', '#3b82f6', '#2563eb', '#1d4ed8', '#1e3a8a',
|
||||
'#e0e7ff', '#a5b4fc', '#6366f1', '#4f46e5', '#3730a3', '#ede9fe', '#c4b5fd', '#8b5cf6',
|
||||
'#fae8ff', '#e879f9', '#c026d3', '#86198f', '#fce7f3', '#f9a8d4', '#ec4899', '#be185d'
|
||||
];
|
||||
const colors = [...basicColors, ...advancedColors];
|
||||
return COLOR_CODES.split('').map((code, index) => ({ code, color: colors[index] || hslToHex((index * 47) % 360, 72, 58) }));
|
||||
}
|
||||
|
||||
const PALETTE = buildPalette();
|
||||
const PALETTE_BY_CODE = Object.fromEntries(PALETTE.map((p) => [p.code, p.color]));
|
||||
|
||||
root.Palette = {
|
||||
BASE_COLOR_CODES,
|
||||
ADVANCED_COLOR_CODES,
|
||||
COLOR_CODES,
|
||||
BASIC_PALETTE_COUNT,
|
||||
DEFAULT_SELECTED_COLOR_CODE,
|
||||
PALETTE,
|
||||
PALETTE_BY_CODE,
|
||||
buildPalette,
|
||||
hslToHex
|
||||
};
|
||||
})();
|
||||
175
js/pixel-codec.js
Normal file
175
js/pixel-codec.js
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
(function (global) {
|
||||
'use strict';
|
||||
|
||||
const root = global.PixelIslandModules ||= {};
|
||||
const MAX_DIMENSION = 64;
|
||||
|
||||
function clamp(value, min, max) {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
function clampInt(value, min, max, fallback = min) {
|
||||
const n = Math.round(Number(value));
|
||||
return Number.isFinite(n) ? clamp(n, min, max) : fallback;
|
||||
}
|
||||
|
||||
function clampDimension(value, fallback = 8) {
|
||||
return clampInt(value, 1, MAX_DIMENSION, fallback);
|
||||
}
|
||||
|
||||
function fnv1a(value) {
|
||||
let hash = 0x811c9dc5;
|
||||
const text = String(value);
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
hash ^= text.charCodeAt(i);
|
||||
hash = Math.imul(hash, 0x01000193) >>> 0;
|
||||
}
|
||||
return hash.toString(16).padStart(8, '0');
|
||||
}
|
||||
|
||||
function assetWidth(asset) {
|
||||
return clampInt(asset?.width ?? asset?.w ?? asset?.size, 1, MAX_DIMENSION, 16);
|
||||
}
|
||||
|
||||
function assetHeight(asset) {
|
||||
return clampInt(asset?.height ?? asset?.ht ?? asset?.size, 1, MAX_DIMENSION, assetWidth(asset));
|
||||
}
|
||||
|
||||
function assetMaxSize(asset) {
|
||||
return Math.max(assetWidth(asset), assetHeight(asset));
|
||||
}
|
||||
|
||||
function blankPixels(width, height = width) {
|
||||
return Array(Math.max(1, width) * Math.max(1, height)).fill(null);
|
||||
}
|
||||
|
||||
function parseHex(hex) {
|
||||
if (typeof hex !== 'string' || !hex.startsWith('#')) return null;
|
||||
const clean = hex.replace('#', '');
|
||||
const full = clean.length === 3 ? clean.split('').map((c) => c + c).join('') : clean;
|
||||
const value = parseInt(full, 16);
|
||||
if (!Number.isFinite(value)) return null;
|
||||
return { r: (value >> 16) & 255, g: (value >> 8) & 255, b: value & 255 };
|
||||
}
|
||||
|
||||
function nearestPaletteCodeFrom(entries, color, fallback) {
|
||||
if (!Array.isArray(entries) || !entries.length) return fallback || '0';
|
||||
if (!color || typeof color !== 'string') return fallback || entries[0]?.code || '0';
|
||||
if (entries.some((entry) => entry.code === color)) return color;
|
||||
const rgb = parseHex(color);
|
||||
if (!rgb) return fallback || entries[0]?.code || '0';
|
||||
let best = entries[0]?.code || '0';
|
||||
let bestDist = Infinity;
|
||||
for (const entry of entries) {
|
||||
const entryRgb = parseHex(entry.color);
|
||||
if (!entryRgb) continue;
|
||||
const dist = (rgb.r - entryRgb.r) ** 2 + (rgb.g - entryRgb.g) ** 2 + (rgb.b - entryRgb.b) ** 2;
|
||||
if (dist < bestDist) {
|
||||
bestDist = dist;
|
||||
best = entry.code;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function normalizePixels(pixels, width, height = width, palette = root.Palette?.PALETTE || [], defaultColorCode = root.Palette?.DEFAULT_SELECTED_COLOR_CODE || 'a') {
|
||||
const out = blankPixels(width, height);
|
||||
const paletteByCode = new Map((palette || []).map((entry) => [entry.code, entry.color]));
|
||||
if (typeof pixels === 'string') {
|
||||
for (let i = 0; i < Math.min(out.length, pixels.length); i++) {
|
||||
const ch = pixels[i];
|
||||
out[i] = ch === '.' ? null : (paletteByCode.has(ch) ? ch : nearestPaletteCodeFrom(palette, ch, defaultColorCode));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
if (!Array.isArray(pixels)) return out;
|
||||
for (let i = 0; i < Math.min(out.length, pixels.length); i++) {
|
||||
const value = pixels[i];
|
||||
if (!value) out[i] = null;
|
||||
else if (paletteByCode.has(value)) out[i] = value;
|
||||
else out[i] = nearestPaletteCodeFrom(palette, value, defaultColorCode);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function encodePixels(pixels, width = null, height = null, palette = root.Palette?.PALETTE || [], defaultColorCode = root.Palette?.DEFAULT_SELECTED_COLOR_CODE || 'a') {
|
||||
const w = width || Math.sqrt(pixels?.length || 0) || 8;
|
||||
const h = height || w;
|
||||
return normalizePixels(pixels, w, h, palette, defaultColorCode).map((value) => value || '.').join('');
|
||||
}
|
||||
|
||||
function buildPixelBlob(encodedPixels, width, height = width, palette = root.Palette?.PALETTE || [], defaultColorCode = root.Palette?.DEFAULT_SELECTED_COLOR_CODE || 'a') {
|
||||
const w = clampDimension(width, 16);
|
||||
const h = clampDimension(height, w);
|
||||
const payload = encodePixels(encodedPixels, w, h, palette, defaultColorCode);
|
||||
const codec = 'palette-index-v1';
|
||||
return {
|
||||
id: computePixelBlobId(codec, w, h, payload, palette, defaultColorCode),
|
||||
codec,
|
||||
width: w,
|
||||
height: h,
|
||||
payload
|
||||
};
|
||||
}
|
||||
|
||||
function computePixelBlobId(codec, width, height, payload, palette = root.Palette?.PALETTE || [], defaultColorCode = root.Palette?.DEFAULT_SELECTED_COLOR_CODE || 'a') {
|
||||
const normalizedCodec = codec || 'palette-index-v1';
|
||||
const w = clampDimension(width, 16);
|
||||
const h = clampDimension(height, w);
|
||||
const encoded = encodePixels(payload, w, h, palette, defaultColorCode);
|
||||
return `blob:${fnv1a(['pixel-blob-v1', normalizedCodec, w, h, encoded].join('|'))}`;
|
||||
}
|
||||
|
||||
function ensureAssetBlobId(asset, palette = root.Palette?.PALETTE || [], defaultColorCode = root.Palette?.DEFAULT_SELECTED_COLOR_CODE || 'a') {
|
||||
if (!asset) return asset;
|
||||
const w = assetWidth(asset);
|
||||
const h = assetHeight(asset);
|
||||
const right = normalizePixels(asset.faces?.right || asset.pixels || blankPixels(w, h), w, h, palette, defaultColorCode);
|
||||
const blob = buildPixelBlob(right, w, h, palette, defaultColorCode);
|
||||
return { ...asset, blobId: asset.blobId || asset.bi || blob.id };
|
||||
}
|
||||
|
||||
function colorToHex(value) {
|
||||
if (!value) return 'rgba(0,0,0,0)';
|
||||
return root.Palette?.PALETTE_BY_CODE?.[value] || value;
|
||||
}
|
||||
|
||||
function nearestPaletteCode(color) {
|
||||
return nearestPaletteCodeFrom(root.Palette?.PALETTE || [], color, root.Palette?.DEFAULT_SELECTED_COLOR_CODE || 'a');
|
||||
}
|
||||
|
||||
function nearestBasicPaletteCode(color) {
|
||||
const palette = root.Palette?.PALETTE || [];
|
||||
const count = root.Palette?.BASIC_PALETTE_COUNT || palette.length;
|
||||
return nearestPaletteCodeFrom(palette.slice(0, count), root.Palette?.PALETTE_BY_CODE?.[color] || color, palette[0]?.code || '0');
|
||||
}
|
||||
|
||||
function readableTextColor(hex) {
|
||||
const rgb = parseHex(hex);
|
||||
if (!rgb) return '#243044';
|
||||
const yiq = (rgb.r * 299 + rgb.g * 587 + rgb.b * 114) / 1000;
|
||||
return yiq > 140 ? '#243044' : '#fffdf5';
|
||||
}
|
||||
|
||||
root.PixelCodec = {
|
||||
MAX_DIMENSION,
|
||||
assetHeight,
|
||||
assetMaxSize,
|
||||
assetWidth,
|
||||
blankPixels,
|
||||
buildPixelBlob,
|
||||
clampDimension,
|
||||
clampInt,
|
||||
colorToHex,
|
||||
computePixelBlobId,
|
||||
ensureAssetBlobId,
|
||||
encodePixels,
|
||||
fnv1a,
|
||||
nearestBasicPaletteCode,
|
||||
nearestPaletteCode,
|
||||
nearestPaletteCodeFrom,
|
||||
normalizePixels,
|
||||
parseHex,
|
||||
readableTextColor
|
||||
};
|
||||
})(typeof self !== 'undefined' ? self : window);
|
||||
21
js/pixel-worker.js
Normal file
21
js/pixel-worker.js
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
'use strict';
|
||||
|
||||
importScripts('./pixel-codec.js');
|
||||
|
||||
const PixelCodec = self.PixelIslandModules.PixelCodec;
|
||||
|
||||
self.onmessage = (event) => {
|
||||
const { id, type, payload } = event.data || {};
|
||||
try {
|
||||
if (type === 'buildPixelBlobs') {
|
||||
const palette = Array.isArray(payload?.palette) ? payload.palette : [];
|
||||
const defaultColorCode = payload?.defaultColorCode || 'a';
|
||||
const blobs = (payload?.assets || []).map((asset) => PixelCodec.buildPixelBlob(asset?.faces?.right || asset?.pixels || '', PixelCodec.assetWidth(asset), PixelCodec.assetHeight(asset), palette, defaultColorCode));
|
||||
self.postMessage({ id, ok: true, result: blobs });
|
||||
return;
|
||||
}
|
||||
self.postMessage({ id, ok: false, error: 'unsupported_worker_request' });
|
||||
} catch (error) {
|
||||
self.postMessage({ id, ok: false, error: error?.message || 'worker_error' });
|
||||
}
|
||||
};
|
||||
53
js/worker-client.js
Normal file
53
js/worker-client.js
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
(function () {
|
||||
'use strict';
|
||||
|
||||
const root = window.PixelIslandModules ||= {};
|
||||
let worker = null;
|
||||
let nextId = 1;
|
||||
const pending = new Map();
|
||||
|
||||
function canUseWorkers() {
|
||||
return typeof Worker === 'function';
|
||||
}
|
||||
|
||||
function getWorker() {
|
||||
if (!canUseWorkers()) return null;
|
||||
if (worker) return worker;
|
||||
worker = new Worker('./js/pixel-worker.js');
|
||||
worker.onmessage = (event) => {
|
||||
const { id, ok, result, error } = event.data || {};
|
||||
const entry = pending.get(id);
|
||||
if (!entry) return;
|
||||
pending.delete(id);
|
||||
if (ok) entry.resolve(result);
|
||||
else entry.reject(new Error(error || 'worker_error'));
|
||||
};
|
||||
worker.onerror = (event) => {
|
||||
const error = new Error(event.message || 'worker_error');
|
||||
for (const entry of pending.values()) entry.reject(error);
|
||||
pending.clear();
|
||||
worker?.terminate();
|
||||
worker = null;
|
||||
};
|
||||
return worker;
|
||||
}
|
||||
|
||||
function request(type, payload) {
|
||||
const target = getWorker();
|
||||
if (!target) return Promise.reject(new Error('workers_unavailable'));
|
||||
const id = nextId++;
|
||||
const promise = new Promise((resolve, reject) => pending.set(id, { resolve, reject }));
|
||||
target.postMessage({ id, type, payload });
|
||||
return promise;
|
||||
}
|
||||
|
||||
function buildPixelBlobs(assets) {
|
||||
return request('buildPixelBlobs', {
|
||||
assets,
|
||||
palette: root.Palette?.PALETTE || [],
|
||||
defaultColorCode: root.Palette?.DEFAULT_SELECTED_COLOR_CODE || 'a'
|
||||
});
|
||||
}
|
||||
|
||||
root.WorkerClient = { canUseWorkers, buildPixelBlobs };
|
||||
})();
|
||||
Loading…
Add table
Add a link
Reference in a new issue