6.7 KiB · text History be2c05b
// What to paint: a fixed theme and pattern, or ones that follow the sun
// through night, sunrise, day and sunset, each phase with its own.
//
// Following the sun blends between the four phase themes by the sun's
// elevation, so the change is continuous rather than a switch at fixed
// times. Blending happens in OKLCH, the shorter way round the hue
// circle: a straight mix of two near-opposite hues (clays and sky, say)
// cancels to grey halfway, where turning the hue keeps full colour. For
// these palettes every twilight transition turns through magenta and
// purple, which is what the sky does anyway.
.pragma library
.import "themes.js" as Themes

var ROLES = ["base", "deep", "ink", "tone"]

// ------------------------------------------------------------------ sun

// Solar elevation in degrees, after the approximation in the
// Astronomical Almanac: good to a fraction of a degree, which is far
// finer than a wallpaper can show.
function sunElevation(date, latitude, longitude) {
    var rad = Math.PI / 180
    var d = date.getTime() / 86400000 - 10957.5  // days since J2000.0
    var g = (357.529 + 0.98560028 * d) * rad
    var q = 280.459 + 0.98564736 * d
    var L = (q + 1.915 * Math.sin(g) + 0.020 * Math.sin(2 * g)) * rad
    var e = (23.439 - 0.00000036 * d) * rad
    var ra = Math.atan2(Math.cos(e) * Math.sin(L), Math.cos(L))
    var dec = Math.asin(Math.sin(e) * Math.sin(L))
    var gmst = 18.697374558 + 24.06570982441908 * d  // hours
    var hourAngle = ((gmst * 15 + longitude) * rad) - ra
    var lat = latitude * rad
    return Math.asin(Math.sin(lat) * Math.sin(dec)
                     + Math.cos(lat) * Math.cos(dec) * Math.cos(hourAngle)) / rad
}

function minutesOf(hhmm) {
    var parts = String(hhmm).split(":")
    var m = Number(parts[0]) * 60 + Number(parts[1] || 0)
    return isNaN(m) ? 0 : m
}

// Without a location, sunrise and sunset are clock times, and this
// stands in for the elevation: a sine arc peaking at 50 degrees
// between them and dipping to -30 at the middle of the night, which
// passes through the same thresholds as the real sun would.
function clockElevation(date, sunrise, sunset) {
    var now = date.getHours() * 60 + date.getMinutes() + date.getSeconds() / 60
    var rise = minutesOf(sunrise)
    var set = minutesOf(sunset)
    var dayLength = (set - rise + 1440) % 1440 || 720
    var sinceRise = (now - rise + 1440) % 1440
    if (sinceRise < dayLength)
        return 50 * Math.sin(Math.PI * sinceRise / dayLength)
    return -30 * Math.sin(Math.PI * (sinceRise - dayLength) / (1440 - dayLength))
}

// ---------------------------------------------------------------- colour

function oklab(lch) {
    var h = lch[2] * Math.PI / 180
    return [lch[0], lch[1] * Math.cos(h), lch[1] * Math.sin(h)]
}

function oklabToHex(lab) {
    var l = Math.pow(lab[0] + 0.3963377774 * lab[1] + 0.2158037573 * lab[2], 3)
    var m = Math.pow(lab[0] - 0.1055613458 * lab[1] - 0.0638541728 * lab[2], 3)
    var s = Math.pow(lab[0] - 0.0894841775 * lab[1] - 1.2914855480 * lab[2], 3)
    var linear = [
        4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
        -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
        -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s,
    ]
    var hex = "#"
    for (var i = 0; i < 3; i++) {
        var x = Math.min(Math.max(linear[i], 0), 1)
        x = x <= 0.0031308 ? 12.92 * x : 1.055 * Math.pow(x, 1 / 2.4) - 0.055
        hex += ("0" + Math.round(x * 255).toString(16)).slice(-2)
    }
    return hex
}

function theme(name) {
    return Themes.themes[name] || Themes.themes.her
}

// Two OKLCH colours, t of the way from a to b. A colour with almost no
// chroma has no meaningful hue, so it takes the other one's: black to
// a colour then darkens the colour rather than sweeping round the wheel.
function mixLch(a, b, t) {
    var ha = a[1] < 0.01 ? b[2] : a[2]
    var hb = b[1] < 0.01 ? a[2] : b[2]
    var dh = ((hb - ha) % 360 + 540) % 360 - 180
    return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, ha + dh * t]
}

// weights: [[themeName, weight]] or [[from, 1 - t], [to, t]].
function blend(weights) {
    var out = {}
    for (var r = 0; r < ROLES.length; r++) {
        var lch = theme(weights[0][0])[ROLES[r]]
        if (weights.length > 1)
            lch = mixLch(lch, theme(weights[1][0])[ROLES[r]], weights[1][1])
        out[ROLES[r]] = oklabToHex(oklab(lch))
    }
    return out
}

// ---------------------------------------------------------------- phase

function smooth(t) {
    t = Math.min(Math.max(t, 0), 1)
    return t * t * (3 - 2 * t)
}

// Night below -12 degrees (nautical dusk), twilight across -4 to 2,
// day above 10, crossfading in between. Whether twilight is sunrise or
// sunset depends on which way the sun is going. Returns the phases in
// play, as [["night", weight], ...].
function weights(elevation, rising) {
    var twilight = rising ? "sunrise" : "sunset"
    if (elevation < -12)
        return { label: "night", weights: [["night", 1]] }
    if (elevation < -4) {
        var t = smooth((elevation + 12) / 8)
        return { label: t < 0.5 ? "night" : twilight,
                 weights: [["night", 1 - t], [twilight, t]] }
    }
    if (elevation < 2)
        return { label: twilight, weights: [[twilight, 1]] }
    if (elevation < 10) {
        var u = smooth((elevation - 2) / 8)
        return { label: u < 0.5 ? twilight : "day",
                 weights: [[twilight, 1 - u], ["day", u]] }
    }
    return { label: "day", weights: [["day", 1]] }
}

// The config key for a phase's setting: ("sunrise", "Theme") is
// SunriseTheme.
function key(phase, what) {
    return phase.charAt(0).toUpperCase() + phase.slice(1) + what
}

// cfg is the wallpaper configuration (or anything with the same keys).
// Returns { base, deep, ink, tone, patterns, label, elevation }, where
// patterns is [[pattern, weight], ...] for Field's "mix" pattern.
function palette(cfg, date) {
    if (cfg.ThemeMode !== "sun") {
        var fixed = blend([[cfg.Theme, 1]])
        fixed.patterns = [[cfg.Pattern, 1]]
        fixed.label = cfg.Theme
        return fixed
    }

    var elevationAt = cfg.UseLocation
        ? function (d) { return sunElevation(d, Number(cfg.Latitude), Number(cfg.Longitude)) }
        : function (d) { return clockElevation(d, cfg.SunriseTime, cfg.SunsetTime) }
    var elevation = elevationAt(date)
    var rising = elevationAt(new Date(date.getTime() + 600000)) > elevation

    var phase = weights(elevation, rising)
    var result = blend(phase.weights.map(function (w) { return [cfg[key(w[0], "Theme")], w[1]] }))
    result.patterns = phase.weights.map(function (w) { return [cfg[key(w[0], "Pattern")], w[1]] })
    result.label = phase.label
    result.elevation = elevation
    return result
}