Module:Color/sandbox

-- 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

end

end

local function numArgDefault(value, default)

if (value == nil or value == '') then

return default

else

return tonumber(value)

end

end

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)

end

end

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')

end

end

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 * 100

end

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 / 255

end

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 / 255

end

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

end

end

-- 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

end

end

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

end

end

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, h

end

local function checkInterpolationParameter(t)

if (t > 1 or t < 0) then

error('Interpolation parameter out of bounds')

end

end

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 mapping

local 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 + d

end

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 + d

end

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

end

end

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 ''

end

end

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))

end

end

-- 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 good

local function brewerGradient(t, palette)

local colors = {

spectral = { '9E0142', 'D53E4F', 'F46D43', 'FDAE61', 'FEE08B', 'FFFFBF', 'E6F598', 'ABDDA4', '66C2A5', '3288BD', '5E4FA2' },

rdylgn = { 'A50026', 'D73027', 'F46D43', 'FDAE61', 'FEE08B', 'FFFFBF', 'D9EF8B', 'A6D96A', '66BD63', '1A9850', '006837' },

rdylbu = { 'A50026', 'D73027', 'F46D43', 'FDAE61', 'FEE090', 'FFFFBF', 'E0F3F8', 'ABD9E9', '74ADD1', '4575B4', '313695' },

piyg = { '8E0152', 'C51B7D', 'DE77AE', 'F1B6DA', 'FDE0EF', 'F7F7F7', 'E6F5D0', 'B8E186', '7FBC41', '4D9221', '276419' },

brbg = { '543005', '8C510A', 'BF812D', 'DFC27D', 'F6E8C3', 'F5F5F5', 'C7EAE5', '80CDC1', '35978F', '01665E', '003C30' },

rdbu = { '67001F', 'B2182B', 'D6604D', 'F4A582', 'FDDBC7', 'F7F7F7', 'D1E5F0', '92C5DE', '4393C3', '2166AC', '053061' },

prgn = { '40004B', '762A83', '9970AB', 'C2A5CF', 'E7D4E8', 'F7F7F7', 'D9F0D3', 'A6DBA0', '5AAE61', '1B7837', '00441B' },

puor = { '7F3B08', 'B35806', 'E08214', 'FDB863', 'FEE0B6', 'F7F7F7', 'D8DAEB', 'B2ABD2', '8073AC', '542788', '2D004B' },

rdgy = { '67001F', 'B2182B', 'D6604D', 'F4A582', 'FDDBC7', 'FFFFFF', 'E0E0E0', 'BABABA', '878787', '4D4D4D', '1A1A1A' },

pubugn = { 'FFF7FB', 'ECE2F0', 'D0D1E6', 'A6BDDB', '67A9CF', '3690C0', '02818A', '016C59', '014636' },

ylorrd = { 'FFFFCC', 'FFEDA0', 'FED976', 'FEB24C', 'FD8D3C', 'FC4E2A', 'E31A1C', 'BD0026', '800026' },

ylorbr = { 'FFFFE5', 'FFF7BC', 'FEE391', 'FEC44F', 'FE9929', 'EC7014', 'CC4C02', '993404', '662506' },

ylgnbu = { 'FFFFD9', 'EDF8B1', 'C7E9B4', '7FCDBB', '41B6C4', '1D91C0', '225EA8', '253494', '081D58' },

gnbu = { 'F7FCF0', 'E0F3DB', 'CCEBC5', 'A8DDB5', '7BCCC4', '4EB3D3', '2B8CBE', '0868AC', '084081' },

orrd = { 'FFF7EC', 'FEE8C8', 'FDD49E', 'FDBB84', 'FC8D59', 'EF6548', 'D7301F', 'B30000', '7F0000' },

ylgn = { 'FFFFE5', 'F7FCB9', 'D9F0A3', 'ADDD8E', '78C679', '41AB5D', '238443', '006837', '004529' },

bugn = { 'F7FCFD', 'E5F5F9', 'CCECE6', '99D8C9', '66C2A4', '41AE76', '238B45', '006D2C', '00441B' },

pubu = { 'FFF7FB', 'ECE7F2', 'D0D1E6', 'A6BDDB', '74A9CF', '3690C0', '0570B0', '045A8D', '023858' },

purd = { 'F7F4F9', 'E7E1EF', 'D4B9DA', 'C994C7', 'DF65B0', 'E7298A', 'CE1256', '980043', '67001F' },

rdpu = { 'FFF7F3', 'FDE0DD', 'FCC5C0', 'FA9FB5', 'F768A1', 'DD3497', 'AE017E', '7A0177', '49006A' },

bupu = { 'F7FCFD', 'E0ECF4', 'BFD3E6', '9EBCDA', '8C96C6', '8C6BB1', '88419D', '810F7C', '4D004B' },

oranges = { 'FFF5EB', 'FEE6CE', 'FDD0A2', 'FDAE6B', 'FD8D3C', 'F16913', 'D94801', 'A63603', '7F2704' },

greens = { 'F7FCF5', 'E5F5E0', 'C7E9C0', 'A1D99B', '74C476', '41AB5D', '238B45', '006D2C', '00441B' },

blues = { 'F7FBFF', 'DEEBF7', 'C6DBEF', '9ECAE1', '6BAED6', '4292C6', '2171B5', '08519C', '08306B' },

reds = { 'FFF5F0', 'FEE0D2', 'FCBBA1', 'FC9272', 'FB6A4A', 'EF3B2C', 'CB181D', 'A50F15', '67000D' },

purples = { 'FCFBFD', 'EFEDF5', 'DADAEB', 'BCBDDC', '9E9AC8', '807DBA', '6A51A3', '54278F', '3F007D' },

greys = { 'FFFFFF', 'F0F0F0', 'D9D9D9', 'BDBDBD', '969696', '737373', '525252', '252525', '000000' }

}

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

end

end

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 .. ', ' .. b

end

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

end

end

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