跳转到内容

User:A2569875/Module:Graph/Chart3D.js

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

	// =========================================================================
	// 1. 3D 數學投影、光影與畫家演算法引擎 (移植自 Module:Graph/MathEngine)
	// =========================================================================
	var MathEngine = {};

	MathEngine.createCamera = function ( thetaDeg, phiDeg, distance, cx, cy, scale ) {
		var th = ( thetaDeg !== undefined && !isNaN( thetaDeg ) ? Number( thetaDeg ) : -38 ) * DEG2RAD;
		var ph = Math.max( -85, Math.min( 85, ( phiDeg !== undefined && !isNaN( phiDeg ) ? Number( phiDeg ) : 26 ) ) ) * DEG2RAD;
		var dist = Math.max( 2.2, Number( distance ) || 4.8 );

		var cosT = Math.cos( th ), sinT = Math.sin( th );
		var cosP = Math.cos( ph ), sinP = Math.sin( ph );

		return {
			theta: thetaDeg,
			phi: phiDeg,
			project: function ( nx, ny, nz ) {
				// 將 [-1, 1] 正規化空間旋轉至相機座標系 (X-Y 為水平底面,Z 為垂直高度軸)
				var rx = nx * cosT - ny * sinT;
				var ry = nx * sinT + ny * cosT;
				var sxCam = rx;
				var syCam = -ry * sinP + nz * cosP;
				var depth = ry * cosP + nz * sinP;
				var persp = dist / Math.max( 0.8, dist - depth * 0.45 );
				return {
					x: cx + sxCam * scale * persp,
					y: cy - syCam * scale * persp,
					depth: depth
				};
			}
		};
	};

	// 根據平面法向量計算漫反射明暗色碼 (Lambertian Directional Shading)
	MathEngine.shadeHex = function ( hexColor, nx, ny, nz ) {
		var U = GW.Utils;
		var rgb = U ? U.parseRGB( hexColor ) : [ 31, 119, 180 ];
		var len = Math.sqrt( nx * nx + ny * ny + nz * nz ) || 1;
		// 光源方向向量: 右前上方 (-0.45, -0.55, 0.70)
		var dot = ( ( nx / len ) * -0.45 + ( ny / len ) * -0.55 + ( nz / len ) * 0.70 );
		var factor = 0.82 + 0.28 * dot;
		return U.rgbToHex( rgb[ 0 ] * factor, rgb[ 1 ] * factor, rgb[ 2 ] * factor );
	};

	// 產生 3D 柱體的 6 個面 (供畫家演算法排序)
	MathEngine.buildBar3DFaces = function ( cam, x0, x1, y0, y1, z0, z1, baseColor, metaInfo ) {
		var v = [
			cam.project( x0, y0, z0 ), // 0
			cam.project( x1, y0, z0 ), // 1
			cam.project( x1, y1, z0 ), // 2
			cam.project( x0, y1, z0 ), // 3
			cam.project( x0, y0, z1 ), // 4
			cam.project( x1, y0, z1 ), // 5
			cam.project( x1, y1, z1 ), // 6
			cam.project( x0, y1, z1 )  // 7
		];
		var faceDefs = [
			{ idx: [ 4, 5, 6, 7 ], n: [ 0, 0, 1 ] },   // 頂面 (+Z)
			{ idx: [ 0, 1, 5, 4 ], n: [ 0, -1, 0 ] },  // 前面 (-Y)
			{ idx: [ 1, 2, 6, 5 ], n: [ 1, 0, 0 ] },   // 右面 (+X)
			{ idx: [ 2, 3, 7, 6 ], n: [ 0, 1, 0 ] },   // 後面 (+Y)
			{ idx: [ 3, 0, 4, 7 ], n: [ -1, 0, 0 ] }   // 左面 (-X)
		];
		var out = [];
		for ( var i = 0; i < faceDefs.length; i++ ) {
			var fd = faceDefs[ i ];
			var p0 = v[ fd.idx[ 0 ] ], p1 = v[ fd.idx[ 1 ] ], p2 = v[ fd.idx[ 2 ] ], p3 = v[ fd.idx[ 3 ] ];
			var avgDepth = ( p0.depth + p1.depth + p2.depth + p3.depth ) * 0.25;
			out.push( {
				kind: 'poly',
				depth: avgDepth,
				pts: [ p0, p1, p2, p3 ],
				fill: MathEngine.shadeHex( baseColor, fd.n[ 0 ], fd.n[ 1 ], fd.n[ 2 ] ),
				stroke: '#ffffff',
				strokeWidth: 0.45,
				meta: metaInfo,
				topPt: v[ 4 ]
			} );
		}
		return out;
	};

	GW.MathEngine = MathEngine;

	// =========================================================================
	// 2. 3D 綜合渲染器 (支援 scatter3d, line3d, bar3d, surface3d 與滑鼠拖曳旋轉)
	// =========================================================================
	function renderChart3D( container, spec, rows, meta ) {
		var U = GW.Utils;
		if ( !U || !Array.isArray( rows ) || rows.length === 0 ) {
			return false;
		}

		var rawType = String( spec.type || 'scatter3d' ).toLowerCase();
		var xField = spec.xField || 'x';
		var yField = spec.yField || 'y';
		var zField = spec.zField || 'z';
		var first = rows[ 0 ] || {};
		if ( first[ xField ] === undefined ) { xField = first.date !== undefined ? 'date' : 'x'; }
		if ( first[ yField ] === undefined ) { yField = first.views !== undefined ? 'views' : 'y'; }
		if ( first[ zField ] === undefined ) { zField = first.value !== undefined ? 'value' : ( first.z !== undefined ? 'z' : yField ); }

		var pts3d = [];
		var xMin = Infinity, xMax = -Infinity;
		var yMin = Infinity, yMax = -Infinity;
		var zMin = rawType === 'bar3d' ? 0 : Infinity, zMax = -Infinity;

		for ( var i = 0; i < rows.length; i++ ) {
			var r = rows[ i ];
			var xv = Number( r[ xField ] !== undefined ? r[ xField ] : i );
			var yv = Number( r[ yField ] !== undefined ? r[ yField ] : 0 );
			var zv = Number( r[ zField ] !== undefined ? r[ zField ] : yv );
			if ( isNaN( xv ) || isNaN( yv ) || isNaN( zv ) ) {
				continue;
			}
			if ( xv < xMin ) { xMin = xv; }
			if ( xv > xMax ) { xMax = xv; }
			if ( yv < yMin ) { yMin = yv; }
			if ( yv > yMax ) { yMax = yv; }
			if ( zv < zMin ) { zMin = zv; }
			if ( zv > zMax ) { zMax = zv; }
			pts3d.push( { x: xv, y: yv, z: zv } );
		}

		if ( pts3d.length === 0 ) {
			return false;
		}
		if ( xMax <= xMin ) { xMax = xMin + 1; }
		if ( yMax <= yMin ) { yMax = yMin + 1; }
		if ( zMax <= zMin ) { zMax = zMin + 1; }

		var xTicks = U.computeNiceTicks( xMin, xMax, 4 );
		var yTicks = U.computeNiceTicks( yMin, yMax, 4 );
		var zTicks = U.computeNiceTicks( zMin, zMax, 4 );
		xMin = xTicks.min; xMax = xTicks.max;
		yMin = yTicks.min; yMax = yTicks.max;
		zMin = zTicks.min; zMax = zTicks.max;

		var normX = function ( v ) { return ( ( v - xMin ) / ( xMax - xMin ) ) * 2 - 1; };
		var normY = function ( v ) { return ( ( v - yMin ) / ( yMax - yMin ) ) * 2 - 1; };
		var normZ = function ( v ) { return ( ( v - zMin ) / ( zMax - zMin ) ) * 2 - 1; };

		var plotW = Math.max( 220, Number( spec.width ) || 420 );
		var plotH = Math.max( 180, Number( spec.height ) || 320 );
		var padT = ( spec.title || ( meta && meta.notice ) ) ? 32 : 16;
		var totalW = plotW + 40;
		var totalH = plotH + padT + 24;
		var cx = totalW / 2;
		var cy = padT + plotH * 0.54;
		var scale = Math.min( plotW, plotH ) * 0.31;
		var cmap = ( spec.seriesColorMaps && spec.seriesColorMaps[ '1' ] ) || spec.colorMap || 'viridis';
		var hasCmap = U.isColormapName( cmap );
		var palObj = U.buildColorAndAlphaPalette( spec.colors, rawType, pts3d.length, 1 );
		var grey = '#54595d';

		var curTheta = spec.viewTheta !== undefined && !isNaN( spec.viewTheta ) ? Number( spec.viewTheta ) : -38;
		var curPhi = spec.viewPhi !== undefined && !isNaN( spec.viewPhi ) ? Number( spec.viewPhi ) : 26;
		var curDist = spec.viewDistance !== undefined && !isNaN( spec.viewDistance ) ? Number( spec.viewDistance ) : 4.8;

		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;cursor:grab;user-select:none'
		} );
		var sceneGroup = U.createSvgEl( 'g' );
		svg.appendChild( sceneGroup );

		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 ) );
		}
		svg.appendChild( U.createSvgEl( 'text', {
			x: ( totalW - 10 ).toFixed( 1 ), y: ( totalH - 6 ).toFixed( 1 ),
			'text-anchor': 'end', 'font-size': '10px', 'font-family': 'sans-serif', fill: '#888888'
		}, spec._ui_drag_3d || '⟳ 可拖曳旋轉 3D 視角' ) );

		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();

		function drawScene() {
			while ( sceneGroup.firstChild ) {
				sceneGroup.removeChild( sceneGroup.firstChild );
			}
			var cam = MathEngine.createCamera( curTheta, curPhi, curDist, cx, cy, scale );

			// 1. 繪製 3D 座標軸底框與格線
			var boxEdges = [
				[ [ -1, -1, -1 ], [ 1, -1, -1 ] ],
				[ [ -1, -1, -1 ], [ -1, 1, -1 ] ],
				[ [ -1, -1, -1 ], [ -1, -1, 1 ] ],
				[ [ 1, -1, -1 ], [ 1, 1, -1 ] ],
				[ [ -1, 1, -1 ], [ 1, 1, -1 ] ]
			];
			for ( var ei = 0; ei < boxEdges.length; ei++ ) {
				var e0 = cam.project( boxEdges[ ei ][ 0 ][ 0 ], boxEdges[ ei ][ 0 ][ 1 ], boxEdges[ ei ][ 0 ][ 2 ] );
				var e1 = cam.project( boxEdges[ ei ][ 1 ][ 0 ], boxEdges[ ei ][ 1 ][ 1 ], boxEdges[ ei ][ 1 ][ 2 ] );
				sceneGroup.appendChild( U.createSvgEl( 'line', {
					x1: e0.x.toFixed( 2 ), y1: e0.y.toFixed( 2 ),
					x2: e1.x.toFixed( 2 ), y2: e1.y.toFixed( 2 ),
					stroke: '#a2a9b1', 'stroke-width': 1.2
				} ) );
			}

			// Z 軸刻度
			for ( var zi = 0; zi < zTicks.ticks.length; zi++ ) {
				var zt = zTicks.ticks[ zi ];
				var nz = normZ( zt );
				var zp0 = cam.project( -1, -1, nz );
				var zp1 = cam.project( 1, -1, nz );
				var zp2 = cam.project( -1, 1, nz );
				sceneGroup.appendChild( U.createSvgEl( 'polyline', {
					points: zp1.x.toFixed( 2 ) + ',' + zp1.y.toFixed( 2 ) + ' ' + zp0.x.toFixed( 2 ) + ',' + zp0.y.toFixed( 2 ) + ' ' + zp2.x.toFixed( 2 ) + ',' + zp2.y.toFixed( 2 ),
					fill: 'none', stroke: '#e1e3e5', 'stroke-width': 0.9, 'stroke-dasharray': '2,2'
				} ) );
				sceneGroup.appendChild( U.createSvgEl( 'text', {
					x: ( zp2.x - 6 ).toFixed( 2 ), y: ( zp2.y + 3 ).toFixed( 2 ),
					'text-anchor': 'end', 'font-size': '10px', 'font-family': 'sans-serif', fill: grey
				}, U.formatNumber( zt, spec.zAxisFormat ) ) );
			}

			// X 軸與 Y 軸刻度
			for ( var xi = 0; xi < xTicks.ticks.length; xi++ ) {
				var xt = xTicks.ticks[ xi ];
				var xp = cam.project( normX( xt ), -1.12, -1 );
				sceneGroup.appendChild( U.createSvgEl( 'text', {
					x: xp.x.toFixed( 2 ), y: ( xp.y + 8 ).toFixed( 2 ),
					'text-anchor': 'middle', 'font-size': '10px', 'font-family': 'sans-serif', fill: grey
				}, U.formatNumber( xt, spec.xAxisFormat ) ) );
			}
			for ( var yi = 0; yi < yTicks.ticks.length; yi++ ) {
				var yt = yTicks.ticks[ yi ];
				var yp = cam.project( 1.12, normY( yt ), -1 );
				sceneGroup.appendChild( U.createSvgEl( 'text', {
					x: yp.x.toFixed( 2 ), y: ( yp.y + 6 ).toFixed( 2 ),
					'text-anchor': 'start', 'font-size': '10px', 'font-family': 'sans-serif', fill: grey
				}, U.formatNumber( yt, spec.yAxisFormat ) ) );
			}

			// 繪製繁簡轉換後的 X / Y / Z 3D 軸標題
			if ( spec.xAxisTitle ) {
				var xTitlePos = cam.project( 0, -1.42, -1 );
				sceneGroup.appendChild( U.createSvgEl( 'text', {
					x: xTitlePos.x.toFixed( 2 ), y: ( xTitlePos.y + 10 ).toFixed( 2 ),
					'text-anchor': 'middle', 'font-size': '11px', 'font-weight': 'bold', 'font-family': 'sans-serif', fill: grey
				}, spec.xAxisTitle ) );
			}
			if ( spec.yAxisTitle ) {
				var yTitlePos = cam.project( 1.42, 0, -1 );
				sceneGroup.appendChild( U.createSvgEl( 'text', {
					x: yTitlePos.x.toFixed( 2 ), y: ( yTitlePos.y + 8 ).toFixed( 2 ),
					'text-anchor': 'start', 'font-size': '11px', 'font-weight': 'bold', 'font-family': 'sans-serif', fill: grey
				}, spec.yAxisTitle ) );
			}
			if ( spec.zAxisTitle ) {
				var zTitlePos = cam.project( -1, 1.32, 0 );
				sceneGroup.appendChild( U.createSvgEl( 'text', {
					x: ( zTitlePos.x - 10 ).toFixed( 2 ), y: zTitlePos.y.toFixed( 2 ),
					'text-anchor': 'end', 'font-size': '11px', 'font-weight': 'bold', 'font-family': 'sans-serif', fill: grey
				}, spec.zAxisTitle ) );
			}

			// 2. 建構 3D 圖元並執行 Z-Sorting (畫家演算法:由遠至近排序)
			var primitives = [];
			var zZeroNorm = normZ( Math.max( zMin, Math.min( zMax, 0 ) ) );

			if ( rawType === 'bar3d' ) {
				var halfW = Math.min( 0.14, 0.75 / Math.sqrt( Math.max( 1, pts3d.length ) ) );
				for ( var bi = 0; bi < pts3d.length; bi++ ) {
					var bp = pts3d[ bi ];
					var bnx = normX( bp.x ), bny = normY( bp.y ), bnz = normZ( bp.z );
					var tNorm = ( bp.z - zMin ) / ( zMax - zMin );
					var bCol = hasCmap ? U.sampleColormap( tNorm, cmap ) : palObj.colors[ bi % palObj.colors.length ];
					var faces = MathEngine.buildBar3DFaces(
						cam,
						bnx - halfW, bnx + halfW,
						bny - halfW, bny + halfW,
						Math.min( zZeroNorm, bnz ), Math.max( zZeroNorm, bnz ),
						bCol,
						{ x: bp.x, y: bp.y, z: bp.z, color: bCol }
					);
					for ( var fi = 0; fi < faces.length; fi++ ) {
						primitives.push( faces[ fi ] );
					}
				}
			} else {
				var prevProj = null;
				for ( var pi = 0; pi < pts3d.length; pi++ ) {
					var pt = pts3d[ pi ];
					var pProj = cam.project( normX( pt.x ), normY( pt.y ), normZ( pt.z ) );
					var tCol = ( pt.z - zMin ) / ( zMax - zMin );
					var pCol = hasCmap ? U.sampleColormap( tCol, cmap ) : palObj.colors[ 0 ];
					if ( ( rawType === 'line3d' || rawType === 'surface3d' ) && prevProj ) {
						primitives.push( {
							kind: 'line',
							depth: ( prevProj.depth + pProj.depth ) * 0.5,
							p0: prevProj,
							p1: pProj,
							stroke: pCol,
							strokeWidth: Number( spec.linewidth ) || 2.2
						} );
					}
					primitives.push( {
						kind: 'dot',
						depth: pProj.depth,
						x: pProj.x,
						y: pProj.y,
						r: rawType === 'line3d' ? 3.2 : 4.8,
						fill: pCol,
						meta: { x: pt.x, y: pt.y, z: pt.z, color: pCol }
					} );
					prevProj = pProj;
				}
			}

			primitives.sort( function ( a, b ) {
				return a.depth - b.depth;
			} );

			for ( var pri = 0; pri < primitives.length; pri++ ) {
				var pr = primitives[ pri ];
				if ( pr.kind === 'poly' ) {
					var polyStr = pr.pts.map( function ( p ) {
						return p.x.toFixed( 2 ) + ',' + p.y.toFixed( 2 );
					} ).join( ' ' );
					var polyEl = U.createSvgEl( 'polygon', {
						points: polyStr, fill: pr.fill, stroke: pr.stroke, 'stroke-width': pr.strokeWidth
					} );
					if ( pr.meta ) {
						( function ( m, tp ) {
							polyEl.addEventListener( 'mousemove', function () {
								var lbl = '(' + U.formatNumber( m.x, spec.xAxisFormat ) + ', ' + U.formatNumber( m.y, spec.yAxisFormat ) + ')';
								tipUI.show( tp.x, tp.y, totalW, totalH, m.color, lbl, 'Z = ' + U.formatNumber( m.z, spec.zAxisFormat ) );
							} );
							polyEl.addEventListener( 'mouseleave', function () { tipUI.hide(); } );
						}( pr.meta, pr.topPt ) );
					}
					sceneGroup.appendChild( polyEl );
				} else if ( pr.kind === 'line' ) {
					sceneGroup.appendChild( U.createSvgEl( 'line', {
						x1: pr.p0.x.toFixed( 2 ), y1: pr.p0.y.toFixed( 2 ),
						x2: pr.p1.x.toFixed( 2 ), y2: pr.p1.y.toFixed( 2 ),
						stroke: pr.stroke, 'stroke-width': pr.strokeWidth, 'stroke-linecap': 'round'
					} ) );
				} else if ( pr.kind === 'dot' ) {
					var dotEl = U.createSvgEl( 'circle', {
						cx: pr.x.toFixed( 2 ), cy: pr.y.toFixed( 2 ), r: pr.r,
						fill: pr.fill, stroke: '#ffffff', 'stroke-width': 1.1, style: 'cursor:pointer'
					} );
					( function ( dObj ) {
						dotEl.addEventListener( 'mousemove', function () {
							var lbl = '(' + U.formatNumber( dObj.meta.x, spec.xAxisFormat ) + ', ' + U.formatNumber( dObj.meta.y, spec.yAxisFormat ) + ')';
							tipUI.show( dObj.x, dObj.y, totalW, totalH, dObj.meta.color, lbl, 'Z = ' + U.formatNumber( dObj.meta.z, spec.zAxisFormat ) );
						} );
						dotEl.addEventListener( 'mouseleave', function () { tipUI.hide(); } );
					}( pr ) );
					sceneGroup.appendChild( dotEl );
				}
			}
		}

		drawScene();

		// 綁定滑鼠拖曳旋轉視角 (Drag-to-Rotate)
		var isDragging = false;
		var startX = 0, startY = 0;
		var startTheta = curTheta, startPhi = curPhi;

		svg.addEventListener( 'mousedown', function ( ev ) {
			isDragging = true;
			startX = ev.clientX;
			startY = ev.clientY;
			startTheta = curTheta;
			startPhi = curPhi;
			svg.style.cursor = 'grabbing';
			tipUI.hide();
		} );

		window.addEventListener( 'mousemove', function ( ev ) {
			if ( !isDragging ) {
				return;
			}
			var dx = ev.clientX - startX;
			var dy = ev.clientY - startY;
			curTheta = startTheta - dx * 0.55;
			curPhi = Math.max( -80, Math.min( 80, startPhi + dy * 0.45 ) );
			drawScene();
		} );

		window.addEventListener( 'mouseup', function () {
			if ( isDragging ) {
				isDragging = false;
				svg.style.cursor = 'grab';
			}
		} );

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

	var types3D = [ 'scatter3d', 'line3d', 'bar3d', 'surface3d' ];
	for ( var i = 0; i < types3D.length; i++ ) {
		GW.Renderers[ types3D[ i ] ] = renderChart3D;
	}
	GW.Chart3D = renderChart3D;
}() );