hgg-custom-0.2.0.0: src/Graphics/Hgg/Custom/Dendrogram.hs
-- |
-- Module : Graphics.Hgg.Custom.Dendrogram
-- Description : Draws a hierarchical-clustering dendrogram as a custom mark
-- Copyright : (c) 2026 Aelysce Project (Toshiaki Honda)
-- License : BSD-3-Clause
--
-- [日本語]: dendrogram を __custom mark__ で本実装する。 core (@MarkKind@) は
-- 触らない (add-on package)。
--
-- [English]: Implements the dendrogram proper as a __custom mark__. It does
-- not touch core (@MarkKind@) — this is an add-on package.
--
-- [日本語]: 設計 = __焼き込み (baked segments)__: 樹形図の U 字リンクを「計算済みの線分列 ('DendroSeg')」
-- として 'DendroPayload' に持ち、 それを @cmOptions@ (JSON) に焼き込む。 HS の draw closure は
-- payload を直接使い、 PS (canvas) の registry は同じ payload JSON を読む — どちらも「線分を
-- proj して 'PLine' で描くだけ」なので HS=PS parity が自明。 clustering/レイアウト算法は
-- __呼び出し側 (analyze の @dendrogramOf@ 等)__ が行い、 その結果の線分だけをここへ渡す。
--
-- [English]: The design is __baked segments__: the U-shaped links of the tree
-- are held in 'DendroPayload' as a precomputed segment list ('DendroSeg'),
-- which is then baked into @cmOptions@ (JSON). The HS draw closure consumes
-- the payload directly, and the PS (canvas) registry reads the same payload
-- JSON — since both merely project the segments and draw them with 'PLine',
-- HS=PS parity is self-evident. The clustering and layout algorithms are run
-- by __the caller (for example analyze's @dendrogramOf@)__, which passes only
-- the resulting segments here.
--
-- [日本語]: 葉ラベル・軸は本 mark の外 (呼び出し側が数値 x 軸 + @axisBreaksLabeled@ で付ける)。 本 mark は
-- __U 字リンクの線分のみ__を描く。
--
-- [English]: Leaf labels and axes live outside this mark (the caller adds them
-- with a numeric x axis plus @axisBreaksLabeled@). This mark draws
-- __only the U-link segments__.
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE OverloadedStrings #-}
module Graphics.Hgg.Custom.Dendrogram
( -- * 焼き込みペイロード
DendroSeg(..)
, DendroPayload(..)
-- * mark
, dendrogramMark
, drawDendro
-- * PS registry で使う mark id
, dendrogramMarkId
) where
import Data.Aeson (FromJSON (..), ToJSON (..))
import qualified Data.Aeson as Aeson
import qualified Data.Char as Char
import Data.Text (Text)
import qualified Data.Vector as V
import GHC.Generics (Generic)
import Graphics.Hgg.Primitive (Point (..), Primitive (..), solid)
import Graphics.Hgg.Spec (ColRef (ColNum), Layer, RenderCtx (..),
customMarkWith, encX, encY)
-- | [日本語]: dendrogram の 1 線分 (data 座標)。 x = 葉 slot / node 中点、 y = マージ高 (height)。
-- JSON キーは prefix @seg@ を落とす: @x1/y1/x2/y2/color/width@。
-- [English]: A single dendrogram segment (in data coordinates). x is the
-- leaf slot / node midpoint, y is the merge height. The JSON keys drop the
-- @seg@ prefix: @x1/y1/x2/y2/color/width@.
data DendroSeg = DendroSeg
{ segX1 :: !Double
, segY1 :: !Double
, segX2 :: !Double
, segY2 :: !Double
, segColor :: !Text
, segWidth :: !Double
} deriving (Show, Eq, Generic)
segOptions :: Aeson.Options
segOptions = Aeson.defaultOptions { Aeson.fieldLabelModifier = lowerFirst . drop 3 }
instance ToJSON DendroSeg where
toJSON = Aeson.genericToJSON segOptions
toEncoding = Aeson.genericToEncoding segOptions
instance FromJSON DendroSeg where
parseJSON = Aeson.genericParseJSON segOptions
-- | [日本語]: dendrogram 全体の焼き込みペイロード。 線分列 + 軸 range (葉方向 / height 方向)。
-- JSON キーは prefix @dp@ を落とす: @segments/xRange/yRange@ (range は @[lo,hi]@ 配列)。
-- [English]: The baked payload for the whole dendrogram: the segment list
-- plus the axis ranges (leaf direction / height direction). The JSON keys
-- drop the @dp@ prefix: @segments/xRange/yRange@ (each range is a
-- @[lo,hi]@ array).
data DendroPayload = DendroPayload
{ dpSegments :: ![DendroSeg]
, dpXRange :: !(Double, Double)
-- ^ [日本語]: 葉方向 range (例 -0.6 .. n-0.4)。
-- [English]: Leaf-direction range (e.g. -0.6 .. n-0.4).
, dpYRange :: !(Double, Double)
-- ^ [日本語]: height 方向 range (例 0 .. maxH*1.05)。
-- [English]: Height-direction range (e.g. 0 .. maxH*1.05).
} deriving (Show, Eq, Generic)
payloadOptions :: Aeson.Options
payloadOptions = Aeson.defaultOptions { Aeson.fieldLabelModifier = lowerFirst . drop 2 }
instance ToJSON DendroPayload where
toJSON = Aeson.genericToJSON payloadOptions
toEncoding = Aeson.genericToEncoding payloadOptions
instance FromJSON DendroPayload where
parseJSON = Aeson.genericParseJSON payloadOptions
lowerFirst :: String -> String
lowerFirst (c : cs) = Char.toLower c : cs
lowerFirst [] = []
-- | [日本語]: PS registry (canvas) と共有する安定 mark id。
-- [English]: The stable mark id shared with the PS registry (canvas).
dendrogramMarkId :: Text
dendrogramMarkId = "dendrogram"
-- | [日本語]: dendrogram を 'Layer' として返す (= 普通の mark 同様 @layer (...)@ に入れて使う)。
-- 線分を @cmOptions@ に焼き込み、 'encX'/'encY' で軸 range を束ねる (不可視 anchor 不要)。
-- [English]: Returns the dendrogram as a 'Layer' (used inside @layer (...)@
-- just like an ordinary mark). Bakes the segments into @cmOptions@ and
-- binds the axis range via 'encX'/'encY' (no invisible anchor needed).
dendrogramMark :: DendroPayload -> Layer
dendrogramMark p =
customMarkWith dendrogramMarkId (toJSON p) (drawDendro p)
<> encX (rangeCol (dpXRange p))
<> encY (rangeCol (dpYRange p))
where
rangeCol (lo, hi) = ColNum (V.fromList [lo, hi])
-- | [日本語]: payload の線分を 'RenderCtx' で proj して 'PLine' を emit する draw 関数
-- (HS closure が源。 PS registry も同型の draw を手登録する = parity)。
-- [English]: The draw function that projects the payload's segments via
-- 'RenderCtx' and emits 'PLine's. The HS closure is the source of truth;
-- the PS registry hand-registers an equivalent draw function (for parity).
drawDendro :: DendroPayload -> RenderCtx -> [Primitive]
drawDendro p ctx =
[ PLine (proj (segX1 s) (segY1 s)) (proj (segX2 s) (segY2 s))
(solid (segColor s) (segWidth s))
| s <- dpSegments p ]
where
proj x y = uncurry Point (rcProjectXY ctx x y)