packages feed

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

-- |
-- Module      : Graphics.Hgg.Spec.Mark
-- Description : Mark kind (MarkKind), DAG types, and layer helper enums (a Spec leaf)
-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
-- License     : BSD-3-Clause
--
-- [日本語]: 'Graphics.Hgg.Spec' の module 分割で切り出した leaf。 幾何種別
--   'MarkKind'、 DAG 描画の型群 ('DAGSpec' 一式)、 layer 属性の enum
--   ('ColorEnc' / 'Position' / 'Side' / 'Coord' / facet 系 / 'MarkShape' /
--   'LineType' 等) を持つ。 依存は 'Graphics.Hgg.Spec.Column' ('ColRef') のみ。
--   公開 API は従来どおり 'Graphics.Hgg.Spec' (facade) が re-export する。
--   挙動・出力 (JSON tag 含む) は完全に不変。
-- [English]: A leaf split out from the 'Graphics.Hgg.Spec' module decomposition.
--   Holds the mark-kind type 'MarkKind', the DAG rendering type family (the
--   'DAGSpec' bundle), and layer-attribute enums ('ColorEnc' / 'Position' /
--   'Side' / 'Coord' / the facet family / 'MarkShape' / 'LineType', etc). The
--   only dependency is 'Graphics.Hgg.Spec.Column' ('ColRef'). The public API
--   continues to be re-exported by 'Graphics.Hgg.Spec' (the facade), as
--   before. Behavior and output (including JSON tags) are completely
--   unchanged.
{-# LANGUAGE DeriveGeneric             #-}
{-# LANGUAGE OverloadedStrings         #-}
module Graphics.Hgg.Spec.Mark
  ( -- * MarkKind
    MarkKind(..)
    -- * DAG
  , DAGNodeKind(..)
  , DAGLayoutAlgorithm(..)
  , DAGNode(..)
  , EdgeShapeKind(..)
  , RoutedEdge(..)
  , DAGEdge(..)
  , DAGPlate(..)
  , DAGSpec(..)
    -- * layer 補助 enum / spec
  , ConnectSpec(..)
  , defaultConnectSpec
  , ColorEnc(..)
  , YAxisSide(..)
  , Position(..)
  , Side(..)
  , Coord(..)
  , PolarOpts(..)
  , defaultPolarOpts
  , TernaryOpts(..)             -- ★ Phase 69 A4
  , defaultTernaryOpts
  , FacetScales(..)
  , freeScaleX
  , freeScaleY
  , FacetSpace(..)
  , freeSpaceX
  , freeSpaceY
    -- * shape / linetype
  , MarkShape(..)
  , ShapeMapEntry(..)
  , LineType(..)
  , lineTypeDash
  , lineTypeForIndex
  ) where

import           Data.Aeson      (FromJSON (..), ToJSON (..), Value (..),
                                  object, (.!=), (.:), (.:?), (.=))
import qualified Data.Aeson      as Aeson
import qualified Data.Aeson.Types as Aeson (typeMismatch)
import qualified Data.Char       as Char
import           Data.Monoid     (Last (..))
import           Data.Text       (Text)
import           GHC.Generics    (Generic)

import           Graphics.Hgg.Spec.Column (ColRef)

-- ===========================================================================
-- Layer (= 内側 Monoid)
-- ===========================================================================

-- | [日本語]: 1 layer の幾何種別。 12 種列挙、 実 render は段階追加
--   (= Scatter / Line / Bar / Histogram を先行)。
--   [English]: The geometric kind of a single layer. Twelve variants are
--   enumerated; actual render support is added incrementally (Scatter /
--   Line / Bar / Histogram first).
data MarkKind
  = MScatter | MLine | MBar | MHistogram | MBox | MHeatmap
    -- 統計特化 (Phase 26 §E)
  | MTrace | MDensity | MForest | MFunnel
    -- Phase 28 (Ch10 EDA): 頻度多角形 (= ggplot geom_freqpoly)。 histogram と同じ
    -- bin 化 (histBinning) で各 bin の count を求め、 bin 中心を折れ線で結ぶ。
    -- KDE の MDensity とは別物 (= ビン頻度の折れ線、 滑らかでない)。 lyHistDensity
    -- True で after_stat(density) = count/(群N*binW) 正規化 (面積 1)。 color
    -- aesthetic で群分割すると群ごとに別色の折れ線を重ねる (MDensity 同方式)。
  | MFreqPoly
    -- 半導体特化 (Phase 26 §F)
  | MWaferMap | MControl
    -- 統計線 (Phase 26 §C-2 #8)
  | MStatMean | MStatMedian
    -- Parallel coordinates (Phase 26 §C-2 #13)
  | MParallel
    -- DAG (Phase 26 §E-6 HBM ModelGraph)
  | MDAG
    -- Pie chart (Phase 26 S4-d)
  | MPie
    -- Waterfall (Phase 26 S5-c)
  | MWaterfall
    -- Contour (連続 x/y/z → marching squares の等高線 iso-line)
  | MContour
    -- Filled contour (等値帯の塗り = matplotlib contourf / ggplot geom_contour_filled。 Phase 24 A4)
  | MContourFilled
    -- Bin2d (連続 x/y/z → grid binning + セル平均を連続色で塗る = ggplot geom_bin2d)
  | MBin2d
    -- Tile (連続 x/y のセルを fill 値でベタ塗り = ggplot geom_tile/geom_raster。 1 行=1 セル・
    -- 再ビニングせず格子間隔から幅自動。 決定境界の連続軸塗りが主用途。 Phase 60)
  | MTile
    -- MCMC 診断
  | MAutocorr | MEss
    -- stem / step
  | MStep | MStem
    -- distribution 系
  | MViolin | MStrip | MSwarm | MRaincloud
    -- ridge / joyplot
  | MRidge
    -- area band (= 信頼区間 / 予測帯、 PPath fill 1 枚)
  | MBand
    -- 3D placeholder (Phase 26 §C-2 #15、 実装は別 Phase で hgg-3d)
  | MScatter3D
    -- Phase 11 A6: データ駆動テキストラベル (geom_text / geom_label)。 各 (x,y) 点に
    -- lyLabel 列の文字を描く。 MLabel は背景の角丸矩形付き (= ggplot geom_label)。
  | MText | MLabel
    -- Phase 11 A6-2: Q-Q plot (= ggplot stat_qq / geom_qq)。 encY = サンプル列。
    -- ソートした order statistic を y、 理論正規分位点 Φ⁻¹((i-0.5)/n) を x に取り
    -- scatter 系で描画する (= 正規性の視覚診断)。
  | MQQ
    -- Phase 11 A6-4: ECDF (= ggplot stat_ecdf)。 encX = サンプル列。 ソートして
    -- 右連続の階段 F(x)=#(≤x)/n を描く (y∈[0,1])。
  | MEcdf
    -- Phase 11 A6-4b: 区間 geom (= ggplot geom_linerange / geom_pointrange / geom_crossbar)。
    -- encX=x, encY=y(中心), errorY=半幅 (y±err)。 linerange=縦線のみ、 pointrange=縦線+中心点、
    -- crossbar=幅付き箱 (y±err) + 中央水平線。
  | MLineRange | MPointRange | MCrossbar
    -- Phase 16: stat-in (= ggplot stat_smooth(method="lm"/"…"))。 純タグ (回帰 fit は
    -- analyze-bridge の resolveStats が hanalyze で行い band+line layer に展開する)。
    -- encX=x, encY=y。 lyColor/lyStroke/lyAlpha 等の装飾はそのまま band/line に引き継がれる。
    -- MStatSmooth の knot 数は lyBinCount を流用。 renderer は MStat* を no-op (skip)。
  | MStatLM | MStatSmooth
    -- Phase 16 B3: 多項式回帰 (= ggplot stat_smooth(method="lm", formula=y~poly(x,deg)))。
    -- deg は lyBinCount を流用。 resolveStats が y~poly(x,deg) で fit し band+line に展開。
    -- MStatResid = 残差 vs fitted の診断散布図 (= base R plot(lm) #1)。 fit して
    -- (fitted, residual) を scatter に展開する。 いずれも renderer は MStat* を skip。
  | MStatPoly | MStatResid
    -- Phase 52.D2: Streamgraph (= 中心化積層 area、 ThemeRiver 風)。 encX=x, encY=y、
    -- color aes で系列分割。 各 x 点で系列 y を積層し baseline を -(Σy)/2 から開始する
    -- (silhouette 中心化)。 各系列を renderBand 同型の塗り polygon で描く。
  | MStream
    -- Phase 26 A2: vector field (quiver)。 encX=x, encY=y, lyEncU=u, lyEncV=v。
    -- 各 (x,y) に成分 (u,v) の矢印を描く (autoscale × lyArrowScale)。 magnitude
    -- 連続色は lyArrowMagnitude。 = matplotlib quiver / geom_segment(arrow=)。
  | MQuiver
    -- Phase 28 (Ch10 EDA): 2 カテゴリ変数の件数 (= ggplot geom_count / stat_sum)。
    -- encX/encY はともにカテゴリ列。 各 (x,y) セルの観測件数を集計し、 cell 中心に
    -- 面積 ∝ 件数 (= 半径 ∝ √件数) の点を打つ。 lySize で最大半径 px を上書き可。
  | MCount
    -- Phase 40: hexbin (六角ビニング = matplotlib hexbin / ggplot geom_hex)。 encX/encY は
    -- 連続列。 binwidth 正規化空間で d3-hexbin (Carr 1987) アルゴで点を六角セルに割当て count し、
    -- pointy-top 六角形を count→連続色 (Viridis) で塗る。 セル分割数は lyBinCount を流用 (既定 30)。
  | MHexbin
    -- Phase 51: custom mark (拡張可能な描画語彙)。 core を触らず 'customMark' で新プロット型を
    -- 足す拡張点。 payload (id/options/draw closure) は 'lyCustom' に持つ。 renderer は
    -- 'lyCustom' の 'cmDraw' を呼んで primitive を emit する (= registry 不要・closure が源)。
  | MCustom
  deriving (Show, Eq, Generic)

instance ToJSON   MarkKind
instance FromJSON MarkKind

-- | [日本語]: HBM ModelGraph DAG の node 種別 (= 汎用、 HBM 慣例の
--   latent/observed/deterministic/data を含む)。
--   描画形状 (PyMC 慣例): NodeLatent = 白楕円、 NodeObserved = 灰楕円、
--   NodeDeterministic = 白四角、 NodeData = 灰角丸四角、 NodeOther = 四角。
--   [English]: The node kind for an HBM ModelGraph DAG (a general-purpose
--   kind that also covers the HBM convention of latent/observed/
--   deterministic/data). Drawn shapes (following PyMC convention):
--   NodeLatent = white ellipse, NodeObserved = gray ellipse,
--   NodeDeterministic = white square, NodeData = gray rounded square,
--   NodeOther = square.
data DAGNodeKind = NodeLatent | NodeObserved | NodeDeterministic | NodeData | NodeOther
  deriving (Show, Eq, Ord, Generic)

instance ToJSON   DAGNodeKind
instance FromJSON DAGNodeKind

-- | [日本語]: DAG layout algorithm。
--   * 'LayoutManual'       ─ dnX / dnY をそのまま使う
--   * 'LayoutHierarchical' ─ topological sort + 同層 x 均等配置
--   * @LayoutForce@        ─ 将来対応予定 (= force-directed)
--   [English]: DAG layout algorithm.
--   * 'LayoutManual'       ─ uses dnX / dnY as-is
--   * 'LayoutHierarchical' ─ topological sort + evenly spaced x within a layer
--   * @LayoutForce@        ─ planned for the future (force-directed)
data DAGLayoutAlgorithm = LayoutManual | LayoutHierarchical
  deriving (Show, Eq, Generic)

instance ToJSON   DAGLayoutAlgorithm
instance FromJSON DAGLayoutAlgorithm

data DAGNode = DAGNode
  { dnId    :: !Text
  , dnLabel :: !Text
  , dnKind  :: !DAGNodeKind
  , dnDist  :: !(Maybe Text)  -- ★ 分布名 (= "Normal" / "HalfCauchy" 等、 PyMC 風 sub-label)
  , dnX     :: !Double        -- LayoutManual のみ参照、 他は layout で上書き
  , dnY     :: !Double
  } deriving (Show, Eq, Ord, Generic)

instance ToJSON   DAGNode
instance FromJSON DAGNode

-- | [日本語]: DAG edge。 'dePath' (= dummy 経由の control 点列) は
--   layout 計算後に埋まる。 JSON FromJSON はフィールド欠落時 'Nothing' default
--   (= aeson Generic 既定動作)、 旧 JSON との backward compat 維持。
--   [English]: A DAG edge. 'dePath' (the control-point list routed via a
--   dummy node) gets filled in after layout is computed. JSON FromJSON
--   defaults to 'Nothing' when the field is missing (the default aeson
--   Generic behavior), preserving backward compatibility with older JSON.
-- | [日本語]: edge routing の形状種別 (= Render.EdgeRoute の constructor を
--   spec に焼き込むための非依存 tag)。 StraightArrow/SplinePath/BezierPath/CubicPath に対応。
--   [English]: The shape kind for edge routing (a spec-side tag, independent
--   of the renderer, that mirrors the Render.EdgeRoute constructors).
--   Corresponds to StraightArrow/SplinePath/BezierPath/CubicPath.
data EdgeShapeKind = EShStraight | EShSpline | EShBezier | EShCubic
  deriving (Show, Eq, Generic)

instance ToJSON   EdgeShapeKind
instance FromJSON EdgeShapeKind

-- | [日本語]: HS が焼き込んだ routing 結果 (= pt 空間 = post-@toScreen@・pre-fit)。
--   HS @routeEdge@ が owner。 PS は描画 + @fitPrimsToArea@ のみ (option1 / DRY)。
--   'rePts' の意味は 'reKind' 依存: Straight=[port0,port1]、 Spline/Bezier=制御点列、
--   Cubic=先頭が始点で以後 3 点ずつ (ctrl1,ctrl2,end) の cubic segment 列。
--   [English]: The routing result baked in by HS (in point space, that is,
--   post-@toScreen@ and pre-fit). HS's @routeEdge@ owns this; PS only
--   draws it and applies @fitPrimsToArea@ (option 1 / DRY). The meaning of
--   'rePts' depends on 'reKind': Straight = [port0, port1], Spline/Bezier =
--   the control-point list, Cubic = the start point followed by cubic
--   segments of 3 points each (ctrl1, ctrl2, end).
data RoutedEdge = RoutedEdge
  { reKind :: !EdgeShapeKind
  , rePts  :: ![(Double, Double)]
  } deriving (Show, Eq, Generic)

instance ToJSON   RoutedEdge
instance FromJSON RoutedEdge

data DAGEdge = DAGEdge
  { deFrom :: !Text
  , deTo   :: !Text
  , dePath :: !(Maybe [(Double, Double)])
    -- ^ [日本語]: 中継 dummy 経由の制御点列 (= 始点と終点を含む 0..1 domain)。
    --   'Nothing' なら短 edge (= 直線描画)、 'Just [..]' なら spline 描画。
    --   [English]: The control-point list routed via a relay dummy node (in
    --   the 0..1 domain, including the start and end points). 'Nothing'
    --   means a short edge (drawn straight), 'Just [..]' means it is drawn
    --   as a spline.
  , deRoute :: !(Maybe RoutedEdge)
    -- ^ [日本語]: HS が layout 時に焼き込む pt 空間 routing (= PS と byte parity 用)。
    --   'Nothing' なら未 bake (= HS は live routeEdge、 PS は straight fallback)。
    --   aeson Generic は欠落時 Nothing default で旧 JSON と backward compat。
    --   [English]: The point-space routing that HS bakes in at layout time
    --   (used to keep byte parity with PS). 'Nothing' means it has not been
    --   baked yet (HS falls back to live routeEdge, PS falls back to a
    --   straight line). aeson Generic defaults to 'Nothing' when the field
    --   is missing, keeping backward compatibility with older JSON.
  } deriving (Show, Eq, Generic)

instance ToJSON   DAGEdge
instance FromJSON DAGEdge

-- | [日本語]: Plate (= PyMC スタイルの "repeated" group 囲み)。
--   含まれる node id 列を指定、 layout 時に bounding box を自動計算。
--   [English]: A plate (a PyMC-style "repeated" group enclosure). Specify
--   the contained node ids; the bounding box is computed automatically at
--   layout time.
data DAGPlate = DAGPlate
  { dpLabel   :: !Text       -- e.g. "course (10)" / "record (2396)"
  , dpNodeIds :: ![Text]
  } deriving (Show, Eq, Generic)

instance ToJSON   DAGPlate
instance FromJSON DAGPlate

data DAGSpec = DAGSpec
  { dsNodes  :: ![DAGNode]
  , dsEdges  :: ![DAGEdge]
  , dsLayout :: !DAGLayoutAlgorithm
  , dsPlates :: ![DAGPlate]   -- ★ Plate 群 (= PyMC スタイル grouping)
  } deriving (Show, Eq, Generic)

instance ToJSON   DAGSpec
instance FromJSON DAGSpec

-- | [日本語]: scatter 点を線で結ぶ設定。
--   PlotConfig.connectPoints / connectOrderColumn / connectGroupColumn /
--   connectColor / connectWidth / connectBeforePoints 等価。
--   [English]: Settings for connecting scatter points with a line.
--   Equivalent to PlotConfig.connectPoints / connectOrderColumn /
--   connectGroupColumn / connectColor / connectWidth / connectBeforePoints.
data ConnectSpec = ConnectSpec
  { csOrder  :: !(Last ColRef)   -- Nothing = データ順
  , csGroup  :: !(Last ColRef)   -- Nothing = 全点 1 本
  , csColor  :: !(Last Text)     -- Nothing = layer 色
  , csWidth  :: !(Last Double)   -- Nothing = 1.5
  , csBefore :: !Bool            -- True = 点より下に線、 False = 点より上
  } deriving (Show, Eq, Generic)

instance ToJSON   ConnectSpec
instance FromJSON ConnectSpec

-- ★ レコードフィールド形式 (位置依存撲滅・挙動不変)。csBefore のみ
--   Bool 左勝ち (非 Monoid) なので名前付きで温存。残りは素直な per-field `<>`。
instance Semigroup ConnectSpec where
  a <> b = ConnectSpec
    { csOrder  = csOrder a <> csOrder b
    , csGroup  = csGroup a <> csGroup b
    , csColor  = csColor a <> csColor b
    , csWidth  = csWidth a <> csWidth b
    , csBefore = csBefore a   -- 左 (= 最初に setup されたもの) を優先
    }

instance Monoid ConnectSpec where
  mempty = defaultConnectSpec

defaultConnectSpec :: ConnectSpec
defaultConnectSpec = ConnectSpec mempty mempty mempty mempty False

-- | [日本語]: 色 encoding: 列指定 (categorical) か 静的色 か 連続値 gradient。
--   [English]: Color encoding: a column reference (categorical), a static
--   color, or a continuous-value gradient.
data ColorEnc
  = ColorByCol        !ColRef    -- categorical: Okabe-Ito palette
  | ColorStatic       !Text      -- "red" / "#ff0000"
  | ColorByContinuous !ColRef    -- ★ Phase 26 §C-2 #9 連続値 → Viridis 風 gradient
  deriving (Generic, Show, Eq)

instance ToJSON   ColorEnc
instance FromJSON ColorEnc

-- | [日本語]: layer がどちらの Y 軸に属するか。
--   [English]: Which Y axis a layer belongs to.
data YAxisSide = YAxisLeft | YAxisRight
  deriving (Show, Eq, Generic)

instance ToJSON   YAxisSide
instance FromJSON YAxisSide

-- | [日本語]: bar の position adjustment (= ggplot position_*)。
--   1 カテゴリに複数系列 (= color/group aesthetic = @lyColor@ の 'ColorByCol') の棒を
--   どう配置するか。 'PosIdentity' (既定) = 従来挙動 (= color を見ず単色棒)。
--     * 'PosDodge' = 系列を横に並べる (slot を系列数で等分)
--     * 'PosStack' = 系列を縦に積む (cumsum、 y domain は群和の max)
--     * 'PosFill'  = stack を各カテゴリ合計 1 に正規化 (y domain = [0,1])
--   JSON tag: "identity" / "dodge" / "stack" / "fill" (PS Codec と一致)。
--   [English]: Bar position adjustment (equivalent to ggplot's position_*).
--   Controls how bars for multiple series in one category (that is, the
--   color/group aesthetic, a 'ColorByCol' in @lyColor@) are arranged.
--   'PosIdentity' (default) keeps the legacy behavior (single-color bars,
--   ignoring color).
--     * 'PosDodge' places series side by side (splitting the slot evenly
--       across series)
--     * 'PosStack' stacks series vertically (cumulative sum; the y domain
--       is the max of the group sums)
--     * 'PosFill'  normalizes each stack so every category sums to 1
--       (y domain = [0,1])
--   JSON tag: "identity" / "dodge" / "stack" / "fill" (matches the PS Codec).
data Position = PosIdentity | PosDodge | PosStack | PosFill
  deriving (Show, Eq, Generic)

positionJsonOptions :: Aeson.Options
positionJsonOptions = Aeson.defaultOptions
  { Aeson.constructorTagModifier = \s -> case s of
      'P':'o':'s':rest -> map Char.toLower rest
      other            -> other
  }

instance ToJSON Position where
  toJSON = Aeson.genericToJSON positionJsonOptions
  toEncoding = Aeson.genericToEncoding positionJsonOptions

instance FromJSON Position where
  parseJSON = Aeson.genericParseJSON positionJsonOptions

-- | [日本語]: violin の片側化。 'SideBoth' (既定) = 左右対称、 'SideRight' / 'SideLeft' =
--   半 violin (片側のみ。 raincloud の「雲」 や非対称比較で使う)。
--   JSON tag: "both" / "left" / "right" (PS Codec と一致)。
--   [English]: Half-sidedness for a violin. 'SideBoth' (default) is
--   left-right symmetric; 'SideRight' / 'SideLeft' produce a half violin
--   (one side only, used for the raincloud "cloud" and asymmetric
--   comparisons). JSON tag: "both" / "left" / "right" (matches the PS Codec).
data Side = SideBoth | SideLeft | SideRight
  deriving (Show, Eq, Generic)

sideJsonOptions :: Aeson.Options
sideJsonOptions = Aeson.defaultOptions
  { Aeson.constructorTagModifier = \s -> case s of
      'S':'i':'d':'e':rest -> map Char.toLower rest
      other                -> other }

instance ToJSON Side where
  toJSON = Aeson.genericToJSON sideJsonOptions
  toEncoding = Aeson.genericToEncoding sideJsonOptions

instance FromJSON Side where
  parseJSON = Aeson.genericParseJSON sideJsonOptions

-- | [日本語]: 極座標の角度パラメータ (= ggplot @coord_polar(start=, direction=)@)。
--   'polarStart' = θ 軸の開始角 (rad)。 0 = 真上 (12 時)。 'polarDirection' =
--   回転方向の符号。 +1 = 時計回り (既定)、 -1 = 反時計回り。 投影は
--   @Layout.polarPoint@ 1 箇所に閉じており、 @theta = start + dir * frac * 2π@
--   に一般化される (grid / スポーク / θ ラベルも同じ関数を通る)。
--   'defaultPolarOpts' = @start=0, direction=+1@ で、 Phase 64 A8 以前の
--   「真上始点・時計回り固定」 と完全一致する (= 既存図は不変)。
--   [English]: The angular parameters of polar coordinates (equivalent to
--   ggplot's @coord_polar(start=, direction=)@). 'polarStart' is the starting
--   angle of the theta axis in radians (0 = straight up, 12 o'clock).
--   'polarDirection' is the sign of the rotation direction: +1 clockwise
--   (default), -1 counter-clockwise. Projection is confined to
--   @Layout.polarPoint@ and generalized to @theta = start + dir * frac * 2π@
--   (grid, spokes, and theta labels all go through the same function).
--   'defaultPolarOpts' (@start=0, direction=+1@) exactly matches the previous
--   fixed "top-start, clockwise" behavior, so existing figures are unchanged.
data PolarOpts = PolarOpts
  { polarStart     :: !Double   -- ^ 開始角 (rad)。 0 = 真上
  , polarDirection :: !Double   -- ^ 回転方向。 +1 = 時計回り (既定)、 -1 = 反時計回り
  } deriving (Show, Eq, Generic)

-- | [日本語]: 極座標の既定 (@start=0, direction=+1@ = 真上始点・時計回り)。
--   [English]: The default polar options (@start=0, direction=+1@: top-start,
--   clockwise).
defaultPolarOpts :: PolarOpts
defaultPolarOpts = PolarOpts { polarStart = 0, polarDirection = 1 }

-- | [日本語]: ★ Phase 69 A4: 三角座標の向きオプション (polar の 'PolarOpts' と同型)。
--   'ternaryClockwise' = 頂点の巡回方向 (True=時計回り = 左下↔右下 を反転)。
--   'ternaryRotate' = どの成分を上頂点に置くかの回転 (0/120/240 度・反時計回りに巡回)。
--   既定 (@clockwise=False, rotate=0@) = a=上・b=左下・c=右下 (Phase 64 の従来配置)。
--   [English]: ★ Phase 69 A4: ternary orientation options (mirrors polar's
--   'PolarOpts'). 'ternaryClockwise' flips the vertex precession (True swaps
--   bottom-left ↔ bottom-right); 'ternaryRotate' cycles which component sits at
--   the top vertex (0/120/240 degrees, counter-clockwise). The default
--   (@clockwise=False, rotate=0@) is a=top / b=bottom-left / c=bottom-right.
data TernaryOpts = TernaryOpts
  { ternaryClockwise :: !Bool  -- ^ True = 時計回り (左下↔右下 反転)。 既定 False
  , ternaryRotate    :: !Int   -- ^ 上頂点の回転 0/120/240 度。 既定 0
  } deriving (Show, Eq, Generic)

defaultTernaryOpts :: TernaryOpts
defaultTernaryOpts = TernaryOpts { ternaryClockwise = False, ternaryRotate = 0 }

-- | [日本語]: 座標系 (= ggplot coord_*)。 'CoordCartesian' (既定) = 通常の
--   直交座標。 'CoordFlip' = x/y 軸を入れ替える (= coord_flip、 横棒グラフ等)。
--   'CoordPolarX' / 'CoordPolarY' = 極座標 (= coord_polar(theta="x"|"y"))。 theta 軸を
--   角度 ('PolarOpts' で start/direction 可変)、 他軸を半径に写す。 PolarY +
--   stacked bar = 円グラフ。 'CoordTernary' = 三角座標 (組成データ、 3 成分を
--   正三角形の 3 頂点へ; Phase 64 §3 で投影・grid を実装)。
--   JSON tag: "cartesian" / "flip" / "polarx" / "polary" / "ternary"
--   (PS Codec と一致)。 ★ 後方互換: polar は既定 'PolarOpts' なら従来どおり
--   文字列 tag ("polarx") を出力し、 start/direction を指定したときだけ
--   @{"tag":"polarx","start":..,"direction":..}@ の object 形になる。 読込は
--   両形を受ける (旧 spec の "polarx" 文字列も既定 opts で読める)。
--   [English]: The coordinate system (equivalent to ggplot's coord_*).
--   'CoordCartesian' (default) is the usual orthogonal coordinate system.
--   'CoordFlip' swaps the x/y axes (equivalent to coord_flip). 'CoordPolarX' /
--   'CoordPolarY' are polar coordinates (coord_polar(theta="x"|"y")); the
--   theta axis maps to an angle (start/direction configurable via 'PolarOpts')
--   and the other axis to the radius. 'CoordTernary' is the ternary
--   (compositional) coordinate system, mapping three components to the corners
--   of an equilateral triangle (projection/grid implemented in Phase 64 §3).
--   JSON tag: "cartesian" / "flip" / "polarx" / "polary" / "ternary" (matches
--   the PS Codec). Backward compatible: a polar coord with default 'PolarOpts'
--   still encodes as the bare string tag ("polarx"); only a non-default
--   start/direction produces the object form
--   @{"tag":"polarx","start":..,"direction":..}@. Decoding accepts both forms
--   (an old spec's "polarx" string reads back with the default opts).
data Coord
  = CoordCartesian
  | CoordFlip
  | CoordPolarX !PolarOpts
  | CoordPolarY !PolarOpts
  | CoordTernary !TernaryOpts   -- ★ Phase 69 A4: 向き opts を保持 (既定は従来配置)
  deriving (Show, Eq, Generic)

-- | [日本語]: polar coord の 'PolarOpts' を JSON 化する共通部。 既定なら文字列
--   tag のまま (後方互換)、 非既定なら object 形。
--   [English]: Shared JSON encoding for a polar coord's 'PolarOpts'. Default
--   opts keep the bare string tag (backward compatible); non-default opts use
--   the object form.
polarCoordJson :: Text -> PolarOpts -> Value
polarCoordJson tag o
  | o == defaultPolarOpts = String tag
  | otherwise = object [ "tag" .= tag
                       , "start" .= polarStart o
                       , "direction" .= polarDirection o ]

-- | [日本語]: ★ Phase 69 A4: ternary の 'TernaryOpts' を JSON 化。 polar と同方針
--   (既定なら文字列 tag "ternary"・非既定なら object 形)。 旧 spec の "ternary" 文字列は
--   既定 opts で読める (後方互換)。
--   [English]: ★ Phase 69 A4: JSON encoding for ternary's 'TernaryOpts', same
--   policy as polar (bare string tag "ternary" when default; object form
--   otherwise). Old specs' "ternary" string read back with the default opts.
ternaryCoordJson :: TernaryOpts -> Value
ternaryCoordJson o
  | o == defaultTernaryOpts = String "ternary"
  | otherwise = object [ "tag"       .= ("ternary" :: Text)
                       , "clockwise" .= ternaryClockwise o
                       , "rotate"    .= ternaryRotate o ]

instance ToJSON Coord where
  toJSON CoordCartesian    = String "cartesian"
  toJSON CoordFlip         = String "flip"
  toJSON (CoordTernary o)  = ternaryCoordJson o
  toJSON (CoordPolarX o)   = polarCoordJson "polarx" o
  toJSON (CoordPolarY o)   = polarCoordJson "polary" o

instance FromJSON Coord where
  parseJSON (String s) = case s of
    "cartesian" -> pure CoordCartesian
    "flip"      -> pure CoordFlip
    "polarx"    -> pure (CoordPolarX defaultPolarOpts)
    "polary"    -> pure (CoordPolarY defaultPolarOpts)
    "ternary"   -> pure (CoordTernary defaultTernaryOpts)
    _           -> fail ("Coord: unknown tag " ++ show s)
  parseJSON v@(Object o) = do
    tag <- o .: "tag"
    case (tag :: Text) of
      "polarx"  -> CoordPolarX <$> parsePolar
      "polary"  -> CoordPolarY <$> parsePolar
      "ternary" -> CoordTernary <$> parseTernary
      _         -> Aeson.typeMismatch "Coord" v
    where
      parsePolar = PolarOpts <$> o .:? "start" .!= polarStart defaultPolarOpts
                             <*> o .:? "direction" .!= polarDirection defaultPolarOpts
      parseTernary = TernaryOpts <$> o .:? "clockwise" .!= ternaryClockwise defaultTernaryOpts
                                 <*> o .:? "rotate" .!= ternaryRotate defaultTernaryOpts
  parseJSON v = Aeson.typeMismatch "Coord" v

-- | [日本語]: facet の scale 共有方式 (= ggplot facet_wrap(scales=))。
--   'FacetFixed' (既定) = 全 panel 共通 domain (値比較可)。 'FacetFreeX' = x 軸のみ
--   panel ごとに独立 domain、 'FacetFreeY' = y のみ、 'FacetFree' = 両軸独立。 free な
--   軸は各 panel が自分のデータ範囲で scale を持ち、 全 panel に軸を表示する。
--   JSON tag: "fixed" / "freex" / "freey" / "free" (PS Codec と一致)。
--   [English]: How facets share their scale (equivalent to ggplot's
--   facet_wrap(scales=)). 'FacetFixed' (default) uses one common domain
--   across all panels (values are comparable). 'FacetFreeX' gives each
--   panel an independent domain on the x axis only, 'FacetFreeY' on the y
--   axis only, and 'FacetFree' makes both axes independent. A free axis
--   lets each panel hold a scale sized to its own data range, and every
--   panel shows its axis. JSON tag: "fixed" / "freex" / "freey" / "free"
--   (matches the PS Codec).
data FacetScales = FacetFixed | FacetFreeX | FacetFreeY | FacetFree
  deriving (Show, Eq, Generic)

facetScalesJsonOptions :: Aeson.Options
facetScalesJsonOptions = Aeson.defaultOptions
  { Aeson.constructorTagModifier = \s -> case s of
      'F':'a':'c':'e':'t':rest -> map Char.toLower rest
      other                    -> other
  }

instance ToJSON FacetScales where
  toJSON = Aeson.genericToJSON facetScalesJsonOptions
  toEncoding = Aeson.genericToEncoding facetScalesJsonOptions

instance FromJSON FacetScales where
  parseJSON = Aeson.genericParseJSON facetScalesJsonOptions

-- | [日本語]: x 軸が free か (= 'FacetFreeX' または 'FacetFree')。
--   [English]: Whether the x axis is free (that is, 'FacetFreeX' or 'FacetFree').
freeScaleX :: FacetScales -> Bool
freeScaleX fs = fs == FacetFreeX || fs == FacetFree

-- | [日本語]: y 軸が free か (= 'FacetFreeY' または 'FacetFree')。
--   [English]: Whether the y axis is free (that is, 'FacetFreeY' or 'FacetFree').
freeScaleY :: FacetScales -> Bool
freeScaleY fs = fs == FacetFreeY || fs == FacetFree

-- | [日本語]: facet_grid の panel サイズ配分 (= ggplot facet_grid(space=))。
--   'SpaceFixed' (既定) = 全 panel 同サイズ。 'SpaceFreeX' = 列幅を各列の x データ範囲に
--   比例、 'SpaceFreeY' = 行高を各行の y データ範囲に比例、 'SpaceFree' = 両方。 通常
--   scales="free" と併用する (= 各 panel の単位長を揃える)。 JSON tag: "fixed" / "freex"
--   / "freey" / "free"。
--   [English]: How panel size is distributed in facet_grid (equivalent to
--   ggplot's facet_grid(space=)). 'SpaceFixed' (default) gives every panel
--   the same size. 'SpaceFreeX' makes column width proportional to each
--   column's x data range, 'SpaceFreeY' makes row height proportional to
--   each row's y data range, and 'SpaceFree' does both. This is usually
--   combined with scales="free" (to keep each panel's unit length
--   consistent). JSON tag: "fixed" / "freex" / "freey" / "free".
data FacetSpace = SpaceFixed | SpaceFreeX | SpaceFreeY | SpaceFree
  deriving (Show, Eq, Generic)

facetSpaceJsonOptions :: Aeson.Options
facetSpaceJsonOptions = Aeson.defaultOptions
  { Aeson.constructorTagModifier = \s -> case s of
      'S':'p':'a':'c':'e':rest -> map Char.toLower rest
      other                    -> other
  }

instance ToJSON FacetSpace where
  toJSON = Aeson.genericToJSON facetSpaceJsonOptions
  toEncoding = Aeson.genericToEncoding facetSpaceJsonOptions

instance FromJSON FacetSpace where
  parseJSON = Aeson.genericParseJSON facetSpaceJsonOptions

-- | [日本語]: 列幅が free か (= 'SpaceFreeX' または 'SpaceFree')。
--   [English]: Whether column width is free (that is, 'SpaceFreeX' or 'SpaceFree').
freeSpaceX :: FacetSpace -> Bool
freeSpaceX fs = fs == SpaceFreeX || fs == SpaceFree

-- | [日本語]: 行高が free か (= 'SpaceFreeY' または 'SpaceFree')。
--   [English]: Whether row height is free (that is, 'SpaceFreeY' or 'SpaceFree').
freeSpaceY :: FacetSpace -> Bool
freeSpaceY fs = fs == SpaceFreeY || fs == SpaceFree

-- | [日本語]: shape encoding 用 8 種。 PS Spec.purs MarkShape と一致 (= JSON round-trip)。
--   JSON: "circle" / "square" / ... ("MSh" prefix を constructorTagModifier で剥がす)。
--   [English]: Eight variants for shape encoding. Matches PS Spec.purs's
--   MarkShape (for JSON round-tripping). JSON: "circle" / "square" / ...
--   (the "MSh" prefix is stripped by constructorTagModifier).
data MarkShape
  = MShCircle | MShSquare | MShTriangle | MShDiamond | MShCross
  | MShSpade | MShHeart | MShClub
  deriving (Show, Eq, Generic)

markShapeJsonOptions :: Aeson.Options
markShapeJsonOptions = Aeson.defaultOptions
  { Aeson.constructorTagModifier = \s -> case s of
      'M':'S':'h':rest -> map Char.toLower rest
      other -> other
  }

instance ToJSON MarkShape where
  toJSON = Aeson.genericToJSON markShapeJsonOptions
  toEncoding = Aeson.genericToEncoding markShapeJsonOptions

instance FromJSON MarkShape where
  parseJSON = Aeson.genericParseJSON markShapeJsonOptions

-- | [日本語]: cat 名 → MarkShape の対応 1 件。 PS は `{ value, shape }` record で表現。
--   [English]: One category-name to MarkShape mapping. PS represents this as
--   a `{ value, shape }` record.
data ShapeMapEntry = ShapeMapEntry
  { smeValue :: !Text
  , smeShape :: !MarkShape
  } deriving (Show, Eq, Generic)

-- フィールドは sme- prefix (= 固定 shape combinator 'shape' とのセレクタ名衝突回避)。
-- JSON キーは従来通り value/shape を維持 (PS canvas round-trip 不変)。
shapeMapEntryJsonOptions :: Aeson.Options
shapeMapEntryJsonOptions = Aeson.defaultOptions
  { Aeson.fieldLabelModifier = \s -> case s of
      "smeValue" -> "value"
      "smeShape" -> "shape"
      other      -> other }

instance ToJSON ShapeMapEntry where
  toJSON     = Aeson.genericToJSON shapeMapEntryJsonOptions
  toEncoding = Aeson.genericToEncoding shapeMapEntryJsonOptions
instance FromJSON ShapeMapEntry where
  parseJSON  = Aeson.genericParseJSON shapeMapEntryJsonOptions

-- | [日本語]: linetype aesthetic 用 6 種 (= ggplot2 標準 linetype)。
--   JSON: "solid"/"dashed"/"dotted"/"dotdash"/"longdash"/"twodash"
--   ("Lt" prefix を constructorTagModifier で剥がし lowercase)。 PS Spec.purs LineType と一致。
--   [English]: Six variants for the linetype aesthetic (equivalent to
--   ggplot2's standard linetypes). JSON: "solid"/"dashed"/"dotted"/
--   "dotdash"/"longdash"/"twodash" (the "Lt" prefix is stripped and
--   lowercased by constructorTagModifier). Matches PS Spec.purs's LineType.
data LineType
  = LtSolid | LtDashed | LtDotted | LtDotDash | LtLongDash | LtTwoDash
  deriving (Show, Eq, Enum, Bounded, Generic)

lineTypeJsonOptions :: Aeson.Options
lineTypeJsonOptions = Aeson.defaultOptions
  { Aeson.constructorTagModifier = \s -> case s of
      'L':'t':rest -> map Char.toLower rest
      other        -> other
  }

instance ToJSON LineType where
  toJSON = Aeson.genericToJSON lineTypeJsonOptions
  toEncoding = Aeson.genericToEncoding lineTypeJsonOptions

instance FromJSON LineType where
  parseJSON = Aeson.genericParseJSON lineTypeJsonOptions

-- | [日本語]: LineType → SVG/Canvas dash array (px)。 Solid のみ [] (= 実線・dasharray 無し)。
--   値は ggplot2 既定の見た目に近い汎用パターン。 lsWidth に依存しない固定 px。
--   ※ Solid が [] を返すことが既存 SVG ゼロ diff の要 (dasharray attr を出さない)。
--   [English]: Maps a LineType to an SVG/Canvas dash array (in px). Only
--   Solid returns [] (a solid line with no dasharray). The values are a
--   generic pattern close to ggplot2's default look, expressed as fixed px
--   independent of lsWidth. Note: Solid returning [] is essential for
--   zero-diff SVG output (it must not emit a dasharray attribute).
lineTypeDash :: LineType -> [Double]
lineTypeDash lt = case lt of
  LtSolid    -> []
  LtDashed   -> [4, 4]
  LtDotted   -> [1, 3]
  LtDotDash  -> [1, 3, 4, 3]
  LtLongDash -> [8, 4]
  LtTwoDash  -> [2, 2, 6, 2]

-- | [日本語]: categorical linetype scale: cat index → LineType (Solid から巡回)。
--   ggplot scale_linetype_discrete 同様、 index 0 = solid。 PS と同一順。
--   [English]: A categorical linetype scale: category index to LineType
--   (cycling from Solid). As with ggplot's scale_linetype_discrete, index 0
--   is solid. The order matches PS.
lineTypeForIndex :: Int -> LineType
lineTypeForIndex i = cycle [minBound .. maxBound] !! i