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