@@ -628,14 +628,95 @@ function mergeIntrinsicWithData(
628628}
629629
630630/**
631- * Resolve domain constraints from annotation, type-intrinsic rules, or data.
631+ * Snap-to-bound heuristic for bounded types like Percentage / PercentageChange.
632+ *
633+ * Each bound is snapped independently:
634+ * - If data approaches the intrinsic lower bound → snap min
635+ * - If data approaches the intrinsic upper bound → snap max
636+ * - If data exceeds a bound → don't snap that side (let VL auto-extend)
637+ *
638+ * Threshold: 25% of the *effective side range*.
639+ *
640+ * We err on the side of snapping, because:
641+ * - Semantic types are opt-in — the bound carries meaning by definition.
642+ * - A wrong snap (extra white space) is less harmful than a wrong
643+ * no-snap (viewer loses semantic reference, differences are
644+ * exaggerated and proximity to the bound is hidden).
645+ * - Only when data is clearly in the interior (> 25% away from each
646+ * bound) does the bound stop being a useful reference.
632647 *
633- * The effective domain is always the **union** of the intrinsic (semantic)
634- * domain and the actual data range, so that data points outside the
635- * type's natural bounds (e.g., Percentage > 100 %) are never clipped.
648+ * When the intrinsic domain straddles zero (lo < 0 < hi), zero acts as a
649+ * visual baseline (bar charts, contextual zero). Each bound's threshold
650+ * is computed relative to its distance from zero — not the full range —
651+ * so that snapping one side doesn't make values on the other side of zero
652+ * invisible (e.g., snapping to -100% when data has a tiny +0.2% bar).
653+ *
654+ * When the domain doesn't straddle zero (e.g., [0, 100]), the full range
655+ * is used as the reference.
656+ *
657+ * Examples for Percentage [0, 100] (threshold = 25, full range):
658+ * 20–45% → snap min=0 only (20 within 25 of 0; 45 far from 100)
659+ * 35–65% → no snap (both far from edges, in interior)
660+ * 55–82% → snap max=100 only (82 within 25 of 100; 55 far from 0)
661+ * 15–80% → snap both [0, 100] (15 near 0, 80 near 100)
662+ * 30–130% → no snap (130 exceeds 100 → no snap; 30 far from 0)
663+ *
664+ * Examples for PercentageChange [-1, 1] (threshold = 0.25 per side):
665+ * -0.03 to +0.05 → no snap (both far from ±0.75)
666+ * -0.70 to +0.30 → no snap (-0.70 > -0.75, not close enough)
667+ * -0.80 to +0.30 → snap min=-1 (-0.80 ≤ -0.75; +0.30 < 0.75)
668+ * -0.80 to +0.78 → snap both (both within 0.25 of edges)
669+ */
670+ export function snapToBoundHeuristic (
671+ intrinsic : [ number , number ] ,
672+ values : any [ ] ,
673+ ) : DomainConstraint | undefined {
674+ const nums = values . filter ( ( v : any ) => typeof v === 'number' && ! isNaN ( v ) ) ;
675+ if ( nums . length === 0 ) return undefined ;
676+
677+ const [ lo , hi ] = intrinsic ;
678+ const range = hi - lo ;
679+ if ( range <= 0 ) return undefined ;
680+
681+ const dataMin = Math . min ( ...nums ) ;
682+ const dataMax = Math . max ( ...nums ) ;
683+
684+ // When the domain straddles zero, compute each side's threshold relative
685+ // to its distance from zero. This prevents snapping one side from
686+ // stretching the axis so wide that values near zero on the other side
687+ // become invisible (sub-pixel bars).
688+ const zeroInside = lo < 0 && hi > 0 ;
689+ const thresholdLo = 0.25 * ( zeroInside ? ( 0 - lo ) : range ) ;
690+ const thresholdHi = 0.25 * ( zeroInside ? hi : range ) ;
691+
692+ let snapMin : number | undefined ;
693+ let snapMax : number | undefined ;
694+
695+ // Snap lower bound: data min is close to intrinsic lower bound
696+ // AND data doesn't go below it (if it does, VL auto-extends)
697+ if ( dataMin >= lo && dataMin <= lo + thresholdLo ) {
698+ snapMin = lo ;
699+ }
700+
701+ // Snap upper bound: data max is close to intrinsic upper bound
702+ // AND data doesn't exceed it
703+ if ( dataMax <= hi && dataMax >= hi - thresholdHi ) {
704+ snapMax = hi ;
705+ }
706+
707+ if ( snapMin === undefined && snapMax === undefined ) return undefined ;
708+
709+ return { min : snapMin , max : snapMax , clamp : false } ;
710+ }
711+
712+ /**
713+ * Resolve domain constraints from annotation, type-intrinsic rules, or data.
636714 *
637715 * Only truly fixed physical domains (Latitude, Longitude, Correlation)
638- * use hard clamping.
716+ * use hard clamping. Bounded types like Percentage use a snap-to-bound
717+ * heuristic: the axis extends to the theoretical endpoint (e.g., 100%)
718+ * only when data is close to it, avoiding wasted space when data is
719+ * concentrated in a small region.
639720 *
640721 * Priority: annotation.intrinsicDomain > type-intrinsic > data-inferred
641722 */
@@ -644,8 +725,18 @@ export function resolveDomainConstraint(
644725 annotation : SemanticAnnotation ,
645726 values : any [ ] ,
646727) : DomainConstraint | undefined {
647- // 1. Explicit annotation intrinsicDomain — soft merge with data
728+ const entry = getRegistryEntry ( semanticType ) ;
729+
730+ // 1. Explicit annotation intrinsicDomain
648731 if ( annotation . intrinsicDomain ) {
732+ // Proportion (Percentage) and SignedMeasure (PercentageChange, Profit):
733+ // use snap-to-bound heuristic on both ends independently.
734+ // Don't force the full theoretical range — only snap to a bound
735+ // when data approaches it (e.g., 97% → snap to 100%, -0.95 → snap to -1).
736+ if ( entry . t1 === 'Proportion' || entry . t1 === 'SignedMeasure' ) {
737+ return snapToBoundHeuristic ( annotation . intrinsicDomain , values ) ;
738+ }
739+ // All other types: soft merge (union of intrinsic + data)
649740 return mergeIntrinsicWithData ( annotation . intrinsicDomain , values , false ) ;
650741 }
651742
@@ -654,14 +745,13 @@ export function resolveDomainConstraint(
654745 if ( semanticType === 'Longitude' ) return mergeIntrinsicWithData ( [ - 180 , 180 ] , values , true ) ;
655746 if ( semanticType === 'Correlation' ) return mergeIntrinsicWithData ( [ - 1 , 1 ] , values , true ) ;
656747
657- // 3. Data-inferred intrinsic domain for Percentage / Rate
658- // Detect scale (0–1 fractional vs 0–100 whole-number) then soft-merge.
748+ // 3. Percentage without explicit annotation — detect scale and apply snap
659749 if ( semanticType === 'Percentage' ) {
660750 const nums = values . filter ( ( v : any ) => typeof v === 'number' && ! isNaN ( v ) ) ;
661751 if ( nums . length > 0 ) {
662752 const rep = detectPercentageRepresentation ( nums ) ;
663- const intrinsicMax = rep === '0-1' ? 1 : 100 ;
664- return mergeIntrinsicWithData ( [ 0 , intrinsicMax ] , nums , false ) ;
753+ const M = rep === '0-1' ? 1 : 100 ;
754+ return snapToBoundHeuristic ( [ 0 , M ] , values ) ;
665755 }
666756 }
667757
0 commit comments