feat(grid layout): add outerBoundsClamp to limit shrink.
diff --git a/src/coord/cartesian/Grid.ts b/src/coord/cartesian/Grid.ts
index 50e51e2..bd91338 100644
--- a/src/coord/cartesian/Grid.ts
+++ b/src/coord/cartesian/Grid.ts
@@ -23,7 +23,7 @@
  * TODO Default cartesian
  */
 
-import {isObject, each, indexOf, retrieve3, keys, assert, eqNaN, find} from 'zrender/src/core/util';
+import {isObject, each, indexOf, retrieve3, keys, assert, eqNaN, find, retrieve2} from 'zrender/src/core/util';
 import {BoxLayoutReferenceResult, createBoxLayoutReference, getLayoutRect, LayoutRect} from '../../util/layout';
 import {
     createScaleByModel,
@@ -38,7 +38,7 @@
 import {ParsedModelFinder, ParsedModelFinderKnown, SINGLE_REFERRING} from '../../util/model';
 
 // Depends on GridModel, AxisModel, which performs preprocess.
-import GridModel, { GridOption, OUTER_BOUNDS_DEFAULT } from './GridModel';
+import GridModel, { GridOption, OUTER_BOUNDS_CLAMP_DEFAULT, OUTER_BOUNDS_DEFAULT } from './GridModel';
 import CartesianAxisModel from './AxisModel';
 import GlobalModel from '../../model/Global';
 import ExtensionAPI from '../../core/ExtensionAPI';
@@ -69,7 +69,7 @@
 import { error, log } from '../../util/log';
 import { AxisTickLabelComputingKind } from '../axisTickLabelBuilder';
 import { injectCoordSysByOption } from '../../core/CoordinateSystem';
-import { mathMax } from '../../util/number';
+import { mathMax, parsePositionSizeOption } from '../../util/number';
 
 type Cartesian2DDimensionName = 'x' | 'y';
 
@@ -237,18 +237,19 @@
                         );
                     }
                     noPxChange = layOutGridByOuterBounds(
-                        gridRect.clone(), 'axisLabel', gridRect, axesMap, axisBuilderSharedCtx, layoutRef
+                        gridRect.clone(), 'axisLabel', null, gridRect, axesMap, axisBuilderSharedCtx, layoutRef
                     );
                 }
             }
             else {
-                const {outerBoundsRect, parsedOuterBoundsContain} = prepareOuterBounds(
+                const {outerBoundsRect, parsedOuterBoundsContain, outerBoundsClamp} = prepareOuterBounds(
                     gridModel, gridRect, layoutRef
                 );
                 if (outerBoundsRect) {
                     // console.time('layOutGridByOuterBounds');
                     noPxChange = layOutGridByOuterBounds(
-                        outerBoundsRect, parsedOuterBoundsContain, gridRect, axesMap, axisBuilderSharedCtx, layoutRef
+                        outerBoundsRect, parsedOuterBoundsContain, outerBoundsClamp,
+                        gridRect, axesMap, axisBuilderSharedCtx, layoutRef
                     );
                     // console.timeEnd('layOutGridByOuterBounds');
                 }
@@ -740,6 +741,7 @@
 function layOutGridByOuterBounds(
     outerBoundsRect: BoundingRect,
     outerBoundsContain: ParsedOuterBoundsContain,
+    outerBoundsClamp: number[] | NullUndefined,
     gridRect: LayoutRect,
     axesMap: AxesMap,
     axisBuilderSharedCtx: AxisBuilderSharedContext,
@@ -775,7 +777,7 @@
     fillMarginOnOneDimension(gridRect, 1, NaN);
 
     const noPxChange = find(margin, item => item > 0) == null;
-    expandOrShrinkRect(gridRect, margin, true, true);
+    expandOrShrinkRect(gridRect, margin, true, true, outerBoundsClamp);
 
     updateAllAxisExtentTransByGridRect(axesMap, gridRect);
 
@@ -915,16 +917,17 @@
 
 function prepareOuterBounds(
     gridModel: GridModel,
-    gridRect: BoundingRect,
+    rawRridRect: BoundingRect,
     layoutRef: BoxLayoutReferenceResult,
 ): {
     outerBoundsRect: BoundingRect | NullUndefined
     parsedOuterBoundsContain: ParsedOuterBoundsContain
+    outerBoundsClamp: number[]
 } {
     let outerBoundsRect: BoundingRect | NullUndefined;
     const optionOuterBoundsMode = gridModel.get('outerBoundsMode', true);
     if (optionOuterBoundsMode === 'same') {
-        outerBoundsRect = gridRect.clone();
+        outerBoundsRect = rawRridRect.clone();
     }
     else if (optionOuterBoundsMode == null || optionOuterBoundsMode === 'auto') {
         outerBoundsRect = getLayoutRect(
@@ -952,7 +955,16 @@
         parsedOuterBoundsContain = optionOuterBoundsContain;
     }
 
-    return {outerBoundsRect, parsedOuterBoundsContain};
+    const outerBoundsClamp = [
+        parsePositionSizeOption(
+            retrieve2(gridModel.get('outerBoundsClampWidth', true), OUTER_BOUNDS_CLAMP_DEFAULT[0]), rawRridRect.width
+        ),
+        parsePositionSizeOption(
+            retrieve2(gridModel.get('outerBoundsClampHeight', true), OUTER_BOUNDS_CLAMP_DEFAULT[1]), rawRridRect.height
+        )
+    ];
+
+    return {outerBoundsRect, parsedOuterBoundsContain, outerBoundsClamp};
 }
 
 const resolveAxisNameOverlapForGrid: AxisBuilderSharedContext['resolveAxisNameOverlap'] = (
diff --git a/src/coord/cartesian/GridModel.ts b/src/coord/cartesian/GridModel.ts
index c3f1938..5461319 100644
--- a/src/coord/cartesian/GridModel.ts
+++ b/src/coord/cartesian/GridModel.ts
@@ -29,6 +29,7 @@
 // For backward compatibility, do not use a margin. Although the labels might touch the edge of
 // the canvas, the chart canvas probably does not have an border or a different background color within a page.
 export const OUTER_BOUNDS_DEFAULT = {left: 0, right: 0, top: 0, bottom: 0};
+export const OUTER_BOUNDS_CLAMP_DEFAULT = ['25%', '25%'];
 
 export interface GridOption
     extends ComponentOption, BoxLayoutOptionMixin, ShadowOptionMixin {
@@ -72,6 +73,15 @@
      */
     outerBoundsContain?: 'all' | 'axisLabel' | 'auto' | NullUndefined;
 
+    /**
+     * Available only when `outerBoundsMode` is not 'none'.
+     * Offer a constraint to not to shrink the grid rect causing smaller that width/height.
+     * A string means percent, like '30%', based on the original rect size
+     *  determined by `grid.top/right/bottom/left/width/height`.
+     */
+    outerBoundsClampWidth?: number | string;
+    outerBoundsClampHeight?: number | string;
+
     backgroundColor?: ZRColor;
     borderWidth?: number;
     borderColor?: ZRColor;
@@ -120,6 +130,8 @@
         outerBoundsMode: 'auto',
         outerBounds: OUTER_BOUNDS_DEFAULT,
         outerBoundsContain: 'all',
+        outerBoundsClampWidth: OUTER_BOUNDS_CLAMP_DEFAULT[0],
+        outerBoundsClampHeight: OUTER_BOUNDS_CLAMP_DEFAULT[1],
 
         // width: {totalWidth} - left - right,
         // height: {totalHeight} - top - bottom,
diff --git a/src/util/graphic.ts b/src/util/graphic.ts
index f6f5c01..4c5b996 100644
--- a/src/util/graphic.ts
+++ b/src/util/graphic.ts
@@ -597,7 +597,8 @@
     rect: TRect,
     delta: number[] | number | NullUndefined,
     shrinkOrExpand: boolean,
-    noNegative: boolean
+    noNegative: boolean,
+    minSize?: number[] // by default [0, 0].
 ): TRect {
     if (delta == null) {
         return rect;
@@ -623,25 +624,31 @@
         _tmpExpandRectDelta[2] = -_tmpExpandRectDelta[2];
         _tmpExpandRectDelta[3] = -_tmpExpandRectDelta[3];
     }
-    expandRectOnOneDimension(rect, _tmpExpandRectDelta, 'x', 'width', 3, 1);
-    expandRectOnOneDimension(rect, _tmpExpandRectDelta, 'y', 'height', 0, 2);
+    expandRectOnOneDimension(rect, _tmpExpandRectDelta, 'x', 'width', 3, 1, minSize && minSize[0] || 0);
+    expandRectOnOneDimension(rect, _tmpExpandRectDelta, 'y', 'height', 0, 2, minSize && minSize[1] || 0);
 
     return rect;
 }
 const _tmpExpandRectDelta = [0, 0, 0, 0];
 function expandRectOnOneDimension(
-    rect: RectLike, delta: number[], xy: 'x' | 'y', wh: 'width' | 'height', ltIdx: 3 | 0, rbIdx: 1 | 2
+    rect: RectLike,
+    delta: number[],
+    xy: 'x' | 'y',
+    wh: 'width' | 'height',
+    ltIdx: 3 | 0, rbIdx: 1 | 2,
+    minSize: number
 ): void {
     const deltaSum = delta[rbIdx] + delta[ltIdx];
     const oldSize = rect[wh];
     rect[wh] += deltaSum;
-    if (rect[wh] < 0) {
-        rect[wh] = 0;
+    minSize = mathMax(0, mathMin(minSize, oldSize));
+    if (rect[wh] < minSize) {
+        rect[wh] = minSize;
         // Try to make the position of the zero rect reasonable in most visual cases.
         rect[xy] += (
             delta[ltIdx] >= 0 ? -delta[ltIdx]
             : delta[rbIdx] >= 0 ? oldSize + delta[rbIdx]
-            : mathAbs(deltaSum) > 1e-8 ? oldSize * delta[ltIdx] / deltaSum
+            : mathAbs(deltaSum) > 1e-8 ? (oldSize - minSize) * delta[ltIdx] / deltaSum
             : 0
         );
     }
diff --git a/test/axis-layout-0.html b/test/axis-layout-0.html
index 2e09b34..0c904f5 100755
--- a/test/axis-layout-0.html
+++ b/test/axis-layout-0.html
@@ -623,6 +623,28 @@
                                 });
                             }
                         },
+                        ...['Width', 'Height'].map(prop => {
+                            return {
+                                type: 'select',
+                                text: `grid.outerBoundsClamp${prop}:`,
+                                options: [
+                                    {value: undefined},
+                                    {id: 'absolute', input: {type: 'range', min: -10, max: 500}, text: 'absolute'},
+                                    {id: 'percent', input: {type: 'range', min: -10, max: 100, suffix: '%'}, text: 'percent'},
+                                ],
+                                onchange() {
+                                    let val = this.value;
+                                    if (this.optionId === 'percent') {
+                                        val += '%'
+                                    }
+                                    chartSetOption(chart, {
+                                        grid: {
+                                            [`outerBoundsClamp${prop}`]: val
+                                        }
+                                    });
+                                }
+                            };
+                        }),
                         {
                             type: 'select',
                             text: 'grid.containLabel(deprecated):',