packages feed

granite-0.6.0.0: src/Granite/Scale.hs

{-# LANGUAGE Strict #-}

{- |
Module      : Granite.Scale
Copyright   : (c) 2025
License     : MIT
Maintainer  : mschavinda@gmail.com

Scale training: turn a declarative 'Scale' into a 'TrainedScale' that
projects data values onto [0,1] and generates round-number ticks.
-}
module Granite.Scale (
    TrainedScale (..),
    train,
    niceTicks,
    niceNum,
) where

import Data.Text (Text)

import Granite.Format (Formatter (..), runFormatter)
import Granite.Internal.Util (eps)
import Granite.Spec (
    BreaksSpec (..),
    Expand (..),
    LogBase (..),
    Scale (..),
    ScaleOpts (..),
 )

data TrainedScale = TrainedScale
    { tsDomain :: !(Double, Double)
    , tsProject :: !(Double -> Double)
    , tsUnproject :: !(Double -> Double)
    , tsBreaks :: ![Double]
    , tsLabels :: ![Text]
    }

train :: Scale -> (Double, Double) -> TrainedScale
train scale dataRange = case scale of
    SLinear opts -> trainLinear opts dataRange
    SLog base opts -> trainLog base opts dataRange
    SSqrt opts -> trainSqrt opts dataRange
    SIdentity -> trainIdentity dataRange
    SReverse inner -> reverseScale (train inner dataRange)
    SDiscrete -> trainLinear (defaultOpts BreaksNice) dataRange
    SColorContinuous _ -> trainLinear (defaultOpts BreaksNice) dataRange
    SColorDiscrete _ -> trainLinear (defaultOpts BreaksNice) dataRange

defaultOpts :: BreaksSpec -> ScaleOpts
defaultOpts brk =
    ScaleOpts
        { scaleDomain = Nothing
        , scaleBreaks = brk
        , scaleLabels = FormatDefault
        , scaleExpand = Expand 0.05 0
        , scaleClip = False
        }

trainLinear :: ScaleOpts -> (Double, Double) -> TrainedScale
trainLinear opts dataRange =
    let (lo, hi) = expandRange (scaleExpand opts) (overrideRange (scaleDomain opts) dataRange)
        span_ = hi - lo + eps
        project v = (v - lo) / span_
        unproject t = lo + t * span_
        breaks = chooseBreaks (scaleBreaks opts) (lo, hi)
        labels = map (runFormatter (scaleLabels opts)) breaks
     in TrainedScale (lo, hi) project unproject breaks labels

{- | Log scales need a strictly positive domain. When the data range
contains zero or negatives we fall back to a window 3 decades below
the data max (or [1, 10] if neither bound is positive).
-}
trainLog :: LogBase -> ScaleOpts -> (Double, Double) -> TrainedScale
trainLog base opts (dlo, dhi) =
    let safeLo
            | dlo > 0 = dlo
            | dhi > 0 = dhi / 1000
            | otherwise = 1
        safeHi
            | dhi > safeLo = dhi
            | otherwise = safeLo * 10
        (lo, hi) =
            expandLog (scaleExpand opts) (overrideRange (scaleDomain opts) (safeLo, safeHi))
        b = logBaseConst base
        ll = logBase b lo
        lh = logBase b hi
        span_ = lh - ll + eps
        project v = (logBase b (max safeLo v) - ll) / span_
        unproject t = b ** (ll + t * span_)
        breaks = case scaleBreaks opts of
            BreaksAt xs -> xs
            BreaksCount n -> sampleLogBreaks b lo hi n
            BreaksNice -> integerPowers b lo hi
        labels = map (runFormatter (scaleLabels opts)) breaks
     in TrainedScale (lo, hi) project unproject breaks labels

logBaseConst :: LogBase -> Double
logBaseConst Base2 = 2
logBaseConst BaseE = exp 1
logBaseConst Base10 = 10

integerPowers :: Double -> Double -> Double -> [Double]
integerPowers b lo hi =
    let kLo = floor (logBase b lo) :: Int
        kHi = ceiling (logBase b hi) :: Int
        n = kHi - kLo + 1
        maxTicks = 10
     in if n <= maxTicks
            then [b ** fromIntegral k | k <- [kLo .. kHi]]
            else
                let stride = (n + maxTicks - 1) `div` maxTicks
                 in [b ** fromIntegral k | k <- [kLo, kLo + stride .. kHi]]

sampleLogBreaks :: Double -> Double -> Double -> Int -> [Double]
sampleLogBreaks b lo hi n
    | n < 2 = [lo, hi]
    | otherwise =
        let ll = logBase b lo
            lh = logBase b hi
            step = (lh - ll) / fromIntegral (n - 1)
         in [b ** (ll + step * fromIntegral i) | i <- [0 .. n - 1]]

trainSqrt :: ScaleOpts -> (Double, Double) -> TrainedScale
trainSqrt opts dataRange =
    let (lo0, hi0) = overrideRange (scaleDomain opts) dataRange
        lo = max 0 lo0
        hi = max lo hi0
        (lo', hi') = expandRange (scaleExpand opts) (lo, hi)
        sLo = sqrt (max 0 lo')
        sHi = sqrt (max sLo hi')
        span_ = sHi - sLo + eps
        project v = (sqrt (max 0 v) - sLo) / span_
        unproject t =
            let s = sLo + t * span_
             in s * s
        breaks = chooseBreaks (scaleBreaks opts) (lo', hi')
        labels = map (runFormatter (scaleLabels opts)) breaks
     in TrainedScale (lo', hi') project unproject breaks labels

trainIdentity :: (Double, Double) -> TrainedScale
trainIdentity (lo, hi) =
    let breaks = chooseBreaks BreaksNice (lo, hi)
        labels = map (runFormatter FormatDefault) breaks
     in TrainedScale
            { tsDomain = (lo, hi)
            , tsProject = id
            , tsUnproject = id
            , tsBreaks = breaks
            , tsLabels = labels
            }

reverseScale :: TrainedScale -> TrainedScale
reverseScale ts =
    ts
        { tsProject = \v -> 1 - tsProject ts v
        , tsUnproject = tsUnproject ts . (1 -)
        }

overrideRange :: Maybe (Double, Double) -> (Double, Double) -> (Double, Double)
overrideRange Nothing r = r
overrideRange (Just (a, b)) _ = (a, b)

expandRange :: Expand -> (Double, Double) -> (Double, Double)
expandRange (Expand m a) (lo, hi) =
    let pad = (hi - lo) * m + a
     in (lo - pad, hi + pad)

expandLog :: Expand -> (Double, Double) -> (Double, Double)
expandLog (Expand m _) (lo, hi)
    | m <= 0 = (lo, hi)
    | otherwise =
        let factor = (hi / lo) ** m
         in (lo / factor, hi * factor)

chooseBreaks :: BreaksSpec -> (Double, Double) -> [Double]
chooseBreaks brk (lo, hi) = case brk of
    BreaksAt xs -> xs
    BreaksCount n -> niceTicks (lo, hi) n
    BreaksNice -> niceTicks (lo, hi) 5

{- | Heckbert "loose label" tick selection. Round-number positions in
@{1, 2, 2.5, 5} × 10^k@ that bracket the data range.
-}
niceTicks :: (Double, Double) -> Int -> [Double]
niceTicks (lo, hi) target0
    | not (isValid lo && isValid hi) || lo == hi = [lo]
    | lo > hi = niceTicks (hi, lo) target0
    | otherwise =
        let target = max 2 target0
            range = niceNum (hi - lo) False
            step = niceNum (range / fromIntegral (target - 1)) True
            gMin = (fromIntegral :: Int -> Double) (floor (lo / step)) * step
            gMax = (fromIntegral :: Int -> Double) (ceiling (hi / step)) * step
            n = round ((gMax - gMin) / step) + 1 :: Int
         in [gMin + step * fromIntegral i | i <- [0 .. n - 1]]
  where
    isValid x = not (isNaN x || isInfinite x)

niceNum :: Double -> Bool -> Double
niceNum 0 _ = 1
niceNum x roundIt =
    let absX = abs x
        sign = if x < 0 then (-1) else 1
        exp10 = floor (logBase 10 absX) :: Int
        f = absX / (10 ** fromIntegral exp10)
        nf
            | roundIt =
                if f < 1.5
                    then 1
                    else
                        if f < 3
                            then 2
                            else
                                if f < 7
                                    then 5
                                    else 10
            | f <= 1 = 1
            | f <= 2 = 2
            | f <= 5 = 5
            | otherwise = 10
     in sign * nf * (10 ** fromIntegral exp10)