packages feed

hgg-core-0.2.0.0: src/Graphics/Hgg/Spec/Axis.hs

-- |
-- Module      : Graphics.Hgg.Spec.Axis
-- Description : AxisSpec — configuration for a single axis (scale, format, breaks, rotation)
-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
-- License     : BSD-3-Clause
--
-- [日本語]: 'Graphics.Hgg.Spec' の module 分割で切り出し。 軸 1 本の宣言型設定
--   'AxisSpec' (log/sqrt/time scale・範囲・tick・回転・break) とその setter /
--   accessor を持つ。 Spec 内の他 module に依存しない leaf。 公開 API は従来どおり
--   'Graphics.Hgg.Spec' (facade) が re-export する。 挙動・出力は完全に不変。
--   [English]: Split out during the module split of 'Graphics.Hgg.Spec'. Holds
--   the declarative per-axis configuration type 'AxisSpec' (log/sqrt/time
--   scale, range, ticks, rotation, breaks) along with its setters and
--   accessors. A leaf module with no dependency on other modules in Spec.
--   The public API is unchanged: 'Graphics.Hgg.Spec' (the facade) still
--   re-exports it. Behavior and output are completely unaffected.
{-# LANGUAGE DeriveGeneric             #-}
{-# LANGUAGE OverloadedStrings         #-}
module Graphics.Hgg.Spec.Axis
  ( AxisKind(..)
  , AxisFormat(..)
  , AxisBreak(..)
  , AxisSpec(..)
  , linearAxis, logAxis, sqrtAxis, timeAxis
  , axisMin, axisMax, axisRange
  , axisFormat, axisRotate, axisTickLabels, hideTicks
  , axisBreak, axisBreaksAt, axisBreaksLabeled
  , axisKindOf, axisFormatOf, axisRotateOf, axisShowTicksOf
  , axTickValsOf, axTickLabelsOf
  , resolveAxisAngle, themeAngleOr0
  ) where

import           Data.Aeson      (FromJSON, ToJSON)
import           Data.Monoid     (Last (..))
import           Data.Text       (Text)
import           GHC.Generics    (Generic)

-- ===========================================================================
-- AxisSpec ─ 軸 1 本の設定 (LogScale + 軸 format)
-- ===========================================================================

-- | [日本語]: 軸 scale 種別。 線形 / 対数 / sqrt / time / ordinal / band を将来追加。
--   現状は AxisLinear / AxisLog / AxisSqrt / AxisTime。
--   [English]: The axis scale kind. Ordinal / band scales are planned for
--   the future. Currently supports AxisLinear / AxisLog / AxisSqrt /
--   AxisTime.
data AxisKind = AxisLinear | AxisLog | AxisSqrt | AxisTime
  deriving (Show, Eq, Generic)

instance ToJSON   AxisKind
instance FromJSON AxisKind

-- | [日本語]: 軸ラベルの数値表記。
--   [English]: The numeric display format for axis labels.
data AxisFormat
  = AxisIntegerFmt
  | AxisDecimalFmt !Int      -- 小数桁数
  | AxisExponentFmt !Int     -- 指数表記 N 桁
  | AxisTimeFmt !Text        -- ★ timestamp ms → date 文字列 (= "yyyy-MM-dd" 等)
  deriving (Show, Eq, Generic)

instance ToJSON   AxisFormat
instance FromJSON AxisFormat

-- | [日本語]: 軸 1 本の設定。 全 field を Maybe で Monoid 化、 後勝ち合成。
--   [English]: The configuration for a single axis. Every field is wrapped
--   in Maybe and turned into a Monoid, with later values winning on
--   combination.
data AxisBreak = AxisBreak { abFrom :: !Double, abTo :: !Double }
  deriving (Show, Eq, Generic)

instance ToJSON   AxisBreak
instance FromJSON AxisBreak

data AxisSpec = AxisSpec
  { axKind   :: !(Last AxisKind)
  , axFormat :: !(Last AxisFormat)
  , axMin    :: !(Last Double)
  , axMax    :: !(Last Double)
  , axRotate :: !(Last Double)    -- ★ 軸 label 回転 (度)
  , axBreaks :: ![AxisBreak]       -- ★ 軸不連続範囲
  , axShowTicks :: !(Last Bool)   -- ★ tick 表示 (= default true、 pairs/facet 内側 false)
  , axShowGrid  :: !(Last Bool)   -- ★ C-5 grid line 表示 (= default false)
    -- ★ 明示 tick 位置 (= ggplot scale_*_continuous(breaks=))。 非空なら
    --   自動 extendedBreaks を上書き。 numeric 軸のみ有効 (categorical は無視)。
  , axTickVals :: ![Double]
    -- ★ 明示 tick ラベル (= ggplot labels=)。 axTickVals と 1:1 対応
    --   (短ければ "" 埋め)。 空なら値を format して使う。
  , axTickLabels :: ![Text]
  } deriving (Show, Eq, Generic)

instance ToJSON   AxisSpec
instance FromJSON AxisSpec

-- ★ レコードフィールド形式 (位置依存撲滅・挙動不変)。axTickVals/axTickLabels
--   のみ「右が非空なら右」特殊合成 (list `<>` = 連結と別) を名前付きで温存。
instance Semigroup AxisSpec where
  a <> b = AxisSpec
    { axKind     = axKind a   <> axKind b
    , axFormat   = axFormat a <> axFormat b
    , axMin      = axMin a    <> axMin b
    , axMax      = axMax a    <> axMax b
    , axRotate   = axRotate a <> axRotate b
    , axBreaks   = axBreaks a <> axBreaks b
    , axShowTicks = axShowTicks a <> axShowTicks b
    , axShowGrid  = axShowGrid a  <> axShowGrid b
      -- ★ A4-d: 明示 break/label は「後勝ち」 (= 後から与えた breaks=/labels= が前の指定を
      --   完全に置換)。 空配列なら前の指定を温存。
    , axTickVals   = if null (axTickVals b)   then axTickVals a   else axTickVals b
    , axTickLabels = if null (axTickLabels b) then axTickLabels a else axTickLabels b
    }

instance Monoid AxisSpec where
  mempty = AxisSpec mempty mempty mempty mempty mempty [] mempty mempty [] []

-- | [日本語]: Last AxisSpec から AxisKind を取り出す (= default は AxisLinear)。
--   [English]: Extracts the AxisKind from a Last AxisSpec (defaults to
--   AxisLinear).
axisKindOf :: Last AxisSpec -> AxisKind
axisKindOf (Last Nothing)   = AxisLinear
axisKindOf (Last (Just as)) = case getLast (axKind as) of
  Just k  -> k
  Nothing -> AxisLinear

-- | [日本語]: Last AxisSpec から AxisFormat を取り出す (= default は AxisDecimalFmt 1
--   相当の auto)。
--   [English]: Extracts the AxisFormat from a Last AxisSpec (defaults to an
--   auto format roughly equivalent to AxisDecimalFmt 1).
axisFormatOf :: Last AxisSpec -> Maybe AxisFormat
axisFormatOf (Last Nothing)   = Nothing
axisFormatOf (Last (Just as)) = getLast (axFormat as)

-- | [日本語]: 'xAxis (linearAxis)' / 'xAxis (logAxis)' のような書き方の起点。
--   [English]: The starting point for expressions like 'xAxis (linearAxis)' /
--   'xAxis (logAxis)'.
linearAxis, logAxis, sqrtAxis :: AxisSpec
linearAxis = mempty { axKind = Last (Just AxisLinear) }
logAxis    = mempty { axKind = Last (Just AxisLog) }
sqrtAxis   = mempty { axKind = Last (Just AxisSqrt) }

-- | [日本語]: time axis (= 値 Unix timestamp ms、 pattern で date 文字列に format)。
--   [English]: A time axis (values are Unix timestamps in ms, formatted to
--   a date string via the given pattern).
timeAxis :: Text -> AxisSpec
timeAxis pat = mempty
  { axKind = Last (Just AxisTime)
  , axFormat = Last (Just (AxisTimeFmt pat)) }

-- | [日本語]: 軸不連続範囲 1 つを追加。
--   [English]: Adds a single axis discontinuity (break) range.
axisBreak :: Double -> Double -> AxisSpec
axisBreak from to = mempty { axBreaks = [AxisBreak { abFrom = from, abTo = to }] }

-- | [日本語]: A4-d: 明示 tick 位置 (= ggplot scale_*_continuous(breaks=))。 自動 tick を
--   これで上書き (numeric 軸のみ。 categorical 軸では無視)。 範囲外の値は描画時に
--   censor される。 例: @xAxis (axisBreaksAt [0,25,50,75,100])@。
--   [English]: A4-d: explicit tick positions (like ggplot's
--   scale_*_continuous(breaks=)). Overrides the automatic ticks (numeric
--   axes only; ignored on categorical axes). Values outside the range are
--   censored at render time. Example:
--   @xAxis (axisBreaksAt [0,25,50,75,100])@.
axisBreaksAt :: [Double] -> AxisSpec
axisBreaksAt vs = mempty { axTickVals = vs }

-- | [日本語]: A4-d: 明示 tick ラベル (= ggplot labels=)。 'axisBreaksAt' と組で使い、 i 番目の
--   break に i 番目のラベルを割り当てる (長さは breaks に揃える)。 単体指定でも
--   自動 break の順に割り当たるが、 通常は 'axisBreaksLabeled' を推奨。
--   [English]: A4-d: explicit tick labels (like ggplot's labels=). Used
--   together with 'axisBreaksAt' to assign the i-th label to the i-th break
--   (the length should match the breaks). Used alone, labels are assigned
--   in the order of the automatic breaks, but 'axisBreaksLabeled' is
--   usually recommended instead.
axisTickLabels :: [Text] -> AxisSpec
axisTickLabels ls = mempty { axTickLabels = ls }

-- | [日本語]: A4-d: break 位置とラベルを対で指定する便利関数 (= ggplot breaks=/labels= を一度に)。
--   例: @xAxis (axisBreaksLabeled [(0,\"low\"),(50,\"mid\"),(100,\"high\")])@。
--   [English]: A4-d: a convenience function that specifies break positions
--   and labels as pairs (setting ggplot's breaks=/labels= in one step).
--   Example: @xAxis (axisBreaksLabeled [(0,\"low\"),(50,\"mid\"),(100,\"high\")])@.
axisBreaksLabeled :: [(Double, Text)] -> AxisSpec
axisBreaksLabeled prs = mempty { axTickVals = map fst prs, axTickLabels = map snd prs }

-- | [日本語]: Last AxisSpec から明示 tick 位置を取り出す (未指定 = [])。
--   [English]: Extracts the explicit tick positions from a Last AxisSpec
--   (unspecified = []).
axTickValsOf :: Last AxisSpec -> [Double]
axTickValsOf (Last Nothing)   = []
axTickValsOf (Last (Just as)) = axTickVals as

-- | [日本語]: Last AxisSpec から明示 tick ラベルを取り出す (未指定 = [])。
--   [English]: Extracts the explicit tick labels from a Last AxisSpec
--   (unspecified = []).
axTickLabelsOf :: Last AxisSpec -> [Text]
axTickLabelsOf (Last Nothing)   = []
axTickLabelsOf (Last (Just as)) = axTickLabels as

-- | [日本語]: tick を隠す (= pairs / facet の内側 panel 用)。
--   [English]: Hides the ticks (for inner panels in pairs / facet layouts).
hideTicks :: AxisSpec
hideTicks = mempty { axShowTicks = Last (Just False) }

-- | [日本語]: 'xAxis (axisFormat (AxisDecimalFmt 2))' で軸 format を指定。
--   [English]: Specifies the axis format, for example via
--   'xAxis (axisFormat (AxisDecimalFmt 2))'.
axisFormat :: AxisFormat -> AxisSpec
axisFormat f = mempty { axFormat = Last (Just f) }

-- | [日本語]: 軸 label 回転 (度・__CCW = 反時計回りが正__、 R / matplotlib / ggplot と同じ規約)。
--   30 / 45 / 90 等。 例: @xAxis (axisRotate 90)@ で x 目盛ラベルを CCW 90°
--   (縦書き・下→上読み・y 軸タイトルと同じ向き)。 内部の SVG/canvas rotate は CW 正なので
--   'resolveAxisAngle' で符号反転して描画に渡す (公開 API は R 準拠の CCW に統一)。
--   [English]: The axis label rotation, in degrees
--   (__CCW = counter-clockwise is positive__, the same convention as R /
--   matplotlib / ggplot). Typical values are 30 / 45 / 90. For example,
--   @xAxis (axisRotate 90)@ rotates the x tick labels 90° CCW (vertical
--   text, read bottom-to-top, the same orientation as the y-axis title).
--   Since the internal SVG/canvas rotation is CW-positive,
--   'resolveAxisAngle' negates the sign before passing it to rendering
--   (the public API is uniformly CCW, matching R).
axisRotate :: Double -> AxisSpec
axisRotate deg = mempty { axRotate = Last (Just deg) }

-- | [日本語]: frontend-settings v0.1 §1.5: 軸 min 値。
--   [English]: frontend-settings v0.1 §1.5: the axis minimum value.
axisMin :: Double -> AxisSpec
axisMin v = mempty { axMin = Last (Just v) }

-- | [日本語]: frontend-settings v0.1 §1.5: 軸 max 値。
--   [English]: frontend-settings v0.1 §1.5: the axis maximum value.
axisMax :: Double -> AxisSpec
axisMax v = mempty { axMax = Last (Just v) }

-- | [日本語]: frontend-settings v0.1 §1.5: 軸 min + max 同時指定。
--   [English]: frontend-settings v0.1 §1.5: sets the axis min and max
--   together.
axisRange :: Double -> Double -> AxisSpec
axisRange lo hi = mempty { axMin = Last (Just lo), axMax = Last (Just hi) }

-- | [日本語]: Last AxisSpec から軸 rotation を取り出す (= default 0)。
--   [English]: Extracts the axis rotation from a Last AxisSpec (defaults to
--   0).
axisRotateOf :: Last AxisSpec -> Double
axisRotateOf (Last Nothing)   = 0
axisRotateOf (Last (Just as)) = case getLast (axRotate as) of
  Just d  -> d
  Nothing -> 0

-- | [日本語]: 軸目盛りラベルの回転角を解決 (__CCW 正・canonical__)。
--   'axisRotate' / theme axis.text angle も内部 @tsRotate@ も __CCW 正__ (R/matplotlib/ggplot 準拠)
--   で一貫。 CW の device (SVG/canvas/rasterific) への変換は __各 backend の emit で 1 回だけ__ 行う
--   (PDF は y-up=CCW ゆえ恒等)。 per-axis 明示指定を最優先、 無ければ theme override、 無ければ 0。
--   [English]: Resolves the rotation angle for axis tick labels
--   (__CCW positive, canonical__). 'axisRotate', the theme's axis.text angle,
--   and the internal @tsRotate@ are all consistently __CCW positive__
--   (matching R/matplotlib/ggplot). The conversion to the CW-positive
--   device coordinate system (SVG/canvas/rasterific) happens
--   __exactly once, in each backend's emit step__ (a no-op for PDF, since
--   y-up means CCW already). Explicit per-axis settings take highest
--   priority, falling back to the theme override, then to 0.
resolveAxisAngle :: Last AxisSpec -> Last Double -> Double
resolveAxisAngle (Last (Just as)) themeAngle
  | Just d <- getLast (axRotate as) = d
  | otherwise                       = themeAngleOr0 themeAngle
resolveAxisAngle (Last Nothing) themeAngle = themeAngleOr0 themeAngle

themeAngleOr0 :: Last Double -> Double
themeAngleOr0 a = case getLast a of
  Just d  -> d
  Nothing -> 0

-- | [日本語]: Last AxisSpec から axShowTicks を取り出す (= default True、 Nothing も True 扱い)。
--   [English]: Extracts axShowTicks from a Last AxisSpec (defaults to True;
--   Nothing is also treated as True).
axisShowTicksOf :: Last AxisSpec -> Bool
axisShowTicksOf (Last Nothing)   = True
axisShowTicksOf (Last (Just as)) = case getLast (axShowTicks as) of
  Just b  -> b
  Nothing -> True