175 lines
6.4 KiB
JavaScript
175 lines
6.4 KiB
JavaScript
|
|
(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);
|