跳转到内容

User:A2569875/Module:Graph/ChartPolar.js

维基百科,自由的百科全书
注意:保存之后,你必须清除浏览器缓存才能看到做出的更改。Google Chrome、Firefox、Microsoft Edge及Safari:按住⇧ Shift键并单击工具栏的“刷新”按钮。参阅Help:绕过浏览器缓存以获取更多帮助。
// Module:Graph/ChartPolar.js
( function () {
	'use strict';

	window.mw = window.mw || {};
	mw.libs = mw.libs || {};
	var GW = mw.libs.GraphWidget = mw.libs.GraphWidget || {};
	GW.Renderers = GW.Renderers || {};

	var DEG2RAD = Math.PI / 180;

	function polarToCart( cx, cy, r, angleRad ) {
		return [
			cx + r * Math.cos( angleRad ),
			cy + r * Math.sin( angleRad )
		];
	}

	// 建立扇形或環形扇區 SVG Path (r0 = 0 為實心扇形,r0 > 0 為環形扇區)
	function buildSectorPath( cx, cy, r0, r1, a0, a1 ) {
		var span = a1 - a0;
		if ( Math.abs( span ) >= Math.PI * 2 - 1e-4 ) {
			var midA = a0 + Math.PI;
			return buildSectorPath( cx, cy, r0, r1, a0, midA ) + ' ' + buildSectorPath( cx, cy, r0, r1, midA, a1 );
		}
		var largeArc = Math.abs( span ) > Math.PI ? 1 : 0;
		var sweepOut = span >= 0 ? 1 : 0;
		var sweepIn = span >= 0 ? 0 : 1;
		var pOut0 = polarToCart( cx, cy, r1, a0 );
		var pOut1 = polarToCart( cx, cy, r1, a1 );

		if ( r0 <= 0.5 ) {
			return [
				'M', cx.toFixed( 2 ) + ',' + cy.toFixed( 2 ),
				'L', pOut0[ 0 ].toFixed( 2 ) + ',' + pOut0[ 1 ].toFixed( 2 ),
				'A', r1.toFixed( 2 ) + ',' + r1.toFixed( 2 ), '0', largeArc + ',' + sweepOut, pOut1[ 0 ].toFixed( 2 ) + ',' + pOut1[ 1 ].toFixed( 2 ),
				'Z'
			].join( ' ' );
		}
		var pIn1 = polarToCart( cx, cy, r0, a1 );
		var pIn0 = polarToCart( cx, cy, r0, a0 );
		return [
			'M', pOut0[ 0 ].toFixed( 2 ) + ',' + pOut0[ 1 ].toFixed( 2 ),
			'A', r1.toFixed( 2 ) + ',' + r1.toFixed( 2 ), '0', largeArc + ',' + sweepOut, pOut1[ 0 ].toFixed( 2 ) + ',' + pOut1[ 1 ].toFixed( 2 ),
			'L', pIn1[ 0 ].toFixed( 2 ) + ',' + pIn1[ 1 ].toFixed( 2 ),
			'A', r0.toFixed( 2 ) + ',' + r0.toFixed( 2 ), '0', largeArc + ',' + sweepIn, pIn0[ 0 ].toFixed( 2 ) + ',' + pIn0[ 1 ].toFixed( 2 ),
			'Z'
		].join( ' ' );
	}

	// =========================================================================
	// 1. 圓餅圖與甜甜圈圖 (pie / donut)
	// =========================================================================
	function renderPieChart( container, spec, rows, meta ) {
		var U = GW.Utils;
		if ( !U || !Array.isArray( rows ) || rows.length === 0 ) {
			return false;
		}
		var xField = spec.xField || 'x';
		var yField = spec.yField || 'y';
		var first = rows[ 0 ] || {};
		if ( first[ xField ] === undefined ) {
			xField = first._xfield !== undefined ? '_xfield' : ( first.label !== undefined ? 'label' : ( first.date !== undefined ? 'date' : 'x' ) );
		}
		if ( first[ yField ] === undefined ) {
			yField = first._yfield !== undefined ? '_yfield' : ( first.views !== undefined ? 'views' : ( first.value !== undefined ? 'value' : 'y' ) );
		}

		var items = [];
		var totalVal = 0;
		for ( var i = 0; i < rows.length; i++ ) {
			var v = Math.max( 0, Number( rows[ i ][ yField ] ) || 0 );
			if ( v > 0 ) {
				var lbl = String( rows[ i ][ xField ] !== undefined ? rows[ i ][ xField ] : ( '#' + ( i + 1 ) ) );
				items.push( { label: lbl, value: v } );
				totalVal += v;
			}
		}
		if ( items.length === 0 || totalVal <= 0 ) {
			return false;
		}

		var plotW = Math.max( 140, Number( spec.width ) || 320 );
		var plotH = Math.max( 140, Number( spec.height ) || 240 );
		var showLegend = spec.legend !== undefined && spec.legend !== null && spec.legend !== false && spec.legend !== 'false';
		var legW = showLegend ? 135 : 0;
		var padT = ( spec.title || ( meta && meta.notice ) ) ? 32 : 14;
		var padB = 16;
		var totalW = plotW + legW + 24;
		var totalH = Math.max( padT + plotH + padB, padT + ( showLegend ? items.length * 18 + 28 : 0 ) + padB );

		var cx = 12 + plotW / 2;
		var cy = padT + plotH / 2;
		var outerR = spec.outerRadius ? Number( spec.outerRadius ) : ( Math.min( plotW, plotH ) * 0.42 );
		var innerR = spec.innerRadius ? Math.min( outerR * 0.85, Number( spec.innerRadius ) ) : 0;
		var palObj = U.buildColorAndAlphaPalette( spec.colors, 'pie', items.length, 1 );
		var sCmap = ( spec.seriesColorMaps && spec.seriesColorMaps[ '1' ] ) || spec.colorMap;
		var hasCmap = sCmap && U.isColormapName( sCmap );
		var grey = '#54595d';

		var svg = U.createSvgEl( 'svg', {
			viewBox: '0 0 ' + totalW + ' ' + totalH,
			width: totalW,
			height: totalH,
			'class': 'graph-chart-svg',
			style: 'display:block;max-width:100%;height:auto'
		} );

		var stageWrap = U.createHtmlEl(
			'div',
			'svg-graph gr-async-stage',
			'position:relative;display:block;clear:both;width:' + totalW + 'px;max-width:100%;margin:0.5em 0'
		);
		var tipUI = U.createTooltipUI();

		var curAngle = ( ( Number( spec.startAngle ) || 0 ) - 90 ) * DEG2RAD;
		for ( var idx = 0; idx < items.length; idx++ ) {
			var it = items[ idx ];
			var frac = it.value / totalVal;
			var nextAngle = curAngle + frac * Math.PI * 2;
			var midAngle = ( curAngle + nextAngle ) / 2;
			var col = hasCmap ?
				U.sampleColormap( items.length > 1 ? idx / ( items.length - 1 ) : 0.5, sCmap ) :
				palObj.colors[ idx % palObj.colors.length ];
			it.color = col;

			var dPath = buildSectorPath( cx, cy, innerR, outerR, curAngle, nextAngle );
			var sliceEl = U.createSvgEl( 'path', {
				d: dPath,
				fill: col,
				stroke: '#ffffff',
				'stroke-width': 1.2,
				style: 'cursor:pointer;transition:opacity 0.15s'
			} );

			( function ( item, midA, c ) {
				sliceEl.addEventListener( 'mousemove', function () {
					sliceEl.setAttribute( 'fill-opacity', '0.82' );
					var tipPt = polarToCart( cx, cy, ( innerR + outerR ) * 0.55, midA );
					var pctStr = ( ( item.value / totalVal ) * 100 ).toFixed( 1 ) + '%';
					var valStr = U.formatNumber( item.value, spec.yAxisFormat ) + ' (' + pctStr + ')';
					tipUI.show( tipPt[ 0 ], tipPt[ 1 ], totalW, totalH, c, item.label, valStr );
				} );
				sliceEl.addEventListener( 'mouseleave', function () {
					sliceEl.removeAttribute( 'fill-opacity' );
					tipUI.hide();
				} );
			}( it, midAngle, col ) );

			svg.appendChild( sliceEl );

			if ( spec.showValues && frac >= 0.045 ) {
				var lblR = innerR > 0 ? ( innerR + outerR ) / 2 : outerR * 0.68;
				var lPt = polarToCart( cx, cy, lblR, midAngle );
				svg.appendChild( U.createSvgEl( 'text', {
					x: lPt[ 0 ].toFixed( 2 ),
					y: ( lPt[ 1 ] + 4 ).toFixed( 2 ),
					'text-anchor': 'middle',
					'font-size': '11px',
					'font-weight': 'bold',
					'font-family': 'sans-serif',
					fill: '#ffffff',
					style: 'pointer-events:none'
				}, U.formatNumber( it.value, spec.yAxisFormat ) ) );
			}
			curAngle = nextAngle;
		}

		var titleText = spec.title || ( meta && meta.notice ) || '';
		if ( titleText ) {
			svg.appendChild( U.createSvgEl( 'text', {
				x: cx.toFixed( 1 ), y: 20,
				'text-anchor': 'middle', 'font-size': '13px', 'font-weight': 'bold', 'font-family': 'sans-serif', fill: grey
			}, titleText ) );
		}

		if ( showLegend ) {
			var legX = plotW + 18;
			var legY = padT + 8;
			var legTitle = String( spec.legend ).trim();
			if ( legTitle !== '' && legTitle.toLowerCase() !== 'true' && legTitle.toLowerCase() !== 'yes' && legTitle !== '1' ) {
				svg.appendChild( U.createSvgEl( 'text', {
					x: legX, y: legY + 10, 'font-size': '12px', 'font-weight': 'bold', 'font-family': 'sans-serif', fill: grey
				}, legTitle ) );
				legY += 20;
			}
			for ( var li = 0; li < items.length; li++ ) {
				var iy = legY + li * 18;
				svg.appendChild( U.createSvgEl( 'rect', {
					x: legX, y: iy, width: 12, height: 11, rx: 2, fill: items[ li ].color
				} ) );
				svg.appendChild( U.createSvgEl( 'text', {
					x: legX + 18, y: iy + 9.5, 'font-size': '11px', 'font-family': 'sans-serif', fill: grey
				}, items[ li ].label ) );
			}
		}

		stageWrap.appendChild( svg );
		stageWrap.appendChild( tipUI.el );
		U.mountStage( container, stageWrap );
		return true;
	}

	// =========================================================================
	// 2. 極座標家族:radar / polarbar / rose / polarline / polararea / polarmap
	// =========================================================================
	function renderPolarFamily( container, spec, rows, meta ) {
		var U = GW.Utils;
		if ( !U || !Array.isArray( rows ) || rows.length === 0 ) {
			return false;
		}
		var rawType = String( spec.type || 'polarbar' ).toLowerCase();
		var isRadar = rawType === 'radar' || rawType === 'spider';
		var isPolarLine = rawType === 'polarline' || rawType === 'polararea';
		var isPolarMap = rawType === 'polarmap' || rawType === 'polarheatmap';
		var isStacked = rawType.indexOf( 'stacked' ) === 0;

		var xField = spec.xField || 'x';
		var yField = spec.yField || 'y';
		var zField = spec.zField || 'z';
		var groupField = spec.groupField;
		var first = rows[ 0 ] || {};
		if ( first[ xField ] === undefined ) {
			xField = first._xfield !== undefined ? '_xfield' : ( first.date !== undefined ? 'date' : 'x' );
		}
		if ( first[ yField ] === undefined ) {
			yField = first._yfield !== undefined ? '_yfield' : ( first.views !== undefined ? 'views' : ( first.value !== undefined ? 'value' : 'y' ) );
		}

		var cats = [];
		var catMap = {};
		var seriesMap = {};
		var seriesList = [];
		var maxVal = 0;

		for ( var i = 0; i < rows.length; i++ ) {
			var r = rows[ i ];
			var cKey = String( r[ xField ] !== undefined ? r[ xField ] : i );
			var yv = Number( r[ yField ] );
			if ( isNaN( yv ) ) {
				continue;
			}
			if ( catMap[ cKey ] === undefined ) {
				catMap[ cKey ] = cats.length;
				cats.push( cKey );
			}
			var cIdx = catMap[ cKey ];
			var gKey = groupField && r[ groupField ] !== undefined ? String( r[ groupField ] ) : ( spec.yAxisTitle || yField || 'Series 1' );
			if ( !seriesMap[ gKey ] ) {
				seriesMap[ gKey ] = { title: gKey, valuesByCat: {}, zByCat: {} };
				seriesList.push( seriesMap[ gKey ] );
			}
			seriesMap[ gKey ].valuesByCat[ cIdx ] = yv;
			if ( r[ zField ] !== undefined ) {
				seriesMap[ gKey ].zByCat[ cIdx ] = Number( r[ zField ] );
			}
			if ( yv > maxVal ) {
				maxVal = yv;
			}
		}

		if ( cats.length === 0 || seriesList.length === 0 ) {
			return false;
		}
		if ( isStacked && seriesList.length > 1 ) {
			maxVal = 0;
			for ( var ci = 0; ci < cats.length; ci++ ) {
				var sum = 0;
				for ( var si = 0; si < seriesList.length; si++ ) {
					sum += Math.max( 0, seriesList[ si ].valuesByCat[ ci ] || 0 );
				}
				if ( sum > maxVal ) {
					maxVal = sum;
				}
			}
		}

		var tickObj = U.computeNiceTicks( 0, maxVal > 0 ? maxVal : 10, 4 );
		var rMaxVal = Math.max( 1, spec.yMax ? Number( spec.yMax ) : tickObj.max );

		var plotW = Math.max( 180, Number( spec.width ) || 340 );
		var plotH = Math.max( 180, Number( spec.height ) || 300 );
		var showLegend = spec.legend !== undefined && spec.legend !== null && spec.legend !== false && spec.legend !== 'false';
		var legW = showLegend ? 130 : 0;
		var padT = ( spec.title || ( meta && meta.notice ) ) ? 34 : 18;
		var padB = 22;
		var totalW = plotW + legW + 30;
		var totalH = padT + plotH + padB;

		var cx = 15 + plotW / 2;
		var cy = padT + plotH / 2;
		var outerR = spec.outerRadius ? Number( spec.outerRadius ) : ( Math.min( plotW, plotH ) * 0.38 );
		var innerR = ( !isRadar && spec.innerRadius ) ? Math.min( outerR * 0.7, Number( spec.innerRadius ) ) : 0;
		var startRad = ( ( Number( spec.startAngle ) || 0 ) - 90 ) * DEG2RAD;
		var nCats = Math.max( 1, cats.length );
		var stepAngle = ( Math.PI * 2 ) / nCats;
		var grey = '#54595d';

		var svg = U.createSvgEl( 'svg', {
			viewBox: '0 0 ' + totalW + ' ' + totalH,
			width: totalW,
			height: totalH,
			'class': 'graph-chart-svg',
			style: 'display:block;max-width:100%;height:auto'
		} );
		var defs = U.createSvgEl( 'defs' );
		svg.appendChild( defs );

		// 1. 繪製極座標同心網格與軸線
		for ( var ti = 1; ti < tickObj.ticks.length; ti++ ) {
			var tk = tickObj.ticks[ ti ];
			if ( tk <= rMaxVal + 1e-9 ) {
				var rr = innerR + ( tk / rMaxVal ) * ( outerR - innerR );
				if ( isRadar && nCats >= 3 ) {
					var webPts = [];
					for ( var wi = 0; wi < nCats; wi++ ) {
						var wp = polarToCart( cx, cy, rr, startRad + wi * stepAngle );
						webPts.push( wp[ 0 ].toFixed( 2 ) + ',' + wp[ 1 ].toFixed( 2 ) );
					}
					svg.appendChild( U.createSvgEl( 'polygon', {
						points: webPts.join( ' ' ), fill: 'none', stroke: '#d8dbdf', 'stroke-width': 1
					} ) );
				} else {
					svg.appendChild( U.createSvgEl( 'circle', {
						cx: cx.toFixed( 2 ), cy: cy.toFixed( 2 ), r: rr.toFixed( 2 ),
						fill: 'none', stroke: '#d8dbdf', 'stroke-width': 1, 'stroke-dasharray': '2,2'
					} ) );
				}
			}
		}

		// 繪製輻射軸與外圍類別標籤
		for ( var ai = 0; ai < nCats; ai++ ) {
			var ang = startRad + ( isRadar || isPolarLine ? ai * stepAngle : ( ai + 0.5 ) * stepAngle );
			var spokeEnd = polarToCart( cx, cy, outerR, startRad + ai * stepAngle );
			svg.appendChild( U.createSvgEl( 'line', {
				x1: cx.toFixed( 2 ), y1: cy.toFixed( 2 ),
				x2: spokeEnd[ 0 ].toFixed( 2 ), y2: spokeEnd[ 1 ].toFixed( 2 ),
				stroke: '#e1e3e5', 'stroke-width': 1
			} ) );

			var lblPt = polarToCart( cx, cy, outerR + 14, ang );
			var cosA = Math.cos( ang );
			var anchor = Math.abs( cosA ) < 0.2 ? 'middle' : ( cosA > 0 ? 'start' : 'end' );
			svg.appendChild( U.createSvgEl( 'text', {
				x: lblPt[ 0 ].toFixed( 2 ), y: ( lblPt[ 1 ] + 4 ).toFixed( 2 ),
				'text-anchor': anchor, 'font-size': '11px', 'font-family': 'sans-serif', fill: grey
			}, cats[ ai ] ) );
		}

		var stageWrap = U.createHtmlEl(
			'div',
			'svg-graph gr-async-stage',
			'position:relative;display:block;clear:both;width:' + totalW + 'px;max-width:100%;margin:0.5em 0'
		);
		var tipUI = U.createTooltipUI();
		var palObj = U.buildColorAndAlphaPalette( spec.colors, rawType, nCats, seriesList.length );
		var sCmap = ( spec.seriesColorMaps && spec.seriesColorMaps[ '1' ] ) || spec.colorMap;
		var hasCmap = sCmap && U.isColormapName( sCmap );

		// 2. 繪製資料圖層
		if ( isRadar || isPolarLine ) {
			for ( var sIdx = 0; sIdx < seriesList.length; sIdx++ ) {
				var sObj = seriesList[ sIdx ];
				var col = palObj.colors[ sIdx % palObj.colors.length ];
				var polyPts = [];
				var dotNodes = [];
				for ( var cIdx2 = 0; cIdx2 < nCats; cIdx2++ ) {
					var val = Math.max( 0, sObj.valuesByCat[ cIdx2 ] || 0 );
					var rad = innerR + ( Math.min( rMaxVal, val ) / rMaxVal ) * ( outerR - innerR );
					var ptA = startRad + cIdx2 * stepAngle;
					var p = polarToCart( cx, cy, rad, ptA );
					polyPts.push( p[ 0 ].toFixed( 2 ) + ',' + p[ 1 ].toFixed( 2 ) );
					dotNodes.push( { x: p[ 0 ], y: p[ 1 ], val: val, cat: cats[ cIdx2 ], color: col, series: sObj.title } );
				}
				if ( isRadar || rawType === 'polararea' ) {
					svg.appendChild( U.createSvgEl( 'polygon', {
						points: polyPts.join( ' ' ), fill: col, 'fill-opacity': '0.25', stroke: col, 'stroke-width': 2
					} ) );
				} else {
					svg.appendChild( U.createSvgEl( 'polyline', {
						points: polyPts.join( ' ' ), fill: 'none', stroke: col, 'stroke-width': 2.2
					} ) );
				}
				for ( var di = 0; di < dotNodes.length; di++ ) {
					( function ( dn ) {
						var cEl = U.createSvgEl( 'circle', {
							cx: dn.x.toFixed( 2 ), cy: dn.y.toFixed( 2 ), r: 4,
							fill: dn.color, stroke: '#ffffff', 'stroke-width': 1.2, style: 'cursor:pointer'
						} );
						cEl.addEventListener( 'mousemove', function () {
							tipUI.show( dn.x, dn.y, totalW, totalH, dn.color, dn.series + ' (' + dn.cat + ')', U.formatNumber( dn.val, spec.yAxisFormat ) );
						} );
						cEl.addEventListener( 'mouseleave', function () { tipUI.hide(); } );
						svg.appendChild( cEl );
					}( dotNodes[ di ] ) );
				}
			}
		} else {
			// polarbar / rose / stackedpolarbar / groupedpolarbar / polarmap
			var padFrac = spec.barPadding !== undefined ? Number( spec.barPadding ) : 0.12;
			var usableSpan = stepAngle * ( 1 - padFrac );
			var accumR = {};

			for ( var ci2 = 0; ci2 < nCats; ci2++ ) {
				accumR[ ci2 ] = innerR;
			}

			for ( var si2 = 0; si2 < seriesList.length; si2++ ) {
				var sBar = seriesList[ si2 ];
				var bCol = palObj.colors[ si2 % palObj.colors.length ];
				for ( var ci3 = 0; ci3 < nCats; ci3++ ) {
					var vBar = Math.max( 0, sBar.valuesByCat[ ci3 ] || 0 );
					if ( vBar <= 0 ) {
						continue;
					}
					var baseStartA = startRad + ci3 * stepAngle + ( stepAngle - usableSpan ) / 2;
					var a0, a1, r0, r1;
					if ( isStacked || isPolarMap ) {
						a0 = baseStartA;
						a1 = baseStartA + usableSpan;
						r0 = accumR[ ci3 ];
						r1 = Math.min( outerR, r0 + ( vBar / rMaxVal ) * ( outerR - innerR ) );
						accumR[ ci3 ] = r1;
					} else {
						var subSpan = usableSpan / seriesList.length;
						a0 = baseStartA + si2 * subSpan;
						a1 = a0 + subSpan * 0.92;
						r0 = innerR;
						r1 = innerR + ( Math.min( rMaxVal, vBar ) / rMaxVal ) * ( outerR - innerR );
					}
					var fillCol = hasCmap ? U.sampleColormap( vBar / rMaxVal, sCmap ) : bCol;
					var secPath = buildSectorPath( cx, cy, r0, r1, a0, a1 );
					var secEl = U.createSvgEl( 'path', {
						d: secPath, fill: fillCol, 'fill-opacity': '0.88', stroke: '#ffffff', 'stroke-width': 0.8, style: 'cursor:pointer'
					} );

					( function ( catLbl, sTitle, valNum, midA, midR, cHex ) {
						secEl.addEventListener( 'mousemove', function () {
							var tp = polarToCart( cx, cy, midR, midA );
							var lText = seriesList.length > 1 ? ( sTitle + ' (' + catLbl + ')' ) : catLbl;
							tipUI.show( tp[ 0 ], tp[ 1 ], totalW, totalH, cHex, lText, U.formatNumber( valNum, spec.yAxisFormat ) );
						} );
						secEl.addEventListener( 'mouseleave', function () { tipUI.hide(); } );
					}( cats[ ci3 ], sBar.title, vBar, ( a0 + a1 ) / 2, ( r0 + r1 ) / 2, fillCol ) );

					svg.appendChild( secEl );
				}
			}
		}

		// 繪製徑向刻度數值
		for ( var rti = 1; rti < tickObj.ticks.length; rti++ ) {
			var rtk = tickObj.ticks[ rti ];
			if ( rtk <= rMaxVal + 1e-9 ) {
				var rPos = innerR + ( rtk / rMaxVal ) * ( outerR - innerR );
				svg.appendChild( U.createSvgEl( 'text', {
					x: ( cx + 4 ).toFixed( 1 ), y: ( cy - rPos + 10 ).toFixed( 1 ),
					'font-size': '10px', 'font-family': 'sans-serif', fill: grey
				}, U.formatNumber( rtk, spec.yAxisFormat ) ) );
			}
		}

		var titleText = spec.title || ( meta && meta.notice ) || '';
		if ( titleText ) {
			svg.appendChild( U.createSvgEl( 'text', {
				x: cx.toFixed( 1 ), y: 20,
				'text-anchor': 'middle', 'font-size': '13px', 'font-weight': 'bold', 'font-family': 'sans-serif', fill: grey
			}, titleText ) );
		}

		if ( showLegend && seriesList.length > 0 ) {
			var legX = plotW + 22;
			var legY = padT + 8;
			for ( var li = 0; li < seriesList.length; li++ ) {
				var iy = legY + li * 18;
				var lCol = palObj.colors[ li % palObj.colors.length ];
				svg.appendChild( U.createSvgEl( 'rect', {
					x: legX, y: iy, width: 12, height: 11, rx: 2, fill: lCol
				} ) );
				svg.appendChild( U.createSvgEl( 'text', {
					x: legX + 18, y: iy + 9.5, 'font-size': '11px', 'font-family': 'sans-serif', fill: grey
				}, seriesList[ li ].title ) );
			}
		}

		stageWrap.appendChild( svg );
		stageWrap.appendChild( tipUI.el );
		U.mountStage( container, stageWrap );
		return true;
	}

	// =========================================================================
	// 3. 補齊 2D 矩陣熱圖 (heatmap)
	// =========================================================================
	function renderHeatmap2D( container, spec, rows, meta ) {
		var U = GW.Utils;
		if ( !U || !Array.isArray( rows ) || rows.length === 0 ) {
			return false;
		}
		var xField = spec.xField || 'x';
		var yField = spec.yField || 'y';
		var zField = spec.zField || 'z';
		var xCats = [], yCats = [], xMap = {}, yMap = {};
		var cells = [];
		var zMin = Infinity, zMax = -Infinity;

		for ( var i = 0; i < rows.length; i++ ) {
			var r = rows[ i ];
			var xk = String( r[ xField ] !== undefined ? r[ xField ] : i );
			var yk = String( r[ yField ] !== undefined ? r[ yField ] : 0 );
			var zv = Number( r[ zField ] !== undefined ? r[ zField ] : r.value );
			if ( isNaN( zv ) ) {
				continue;
			}
			if ( xMap[ xk ] === undefined ) { xMap[ xk ] = xCats.length; xCats.push( xk ); }
			if ( yMap[ yk ] === undefined ) { yMap[ yk ] = yCats.length; yCats.push( yk ); }
			if ( zv < zMin ) { zMin = zv; }
			if ( zv > zMax ) { zMax = zv; }
			cells.push( { xi: xMap[ xk ], yi: yMap[ yk ], xLbl: xk, yLbl: yk, z: zv } );
		}
		if ( cells.length === 0 ) {
			return false;
		}
		if ( zMax <= zMin ) { zMax = zMin + 1; }

		var plotW = Math.max( 140, Number( spec.width ) || 400 );
		var plotH = Math.max( 100, Number( spec.height ) || 240 );
		var padL = 64, padR = 24, padT = 30, padB = 34;
		var totalW = padL + plotW + padR;
		var totalH = padT + plotH + padB;
		var cw = plotW / Math.max( 1, xCats.length );
		var ch = plotH / Math.max( 1, yCats.length );
		var cmap = spec.colorMap || 'viridis';
		var grey = '#54595d';

		var svg = U.createSvgEl( 'svg', {
			viewBox: '0 0 ' + totalW + ' ' + totalH,
			width: totalW, height: totalH, 'class': 'graph-chart-svg',
			style: 'display:block;max-width:100%;height:auto'
		} );
		var stageWrap = U.createHtmlEl(
			'div', 'svg-graph gr-async-stage',
			'position:relative;display:block;clear:both;width:' + totalW + 'px;max-width:100%;margin:0.5em 0'
		);
		var tipUI = U.createTooltipUI();

		for ( var ci = 0; ci < cells.length; ci++ ) {
			( function ( cell ) {
				var cx = padL + cell.xi * cw;
				var cy = padT + cell.yi * ch;
				var col = U.sampleColormap( ( cell.z - zMin ) / ( zMax - zMin ), cmap );
				var rectEl = U.createSvgEl( 'rect', {
					x: cx.toFixed( 2 ), y: cy.toFixed( 2 ),
					width: Math.max( 1, cw - 1 ).toFixed( 2 ), height: Math.max( 1, ch - 1 ).toFixed( 2 ),
					fill: col, style: 'cursor:pointer'
				} );
				rectEl.addEventListener( 'mousemove', function () {
					tipUI.show( cx + cw / 2, cy, totalW, totalH, col, cell.xLbl + ', ' + cell.yLbl, U.formatNumber( cell.z, spec.zAxisFormat ) );
				} );
				rectEl.addEventListener( 'mouseleave', function () { tipUI.hide(); } );
				svg.appendChild( rectEl );
			}( cells[ ci ] ) );
		}

		for ( var xi = 0; xi < xCats.length; xi++ ) {
			svg.appendChild( U.createSvgEl( 'text', {
				x: ( padL + ( xi + 0.5 ) * cw ).toFixed( 2 ), y: padT + plotH + 16,
				'text-anchor': 'middle', 'font-size': '11px', 'font-family': 'sans-serif', fill: grey
			}, xCats[ xi ] ) );
		}
		for ( var yi = 0; yi < yCats.length; yi++ ) {
			svg.appendChild( U.createSvgEl( 'text', {
				x: padL - 8, y: ( padT + ( yi + 0.5 ) * ch + 4 ).toFixed( 2 ),
				'text-anchor': 'end', 'font-size': '11px', 'font-family': 'sans-serif', fill: grey
			}, yCats[ yi ] ) );
		}

		var titleText = spec.title || ( meta && meta.notice ) || '';
		if ( titleText ) {
			svg.appendChild( U.createSvgEl( 'text', {
				x: ( padL + plotW / 2 ).toFixed( 1 ), y: 20,
				'text-anchor': 'middle', 'font-size': '13px', 'font-weight': 'bold', 'font-family': 'sans-serif', fill: grey
			}, titleText ) );
		}

		stageWrap.appendChild( svg );
		stageWrap.appendChild( tipUI.el );
		U.mountStage( container, stageWrap );
		return true;
	}

	GW.Renderers.pie = renderPieChart;
	GW.Renderers.donut = renderPieChart;

	var polarTypes = [
		'radar', 'spider', 'polarline', 'polararea',
		'polarbar', 'rose', 'stackedpolarbar', 'groupedpolarbar',
		'polarmap', 'polarheatmap'
	];
	for ( var i = 0; i < polarTypes.length; i++ ) {
		GW.Renderers[ polarTypes[ i ] ] = renderPolarFamily;
	}
	GW.Renderers.heatmap = renderHeatmap2D;
	GW.ChartPolar = renderPolarFamily;
}() );