@@ -852,51 +852,119 @@ export default class Scales {
852852 }
853853 } )
854854
855- // Second pass: align the y=0 pixel position across opted-in axes by picking
856- // R* = max(R_i) where R_i = -minY_i / (maxY_i - minY_i). The max-ratio
857- // anchor never clips a mixed-sign axis's negative range; positive-leaning
858- // axes get their min extended downward to match. With unified R*, the
859- // downstream baseLineY[i] computation in CoreUtils.getCalculatedRatios()
860- // yields the same pixel offset for every participant.
855+ // Second pass: align the y=0 pixel position across opted-in axes. We run
856+ // niceScale first on each participant's natural bounds so the tick step
857+ // and rounding are honored, then extend the bottom (or top) tick(s) of
858+ // each axis so the post-niceScale ratio matches R* — defined as the max
859+ // of natural ratios so no mixed-sign axis loses its negative range.
861860 if ( alignZeroParticipants . length >= 2 ) {
861+ // Pass 2a: run niceScale on each participant's natural bounds.
862+ alignZeroParticipants . forEach ( ( p ) => {
863+ this . setYScaleForIndex ( p . ai , p . minY , p . maxY )
864+ axisSeriesMap [ p . ai ] . forEach ( ( si ) => {
865+ minYArr [ si ] = gl . yAxisScale [ p . ai ] . niceMin
866+ maxYArr [ si ] = gl . yAxisScale [ p . ai ] . niceMax
867+ } )
868+ } )
869+
870+ // Pass 2b: target ratio from post-niceScale bounds (niceScale may have
871+ // rounded values outward, so natural ratios are no longer reliable).
862872 let targetRatio = 0
863873 alignZeroParticipants . forEach ( ( p ) => {
864- const range = p . maxY - p . minY
874+ const scale = gl . yAxisScale [ p . ai ]
875+ const range = scale . niceMax - scale . niceMin
865876 if ( range > 0 ) {
866- const r = - p . minY / range
877+ const r = - scale . niceMin / range
867878 if ( r > targetRatio ) targetRatio = r
868879 }
869880 } )
870881 if ( targetRatio > 1 ) targetRatio = 1
871882 if ( targetRatio < 0 ) targetRatio = 0
872883
884+ // Pass 2c: rebuild each participant's tick array with a step sized to
885+ // the new range so we keep tick counts reasonable AND land close to R*.
886+ // For each axis we (a) compute the exact target bound that satisfies R*,
887+ // (b) pick a nice step that yields roughly the original tick count over
888+ // the new range, then (c) snap both bounds to step multiples. The
889+ // residual ratio drift is bounded by one step / new-range.
890+ /** @param {number } raw */
891+ const niceRoundUp = ( raw ) => {
892+ if ( raw <= 0 ) return 1
893+ const mag = Math . floor ( Math . log10 ( raw ) )
894+ const magPow = Math . pow ( 10 , mag )
895+ const msd = raw / magPow // in [1, 10)
896+ let mult
897+ if ( msd <= 1 + 1e-9 ) mult = 1
898+ else if ( msd <= 2 + 1e-9 ) mult = 2
899+ else if ( msd <= 2.5 + 1e-9 ) mult = 2.5
900+ else if ( msd <= 5 + 1e-9 ) mult = 5
901+ else mult = 10
902+ return mult * magPow
903+ }
904+
873905 alignZeroParticipants . forEach ( ( p ) => {
874- let adjMinY = p . minY
875- let adjMaxY = p . maxY
876- const range = p . maxY - p . minY
877- const r = range > 0 ? - p . minY / range : 0
878- if ( Math . abs ( r - targetRatio ) > 1e-12 ) {
879- if ( targetRatio < 1 - 1e-12 && targetRatio > 1e-12 ) {
880- // Extend the side that doesn't carry actual data so we never clip
881- // real values. Positive-leaning axes grow downward; negative-
882- // leaning ones grow upward.
883- if ( r < targetRatio ) {
884- adjMinY = ( - targetRatio * p . maxY ) / ( 1 - targetRatio )
885- } else {
886- adjMaxY = ( - p . minY * ( 1 - targetRatio ) ) / targetRatio
887- }
888- } else if ( targetRatio >= 1 - 1e-12 ) {
889- // All-negative anchor — pin max at zero.
890- adjMaxY = 0
891- } else {
892- // targetRatio ~= 0, all-positive anchor — pin min at zero.
893- adjMinY = 0
894- }
906+ const scale = gl . yAxisScale [ p . ai ]
907+ if ( ! scale . result || scale . result . length < 2 ) return
908+ const range = scale . niceMax - scale . niceMin
909+ if ( range <= 0 ) return
910+ const r = - scale . niceMin / range
911+ if ( Math . abs ( r - targetRatio ) <= 1e-9 ) return
912+
913+ // Direction: positive-leaning (r < R*) extends min down,
914+ // negative-leaning (r > R*) extends max up.
915+ const extendMin = r < targetRatio && targetRatio < 1 - 1e-9
916+ const extendMaxOnly = ! extendMin && r > targetRatio && targetRatio > 1e-9
917+ if ( ! extendMin && ! extendMaxOnly ) return
918+
919+ const targetNiceMin = extendMin
920+ ? ( - targetRatio * scale . niceMax ) / ( 1 - targetRatio )
921+ : scale . niceMin
922+ const targetNiceMax = extendMaxOnly
923+ ? ( - scale . niceMin * ( 1 - targetRatio ) ) / targetRatio
924+ : scale . niceMax
925+
926+ const newRange = targetNiceMax - targetNiceMin
927+ if ( newRange <= 0 ) return
928+ const desiredTicks = Math . max ( scale . result . length , 5 )
929+ const newStep = niceRoundUp ( newRange / Math . max ( desiredTicks - 1 , 1 ) )
930+ if ( newStep <= 0 ) return
931+
932+ let newNiceMin
933+ let newNiceMax
934+ if ( extendMin ) {
935+ // Snap min down to a step multiple, then derive max from R* so
936+ // alignment stays exact. Grow max further if data demands it.
937+ newNiceMin = Math . floor ( targetNiceMin / newStep + 1e-9 ) * newStep
938+ const requiredMax =
939+ targetRatio > 1e-9
940+ ? ( newNiceMin * ( targetRatio - 1 ) ) / targetRatio
941+ : scale . niceMax
942+ const maxNeeded = Math . max ( requiredMax , scale . niceMax )
943+ newNiceMax = Math . ceil ( maxNeeded / newStep - 1e-9 ) * newStep
944+ } else {
945+ // Symmetric: snap max up, derive min from R*.
946+ newNiceMax = Math . ceil ( targetNiceMax / newStep - 1e-9 ) * newStep
947+ const requiredMin =
948+ targetRatio < 1 - 1e-9
949+ ? ( - targetRatio * newNiceMax ) / ( 1 - targetRatio )
950+ : scale . niceMin
951+ const minNeeded = Math . min ( requiredMin , scale . niceMin )
952+ newNiceMin = Math . floor ( minNeeded / newStep + 1e-9 ) * newStep
895953 }
896- this . setYScaleForIndex ( p . ai , adjMinY , adjMaxY )
954+
955+ scale . result = [ ]
956+ for (
957+ let v = newNiceMin ;
958+ v <= newNiceMax + newStep * 1e-9 ;
959+ v = Utils . preciseAddition ( v , newStep )
960+ ) {
961+ scale . result . push ( Utils . stripNumber ( v , 7 ) )
962+ }
963+ scale . niceMin = newNiceMin
964+ scale . niceMax = newNiceMax
897965 axisSeriesMap [ p . ai ] . forEach ( ( si ) => {
898- minYArr [ si ] = gl . yAxisScale [ p . ai ] . niceMin
899- maxYArr [ si ] = gl . yAxisScale [ p . ai ] . niceMax
966+ minYArr [ si ] = scale . niceMin
967+ maxYArr [ si ] = scale . niceMax
900968 } )
901969 } )
902970 } else if ( alignZeroParticipants . length === 1 ) {
0 commit comments