Module:Color/sandbox explained

-- Introduction: https://colorspace.r-forge.r-project.org/articles/color_spaces.html

local p =

local function isEmpty(value) return value

nil or value

end

local function isNotEmpty(value) return value ~= nil and value ~= end

local function argDefault(value, default) if (value

nil or value

) then return default else return value endend

local function numArgDefault(value, default) if (value

nil or value

) then return default else return tonumber(value) endend

local function isArgTrue(value) return (value ~= nil and value ~= and value ~= '0')end

local function isEmpty(value) return value

nil or value

end

local function isNotEmpty(value) return value ~= nil and value ~= end

local function hexToRgb(hexColor) local cleanColor = hexColor:gsub('#', '#'):match('^[%s#]*(.-)[%s;]*$') if (#cleanColor

6) then return tonumber(string.sub(cleanColor, 1, 2), 16), tonumber(string.sub(cleanColor, 3, 4), 16), tonumber(string.sub(cleanColor, 5, 6), 16) elseif (#cleanColor

3) then return 17 * tonumber(string.sub(cleanColor, 1, 1), 16), 17 * tonumber(string.sub(cleanColor, 2, 2), 16), 17 * tonumber(string.sub(cleanColor, 3, 3), 16) end error('Invalid hexadecimal color ' .. cleanColor, 1)end

local function round(value) if (value < 0) then return math.ceil(value - 0.5) else return math.floor(value + 0.5) endend

local function rgbToHex(r, g, b) return string.format('%02X%02X%02X', round(r), round(g), round(b))end

local function checkRgb(r, g, b) if (r > 255 or g > 255 or b > 255 or r < 0 or g < 0 or b < 0) then error('Color level out of bounds') endend

local function rgbToCmyk(r, g, b) local c = 1 - r / 255 local m = 1 - g / 255 local y = 1 - b / 255 local k = math.min(c, m, y) if (k

1) then c = 0 m = 0 y = 0 else local kc = 1 - k c = (c - k) / kc m = (m - k) / kc y = (y - k) / kc end return c * 100, m * 100, y * 100, k * 100end

local function rgbToHsl(r, g, b) local channelMax = math.max(r, g, b) local channelMin = math.min(r, g, b) local range = channelMax - channelMin local h, s if (range

0) then h = 0 elseif (channelMax

r) then h = 60 * ((g - b) / range) if (h < 0) then h = 360 + h end elseif (channelMax

g) then h = 60 * (2 + (b - r) / range) else h = 60 * (4 + (r - g) / range) end local L = channelMax + channelMin if (L

0 or L

510) then s = 0 else s = 100 * range / math.min(L, 510 - L) end return h, s, L * 50 / 255end

local function rgbToHsv(r, g, b) local channelMax = math.max(r, g, b) local channelMin = math.min(r, g, b) local range = channelMax - channelMin local h, s if (range

0) then h = 0 elseif (channelMax

r) then h = 60 * ((g - b) / range) if (h < 0) then h = 360 + h end elseif (channelMax

g) then h = 60 * (2 + (b - r) / range) else h = 60 * (4 + (r - g) / range) end if (channelMax

0) then s = 0 else s = 100 * range / channelMax end return h, s, channelMax * 100 / 255end

local function checkHsv(h, s, v) if (s > 100 or v > 100 or s < 0 or v < 0) then error('Color level out of bounds') end end

local function hsvToRgb(h, s, v) local hn = (h / 60 - 6 * math.floor(h / 360)) local hi = math.floor(hn) local hr = hn - hi local sn = s / 100 local vs = v * 255 / 100 local p = vs * (1 - sn); local q = vs * (1 - sn * hr); local t = vs * (1 - sn * (1 - hr)); if (hi < 3) then if (hi

0) then return vs, t, p elseif (hi

1) then return q, vs, p else return p, vs, t end else if (hi

3) then return p, q, vs elseif (hi

4) then return t, p, vs else return vs, p, q end endend

-- c in [0, 255], condition tweaked for no discontinuity-- http://entropymine.com/imageworsener/srgbformula/local function toLinear(c) if (c > 10.314300250662591) then return math.pow((c + 14.025) / 269.025, 2.4) else return c / 3294.6 endend

local function toNonLinear(c) if (c > 0.00313066844250063) then return 269.025 * math.pow(c, 1.0/2.4) - 14.025 else return 3294.6 * c endend

local function srgbToCielchuvD65o2deg(r, g, b) local R = toLinear(r) local G = toLinear(g) local B = toLinear(b) -- https://github.com/w3c/csswg-drafts/issues/5922 local X = 0.1804807884018343 * B + 0.357584339383878 * G + 0.41239079926595934 * R local Y = 0.07219231536073371 * B + 0.21263900587151027 * R + 0.715168678767756 * G local Z = 0.01933081871559182 * R + 0.11919477979462598 * G + 0.9505321522496607 * B local L, C, h if (Y > 0.00885645167903563082) then L = 116 * math.pow(Y, 1/3) - 16 else L = Y * 903.2962962962962962963 end if ((r

g and g

b) or L

0) then C = 0 h = 0 else d = X + 3 * Z + 15 * Y if (d

0) then C = 0 h = 0 else -- 0.19783... and 0.4631... computed with extra precision from (X,Y,Z) when (R,G,B) = (1,1,1), -- in which case (u,v) ≈ (0,0) local us = 4 * X / d - 0.19783000664283678994 local vs = 9 * Y / d - 0.46831999493879099801 h = math.atan2(vs, us) * 57.2957795130823208768 if (h < 0) then h = h + 360 elseif (h

0) then h = 0 -- ensure zero is positive end C = math.sqrt(us * us + vs * vs) * 13 * L if (C

0) then C = 0 h = 0 end end end return L, C, hend

local function checkInterpolationParameter(t) if (t > 1 or t < 0) then error('Interpolation parameter out of bounds') endend

local function srgbMix(t, r0, g0, b0, r1, g1, b1) local tc = 1 - t return toNonLinear(tc * toLinear(r0) + t * toLinear(r1)), toNonLinear(tc * toLinear(g0) + t * toLinear(g1)), toNonLinear(tc * toLinear(b0) + t * toLinear(b1))end

-- functions for generating gradients, inspired by OKLCH but not needing gamut mappinglocal function adjustHueToCielch(h) local n = 180 * math.floor(h / 180) local d = h - n if (d < 60) then d = 73.7 * d / 60 elseif (d < 120) then d = 0.6975 * d + 31.85 else d = 1.07416666666666666667 * d - 13.35 end return n + dend

local function unadjustHueFromCielch(h) local n = 180 * math.floor(h / 180) local d = h - n if (d < 73.7) then d = 0.81411126187245590231 * d elseif (d < 115.55) then d = 1.43369175627240143369 * d - 45.66308243727598566308 else d = 0.93095422808378588053 * d + 12.42823894491854150504 end return n + dend

local function getLightness(r, g, b) local Y = 0.07219231536073371 * toLinear(b) + 0.21263900587151027 * toLinear(r) + 0.715168678767756 * toLinear(g) if (Y > 0.00885645167903563082) then return 116 * math.pow(Y, 1/3) - 16 else return Y * 903.2962962962962962963 endend

local function adjustLightness(L, r, g, b) if (L >= 100) then return 255, 255, 255 end local Yc if (L > 8) then Yc = (L + 16) / 116 Yc = Yc * Yc * Yc else Yc = L * 0.00110705645987945385 end local R = toLinear(r) local G = toLinear(g) local B = toLinear(b) local Y = 0.07219231536073371 * B + 0.21263900587151027 * R + 0.715168678767756 * G if (Y > 0) then local scale = Yc / Y R = R * scale G = G * scale B = B * scale local cmax = math.max(R, G, B) if (cmax > 1) then R = R / cmax G = G / cmax B = B / cmax local d = 0.07219231536073371 * (1 - B) + 0.21263900587151027 * (1 - R) + 0.715168678767756 * (1 - G) if (d <= 0) then R = 1 G = 1 B = 1 else local strength = 0.5 -- 1 yields equal lightness local t = (Yc - 0.07219231536073371 * B - 0.21263900587151027 * R - 0.715168678767756 * G) / d R = R + strength * (1 - R) * t G = G + strength * (1 - G) * t B = B + strength * (1 - B) * t end end else R = Yc G = Yc B = Yc end return toNonLinear(R), toNonLinear(G), toNonLinear(B)end

local function interpolateHue(t, r0, g0, b0, r1, g1, b1, direction) local h0, s0, v0 = rgbToHsv(r0, g0, b0) local h1, s1, v1 = rgbToHsv(r1, g1, b1) if (s0

0) then h0 = h1 if (v0

0) then s0 = s1 end end if (s1

0) then h1 = h0 if (v1

0) then s1 = s1 end end local hn0 = h0 / 360 local hn1 = h1 / 360 if (direction

0) then local dhn = hn1 - hn0 if (dhn > 0.5) then dhn = dhn - math.ceil(dhn - 0.5) elseif (dhn < -0.5) then dhn = dhn - math.floor(dhn + 0.5) end if (dhn >= 0) then hn0 = hn0 - math.floor(hn0) hn1 = hn0 + dhn else hn1 = hn1 - math.floor(hn1) hn0 = hn1 - dhn end elseif (direction > 0) then hn1 = 1 - math.ceil(hn1 - hn0) - math.floor(hn0) + hn1 hn0 = hn0 - math.floor(hn0) else hn0 = 1 - math.ceil(hn0 - hn1) - math.floor(hn1) + hn0 hn1 = hn1 - math.floor(hn1) end if (t < 0) then t = 0 elseif (t > 1) then t = 1 end local tc = 1 - t local ha = tc * adjustHueToCielch(360 * hn0) + t * adjustHueToCielch(360 * hn1) local r, g, b = hsvToRgb(unadjustHueFromCielch(ha), tc * s0 + t * s1, tc * v0 + t * v1) local L0 = getLightness(r0, g0, b0) local L1 = getLightness(r1, g1, b1) return adjustLightness(tc * L0 + t * L1, r, g, b)end

local function formatToPrecision(value, p) return string.format('%.' .. p .. 'f', value)end

local function getFractionalZeros(p) if (p > 0) then return '.' .. string.rep('0', p) else return endend

local function polyMix(t, palette) if (t <= 0) then return palette[1] elseif (t >= 1) then return palette[#palette] end local n, f = math.modf(t * (#palette - 1)) if (f

0) then return palette[n + 1] else local r0, g0, b0 = hexToRgb(palette[n + 1]) local r1, g1, b1 = hexToRgb(palette[n + 2]) return rgbToHex(srgbMix(f, r0, g0, b0, r1, g1, b1)) endend

-- same principle: https://colorspace.r-forge.r-project.org/articles/hcl_palettes.html-- the darkest colors do not yield an WCAG AA contrast with text, maybe this can be solved by using HCL Wizard from R's Colorspace package-- https://colorspace.r-forge.r-project.org/articles/approximations.html-- R's Colorspace does gamut mapping through simple clipping (as do most other color libraries, such as chroma.js and colorio), which is fast but not goodlocal function brewerGradient(t, palette) local colors = return polyMix(t, colors[palette])end

local function softSigmoid(x) local ax = math.abs(x) if (ax > 0.000000000000000111) then return x / (1 + ax) else return x endend

function p.hexToRgbTriplet(frame) local args = frame.args or frame:getParent.args local hex = args[1] if (isEmpty(hex)) then return end local r, g, b = hexToRgb(hex) return r .. ', ' .. g .. ', ' .. bend

function p.hexToCmyk(frame) local args = frame.args or frame:getParent.args local hex = args[1] if (isEmpty(hex)) then return end local p = numArgDefault(args.precision, 0) local s = args.pctsign or '1' local c, m, y, k = rgbToCmyk(hexToRgb(hex)) local fk = formatToPrecision(k, p) local fc, fm, fy local fracZeros = getFractionalZeros(p) if (fk

100 .. fracZeros) then local fZero = 0 .. fracZeros fc = fZero fm = fZero fy = fZero else fc = formatToPrecision(c, p) fm = formatToPrecision(m, p) fy = formatToPrecision(y, p) end if (s ~= '0') then return fc .. '%, ' .. fm .. '%, ' .. fy .. '%, ' .. fk .. '%' else return fc .. ', ' .. fm .. ', ' .. fy .. ', ' .. fk endend

function p.hexToHsl(frame) local args = frame.args or frame:getParent.args local hex = args[1] if (isEmpty(hex)) then return end local p = numArgDefault(args.precision, 0) local h, s, l = rgbToHsl(hexToRgb(hex)) local fl = formatToPrecision(l, p) local fs, fh local fracZeros = getFractionalZeros(p) local fZero = 0 .. fracZeros if (fl

fZero or fl

100 .. fracZeros) then fs = fZero fh = fZero else fs = formatToPrecision(s, p) if (fs

fZero) then fh = fZero else fh = formatToPrecision(h, p) if (fh

360 .. fracZeros) then fh = fZero -- handle rounding to 360 end end end return fh .. '°, ' .. fs .. '%, ' .. fl .. '%'end

function p.hexToHsv(frame) local args = frame.args or frame:getParent.args local hex = args[1] if (isEmpty(hex)) then return end local p = numArgDefault(args.precision, 0) local h, s, v = rgbToHsv(hexToRgb(hex)) local fv = formatToPrecision(v, p) local fs, fh local fracZeros = getFractionalZeros(p) local fZero = 0 .. fracZeros if (fv

fZero) then fh = fZero fs = fZero else fs = formatToPrecision(s, p) if (fs

fZero) then fh = fZero else fh = formatToPrecision(h, p) if (fh

360 .. fracZeros) then fh = fZero -- handle rounding to 360 end end end return fh .. '°, ' .. fs .. '%, ' .. fv .. '%'end

function p.hexToCielch(frame) local args = frame.args or frame:getParent.args local hex = args[1] if (isEmpty(hex)) then return end local p = numArgDefault(args.precision, 0) local L, C, h = srgbToCielchuvD65o2deg(hexToRgb(hex)) local fL = formatToPrecision(L, p) local fC, fh local fracZeros = getFractionalZeros(p) local fZero = 0 .. fracZeros if (fL

fZero or fL

100 .. fracZeros) then fC = fZero fh = fZero else fC = formatToPrecision(C, p) if (fC

fZero) then fh = fZero else fh = formatToPrecision(h, p) if (fh

360 .. fracZeros) then fh = fZero -- handle rounding to 360 end end end return fL .. ', ' .. fC .. ', ' .. fh .. '°'end

function p.hexMix(frame) local args = frame.args or frame:getParent.args local hex0 = args[1] local hex1 = args[2] if (isEmpty(hex0) or isEmpty(hex1)) then return end local t = args[3] if (isEmpty(t)) then t = 0.5 else t = tonumber(t) local amin = numArgDefault(args.min, 0) local amax = numArgDefault(args.max, 100) if (amax

amin) then t = 0.5 else t = (t - amin) / (amax - amin) if (t > 1) then t = 1 elseif (t < 0) then t = 0 end end end local r0, g0, b0 = hexToRgb(hex0) local r1, g1, b1 = hexToRgb(hex1) return rgbToHex(srgbMix(t, r0, g0, b0, r1, g1, b1))end

function p.hexInterpolate(frame) local args = frame.args or frame:getParent.args local hex0 = args[1] local hex1 = args[2] if (isEmpty(hex0)) then return hex1 elseif (isEmpty(hex1)) then return hex0 end local t = args[3] if (isEmpty(t)) then t = 0.5 else t = tonumber(t) local amin = numArgDefault(args.min, 0) local amax = numArgDefault(args.max, 100) if (amax

amin) then t = 0.5 else t = (t - amin) / (amax - amin) if (t > 1) then t = 1 elseif (t < 0) then t = 0 end end end local direction = numArgDefault(args.direction, 0) local r0, g0, b0 = hexToRgb(hex0) local r1, g1, b1 = hexToRgb(hex1) return rgbToHex(interpolateHue(t, r0, g0, b0, r1, g1, b1, direction))end

function p.hexBrewerGradient(frame) local args = frame.args or frame:getParent.args local pal = argDefault(args.pal, 'spectral'):lower local value = args[1] local t if (isEmpty(value)) then t = 0.5 else value = tonumber(value) local high = numArgDefault(args.high, 100) local low = numArgDefault(args.low, -100) if (isEmpty(args.low)) then if (pal ~= 'spectral' and pal ~= 'rdylgn' and pal ~= 'rdylbu' and (pal:len ~= 4 or (pal ~= 'rdgy' and pal ~= 'rdbu' and pal ~= 'puor' and pal ~= 'prgn' and pal ~= 'piyg' and pal ~= 'brbg'))) then low = 0 end end if (high

low) then t = 0.5 elseif (isArgTrue(args.inv)) then t = (high - value) / (high - low) else t = (value - low) / (high - low) end end if (isArgTrue(args.comp)) then t = 0.5 * softSigmoid(2 * t - 1) + 0.5 end return brewerGradient(t, pal)end

return p