跳转到内容

User:A2569875/Module:Graph/RenderUtils.js

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

	Utils.PALETTE_CAT10 = [
		'#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd',
		'#8c564b', '#e377c2', '#7f7f7f', '#bcbd22', '#17becf'
	];
	Utils.PALETTE_CAT20 = [
		'#1f77b4', '#aec7e8', '#ff7f0e', '#ffbb78', '#2ca02c',
		'#98df8a', '#d62728', '#ff9896', '#9467bd', '#c5b0d5',
		'#8c564b', '#c49c94', '#e377c2', '#f7b6d2', '#7f7f7f',
		'#c7c7c7', '#bcbd22', '#dbdb8d', '#17becf', '#9edae5'
	];

	var CSS_COLORS = {
		white: [ 255, 255, 255 ], black: [ 0, 0, 0 ], silver: [ 192, 192, 192 ],
		gray: [ 128, 128, 128 ], grey: [ 128, 128, 128 ], red: [ 255, 0, 0 ],
		darkred: [ 139, 0, 0 ], green: [ 0, 128, 0 ], blue: [ 0, 0, 255 ],
		darkblue: [ 0, 0, 139 ], lightblue: [ 173, 216, 230 ], steelblue: [ 70, 130, 180 ],
		yellow: [ 255, 255, 0 ], gold: [ 255, 215, 0 ], orange: [ 255, 165, 0 ],
		purple: [ 128, 0, 128 ], pink: [ 255, 192, 203 ], brown: [ 165, 42, 42 ],
		cyan: [ 0, 255, 255 ], teal: [ 0, 128, 128 ], navy: [ 0, 0, 128 ]
	};

	Utils.SHAPES = {
		circle: 'circle',
		x: 'M-.5,-.5L.5,.5M.5,-.5L-.5,.5',
		square: 'square',
		cross: 'cross',
		diamond: 'diamond',
		triangle: 'triangle-up',
		triangle_up: 'triangle-up',
		triangle_down: 'triangle-down',
		triangle_right: 'triangle-right',
		triangle_left: 'triangle-left',
		'triangle-up': 'triangle-up',
		'triangle-down': 'triangle-down',
		'triangle-right': 'triangle-right',
		'triangle-left': 'triangle-left',
		star: 'M 0,-0.5 L 0.112,-0.154 L 0.475,-0.154 L 0.181,0.059 L 0.293,0.404 L 0,0.191 L -0.293,0.404 L -0.181,0.059 L -0.475,-0.154 L -0.112,-0.154 Z',
		wye: 'M 0,-0.5 L 0.12,-0.5 L 0.12,-0.08 L 0.48,0.28 L 0.36,0.40 L 0,0.06 L -0.36,0.40 L -0.48,0.28 L -0.12,-0.08 L -0.12,-0.5 Z',
		ring: 'M 0,-0.45 A 0.45,0.45 0 1,0 0,0.45 A 0.45,0.45 0 1,0 0,-0.45 M 0,-0.25 A 0.25,0.25 0 1,1 0,0.25 A 0.25,0.25 0 1,1 0,-0.25 Z',
		plus: 'M -0.12,-0.45 H 0.12 V -0.12 H 0.45 V 0.12 H 0.12 V 0.45 H -0.12 V 0.12 H -0.45 V -0.12 H -0.12 Z',
		banana: 'm -0.5281,0.2880 0.0020,0.0192 m 0,0 c 0.1253,0.0543 0.2118,0.0679 0.3268,0.0252 0.1569,-0.0582 0.3663,-0.1636 0.4607,-0.3407 0.0824,-0.1547 0.1202,-0.2850 0.0838,-0.4794 l 0.0111,-0.1498 -0.0457,-0.0015 c -0.0024,0.3045 -0.1205,0.5674 -0.3357,0.7414 -0.1409,0.1139 -0.3227,0.1693 -0.5031,0.1856 m 0,0 c 0.1804,-0.0163 0.3622,-0.0717 0.5031,-0.1856 0.2152,-0.1739 0.3329,-0.4291 0.3357,-0.7414 l -0.0422,0.0079 c 0,0 -0.0099,0.1111 -0.0227,0.1644 -0.0537,0.1937 -0.1918,0.3355 -0.3349,0.4481 -0.1393,0.1089 -0.2717,0.2072 -0.4326,0.2806 l -0.0062,0.0260'
	};

	var MONTH_ABBR = [ 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec' ];
	var MONTH_FULL = [ 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December' ];

	// =========================================================================
	// 1. 安全 DOM 與 SVG 建構器 (強制使用 textContent,禁止 innerHTML)
	// =========================================================================
	Utils.createSvgEl = function ( tag, attrs, text ) {
		var el = document.createElementNS( SVG_NS, tag );
		if ( attrs ) {
			for ( var k in attrs ) {
				if ( Object.prototype.hasOwnProperty.call( attrs, k ) && attrs[ k ] !== undefined && attrs[ k ] !== null ) {
					el.setAttribute( k, String( attrs[ k ] ) );
				}
			}
		}
		if ( text !== undefined && text !== null ) {
			el.textContent = String( text );
		}
		return el;
	};

	Utils.createHtmlEl = function ( tag, className, cssText, text ) {
		var el = document.createElement( tag );
		if ( className ) {
			el.className = className;
		}
		if ( cssText ) {
			el.style.cssText = cssText;
		}
		if ( text !== undefined && text !== null ) {
			el.textContent = String( text );
		}
		return el;
	};

	// 清除容器內預設的 ombox 警告框,但嚴格保留 MediaWiki 的 <style data-mw-deduplicate>、<link> 與 .gr-async-i18n 繁簡轉換字典
	Utils.mountStage = function ( container, stageWrap ) {
		var child = container.firstChild;
		while ( child ) {
			var next = child.nextSibling;
			var tag = child.nodeName ? child.nodeName.toLowerCase() : '';
			var isI18n = child.nodeType === 1 && child.classList && child.classList.contains( 'gr-async-i18n' );
			if ( tag !== 'style' && tag !== 'link' && !isI18n ) {
				container.removeChild( child );
			}
			child = next;
		}
		container.style.display = 'block';
		container.style.clear = 'both';
		container.appendChild( stageWrap );
	};

	// =========================================================================
	// 2. 數值與日期格式化 (對應 RenderUtils.formatNumber / formatDateValue)
	// =========================================================================
	function addCommas( numStr ) {
		var m = String( numStr ).match( /^([+-]?)(\d+)(\.?\d*)$/ );
		if ( !m ) {
			return String( numStr );
		}
		var intPart = m[ 2 ].replace( /\B(?=(\d{3})+(?!\d))/g, ',' );
		return m[ 1 ] + intPart + m[ 3 ];
	}

	Utils.formatNumber = function ( val, fmt, isInt ) {
		var n = Number( val );
		if ( isNaN( n ) ) {
			return String( val === undefined || val === null ? '' : val );
		}
		if ( Math.abs( n ) < 1e-12 ) {
			n = 0;
		}
		if ( !fmt || fmt === '' ) {
			if ( isInt || Math.abs( n - Math.round( n ) ) < 1e-7 ) {
				return addCommas( String( Math.round( n ) ) );
			}
			return addCommas( n.toFixed( 2 ).replace( /\.?0+$/, '' ) );
		}

		var sFmt = String( fmt );
		var useComma = sFmt.indexOf( ',' ) !== -1;
		var isPct = sFmt.indexOf( '%' ) !== -1;
		var isPermille = sFmt.indexOf( '‰' ) !== -1;
		var isSI = sFmt.indexOf( 's' ) !== -1;
		var precMatch = sFmt.match( /\.(\d+)/ );
		var prec = precMatch ? parseInt( precMatch[ 1 ], 10 ) : null;

		if ( isPct ) {
			n *= 100;
		} else if ( isPermille ) {
			n *= 1000;
		}
		if ( Math.abs( n ) < 1e-12 ) {
			n = 0;
		}

		var suffix = '';
		if ( isSI && n !== 0 ) {
			var absN = Math.abs( n );
			if ( absN >= 1e9 ) {
				n /= 1e9;
				suffix = 'G';
			} else if ( absN >= 1e6 ) {
				n /= 1e6;
				suffix = 'M';
			} else if ( absN >= 1e3 ) {
				n /= 1e3;
				suffix = 'k';
			}
		}

		var out;
		if ( prec !== null ) {
			out = n.toFixed( prec );
		} else if ( ( isInt && !isPct && !isPermille ) || Math.abs( n - Math.round( n ) ) < 1e-7 ) {
			out = String( Math.round( n ) );
		} else {
			out = n.toFixed( 2 ).replace( /\.?0+$/, '' );
		}

		if ( useComma ) {
			out = addCommas( out );
		}
		if ( isPct ) {
			out += '%';
		} else if ( isPermille ) {
			out += '‰';
		}
		return out + suffix;
	};

	Utils.parseDateMs = function ( v ) {
		if ( v === null || v === undefined || v === false ) {
			return NaN;
		}
		if ( typeof v === 'number' ) {
			if ( v > 1e8 ) {
				return v;
			}
			var yr = Math.floor( v );
			var rem = v - yr;
			return Date.UTC( yr, 0, 1 ) + rem * 365.25 * 86400000;
		}
		var s = String( v ).trim();
		var mTs = s.match( /^(\d{4})(\d{2})(\d{2})(?:\d{2})?$/ );
		if ( mTs ) {
			return Date.UTC( Number( mTs[ 1 ] ), Number( mTs[ 2 ] ) - 1, Number( mTs[ 3 ] ) );
		}
		var mIso = s.match( /^(\d{4})[-/](\d{1,2})(?:[-/](\d{1,2}))?/ );
		if ( mIso ) {
			return Date.UTC( Number( mIso[ 1 ] ), Number( mIso[ 2 ] ) - 1, Number( mIso[ 3 ] || 1 ) );
		}
		var parsed = Date.parse( s );
		return isNaN( parsed ) ? Number( s ) : parsed;
	};

	Utils.formatDateMs = function ( ms, fmt, spanMs ) {
		var d = new Date( ms );
		if ( isNaN( d.getTime() ) ) {
			return String( ms );
		}
		var y = d.getUTCFullYear();
		var m = d.getUTCMonth() + 1;
		var day = d.getUTCDate();
		var mm = ( m < 10 ? '0' : '' ) + m;
		var dd = ( day < 10 ? '0' : '' ) + day;

		if ( !fmt || fmt === '' ) {
			if ( spanMs !== undefined && spanMs > 86400000 * 365 * 4 ) {
				return String( y );
			}
			if ( spanMs !== undefined && spanMs > 86400000 * 365 * 1.5 ) {
				return y + '-' + mm;
			}
			return y + '-' + mm + '-' + dd;
		}
		return String( fmt )
			.replace( /%Y/g, String( y ) )
			.replace( /%y/g, String( y % 100 ).replace( /^(\d)$/, '0$1' ) )
			.replace( /%B/g, MONTH_FULL[ m - 1 ] || '' )
			.replace( /%b/g, MONTH_ABBR[ m - 1 ] || '' )
			.replace( /%m/g, mm )
			.replace( /%d/g, dd );
	};

	// =========================================================================
	// 3. 比例尺與座標軸刻度計算 (applyScale / computeNiceTicks)
	// =========================================================================
	Utils.applyScale = function ( val, minV, maxV, scaleType ) {
		if ( maxV <= minV ) {
			return 0.5;
		}
		var sType = String( scaleType || 'linear' ).toLowerCase();
		if ( sType === 'log' ) {
			var lMin = Math.log( Math.max( 1e-9, minV ) ) / Math.LN10;
			var lMax = Math.log( Math.max( 1e-8, maxV ) ) / Math.LN10;
			if ( lMax <= lMin ) {
				return 0.5;
			}
			var lVal = Math.log( Math.max( 1e-9, val ) ) / Math.LN10;
			return ( lVal - lMin ) / ( lMax - lMin );
		} else if ( sType === 'sqrt' ) {
			var sMin = Math.sqrt( Math.max( 0, minV ) );
			var sMax = Math.sqrt( Math.max( 1e-9, maxV ) );
			return ( Math.sqrt( Math.max( 0, val ) ) - sMin ) / ( sMax - sMin );
		} else if ( sType.indexOf( 'pow' ) === 0 ) {
			var mExp = sType.match( /([\d.]+)/ );
			var expN = mExp ? Number( mExp[ 1 ] ) : 2;
			var pMin = ( minV >= 0 ? 1 : -1 ) * Math.pow( Math.abs( minV ), expN );
			var pMax = ( maxV >= 0 ? 1 : -1 ) * Math.pow( Math.abs( maxV ), expN );
			var pVal = ( val >= 0 ? 1 : -1 ) * Math.pow( Math.abs( val ), expN );
			return ( pVal - pMin ) / ( pMax - pMin );
		}
		return ( val - minV ) / ( maxV - minV );
	};

	Utils.computeNiceTicks = function ( minV, maxV, targetCount, scaleType, isInteger ) {
		targetCount = Math.max( 2, Number( targetCount ) || 5 );
		if ( minV === maxV ) {
			if ( minV === 0 ) {
				maxV = 10;
			} else {
				minV -= 1;
				maxV += 1;
			}
		}
		var sType = String( scaleType || 'linear' ).toLowerCase();
		if ( sType === 'log' && minV > 0 && maxV > minV ) {
			var pMin = Math.floor( Math.log( minV ) / Math.LN10 + 1e-9 );
			var pMax = Math.ceil( Math.log( maxV ) / Math.LN10 - 1e-9 );
			var logTicks = [];
			for ( var e = pMin; e <= pMax; e++ ) {
				logTicks.push( Math.pow( 10, e ) );
			}
			if ( logTicks.length >= 2 ) {
				return {
					ticks: logTicks,
					min: Math.min( minV, logTicks[ 0 ] ),
					max: Math.max( maxV, logTicks[ logTicks.length - 1 ] )
				};
			}
		}

		var span = maxV - minV;
		var rawStep = span / targetCount;
		var mag = Math.pow( 10, Math.floor( Math.log( Math.max( 1e-12, rawStep ) ) / Math.LN10 ) );
		var norm = rawStep / mag;
		var step;
		if ( norm <= 1.5 ) {
			step = 1 * mag;
		} else if ( norm <= 3.0 ) {
			step = 2 * mag;
		} else if ( norm <= 7.0 ) {
			step = 5 * mag;
		} else {
			step = 10 * mag;
		}
		if ( isInteger && step < 1 ) {
			step = 1;
		}

		var niceMin = Math.floor( minV / step + 1e-9 ) * step;
		var niceMax = Math.ceil( maxV / step - 1e-9 ) * step;
		var ticks = [];
		var guard = 0;
		for ( var cur = niceMin; cur <= niceMax + step * 0.001 && guard < 100; cur += step ) {
			ticks.push( Math.abs( cur ) < 1e-11 ? 0 : cur );
			guard++;
		}
		return { ticks: ticks, min: niceMin, max: niceMax };
	};

	// =========================================================================
	// 4. 顏色與科學色階系統 (支援內建、方案 A 與方案 B 之漸層式/離散式色階)
	// =========================================================================
	Utils.isValidColorToken = function ( cStr ) {
		if ( typeof cStr !== 'string' ) {
			return false;
		}
		var s = cStr.trim().toLowerCase();
		if ( !s || s === 'category10' || s === 'category20' ) {
			return false;
		}
		if ( CSS_COLORS[ s ] ) {
			return true;
		}
		if ( /^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test( s ) ) {
			return true;
		}
		if ( /^rgba?\(\s*\d+\s*,\s*\d+\s*,\s*\d+(?:\s*,\s*[\d.]+)?\s*\)$/i.test( s ) ) {
			return true;
		}
		return false;
	};

	Utils.parseRGB = function ( cStr ) {
		if ( !cStr ) {
			return [ 128, 128, 128 ];
		}
		var s = String( cStr ).trim().toLowerCase();
		if ( CSS_COLORS[ s ] ) {
			return CSS_COLORS[ s ].slice();
		}
		var m6 = s.match( /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i );
		if ( m6 ) {
			return [ parseInt( m6[ 1 ], 16 ), parseInt( m6[ 2 ], 16 ), parseInt( m6[ 3 ], 16 ) ];
		}
		var m8 = s.match( /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i );
		if ( m8 ) {
			return [ parseInt( m8[ 2 ], 16 ), parseInt( m8[ 3 ], 16 ), parseInt( m8[ 4 ], 16 ) ];
		}
		var m3 = s.match( /^#([0-9a-f])([0-9a-f])([0-9a-f])$/i );
		if ( m3 ) {
			return [
				parseInt( m3[ 1 ] + m3[ 1 ], 16 ),
				parseInt( m3[ 2 ] + m3[ 2 ], 16 ),
				parseInt( m3[ 3 ] + m3[ 3 ], 16 )
			];
		}
		var mRgb = s.match( /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i );
		if ( mRgb ) {
			return [ Number( mRgb[ 1 ] ), Number( mRgb[ 2 ] ), Number( mRgb[ 3 ] ) ];
		}
		return [ 128, 128, 128 ];
	};

	Utils.rgbToHex = function ( r, g, b ) {
		var toHex = function ( n ) {
			var v = Math.max( 0, Math.min( 255, Math.round( n ) ) );
			return ( v < 16 ? '0' : '' ) + v.toString( 16 );
		};
		return '#' + toHex( r ) + toHex( g ) + toHex( b );
	};

	Utils.buildColorAndAlphaPalette = function ( colorsInput, chartType, xCount, yCount ) {
		var base = null;
		if ( Array.isArray( colorsInput ) && colorsInput.length > 0 ) {
			base = colorsInput;
		} else if ( typeof colorsInput === 'string' && colorsInput.trim() !== '' ) {
			var splitCols = colorsInput.split( ',' );
			var validCols = [];
			for ( var i = 0; i < splitCols.length; i++ ) {
				var tk = splitCols[ i ].trim();
				if ( Utils.isValidColorToken( tk ) ) {
					validCols.push( tk );
				}
			}
			if ( validCols.length > 0 ) {
				base = validCols;
			}
		}
		if ( !base ) {
			base = ( ( chartType === 'pie' && xCount > 10 ) || yCount > 10 ) ? Utils.PALETTE_CAT20 : Utils.PALETTE_CAT10;
		}

		var colors = [];
		var alphas = [];
		for ( var j = 0; j < base.length; j++ ) {
			var c = String( base[ j ] || '' ).trim();
			var m8 = c.match( /^#([0-9a-fA-F]{2})([0-9a-fA-F]{6})$/ );
			if ( m8 ) {
				colors.push( '#' + m8[ 2 ] );
				alphas.push( parseInt( m8[ 1 ], 16 ) / 255 );
			} else {
				colors.push( c );
				alphas.push( 1.0 );
			}
		}
		return { colors: colors, alphas: alphas };
	};

	Utils.COLORMAPS = {
		viridis: [ [ 68, 1, 84 ], [ 59, 82, 139 ], [ 33, 145, 140 ], [ 94, 201, 98 ], [ 253, 231, 37 ] ],
		coolwarm: [ [ 59, 76, 192 ], [ 141, 176, 254 ], [ 221, 221, 221 ], [ 244, 154, 123 ], [ 180, 4, 38 ] ],
		plasma: [ [ 13, 8, 135 ], [ 126, 3, 168 ], [ 204, 71, 120 ], [ 248, 149, 64 ], [ 240, 249, 33 ] ],
		inferno: [ [ 0, 0, 4 ], [ 87, 16, 110 ], [ 188, 55, 84 ], [ 249, 142, 9 ], [ 252, 255, 164 ] ],
		magma: [ [ 0, 0, 4 ], [ 81, 18, 124 ], [ 183, 55, 121 ], [ 251, 136, 97 ], [ 252, 253, 191 ] ],
		rainbow: [ [ 48, 18, 59 ], [ 40, 188, 235 ], [ 164, 252, 60 ], [ 251, 126, 33 ], [ 122, 4, 3 ] ],
		gray: [ [ 0, 0, 0 ], [ 255, 255, 255 ] ],
		greys: [ [ 255, 255, 255 ], [ 0, 0, 0 ] ]
	};

	function evalJetRGB( t ) {
		var rf, gf, bf;
		if ( t <= 0.35 ) {
			rf = 0;
		} else if ( t <= 0.66 ) {
			rf = ( t - 0.35 ) / 0.31;
		} else if ( t <= 0.89 ) {
			rf = 1;
		} else {
			rf = 1 - 0.5 * ( t - 0.89 ) / 0.11;
		}

		if ( t <= 0.125 ) {
			gf = 0;
		} else if ( t <= 0.375 ) {
			gf = ( t - 0.125 ) / 0.25;
		} else if ( t <= 0.64 ) {
			gf = 1;
		} else if ( t <= 0.91 ) {
			gf = 1 - ( t - 0.64 ) / 0.27;
		} else {
			gf = 0;
		}

		if ( t <= 0.11 ) {
			bf = 0.5 + 0.5 * ( t / 0.11 );
		} else if ( t <= 0.34 ) {
			bf = 1;
		} else if ( t <= 0.65 ) {
			bf = 1 - ( t - 0.34 ) / 0.31;
		} else {
			bf = 0;
		}
		return [ rf * 255, gf * 255, bf * 255 ];
	}

	var cmapCache = {};
	Utils.parseColormapSpec = function ( rawName ) {
		if ( typeof rawName !== 'string' || !rawName.trim() ) {
			return null;
		}
		var s = rawName.trim();
		if ( cmapCache[ s ] !== undefined ) {
			return cmapCache[ s ];
		}
		var body = s;
		var isDiscrete = false;
		var mPre = body.match( /^([a-zA-Z]+)\s*:\s*(.+)$/ );
		if ( mPre ) {
			var lp = mPre[ 1 ].toLowerCase();
			if ( lp === 'discrete' || lp === 'step' || lp === 'steps' ) {
				isDiscrete = true;
				body = mPre[ 2 ].trim();
			} else if ( lp === 'gradient' || lp === 'continuous' || lp === 'linear' ) {
				body = mPre[ 2 ].trim();
			}
		}
		var mBr = body.match( /^\[\s*(.*?)\s*\]$/ );
		if ( mBr ) {
			body = mBr[ 1 ];
		}

		var cname = body.toLowerCase();
		if ( cname === 'jet' ) {
			cmapCache[ s ] = { kind: 'jet', isDiscrete: isDiscrete, nSteps: 7 };
			return cmapCache[ s ];
		}
		if ( Utils.COLORMAPS[ cname ] ) {
			var builtIn = Utils.COLORMAPS[ cname ];
			cmapCache[ s ] = { kind: 'stops', stops: builtIn, isDiscrete: isDiscrete, nSteps: builtIn.length };
			return cmapCache[ s ];
		}

		var sep = null;
		if ( body.indexOf( ';' ) !== -1 ) {
			sep = ';';
			isDiscrete = true;
		} else if ( body.indexOf( ':' ) !== -1 ) {
			sep = ':';
		} else if ( body.indexOf( ',' ) !== -1 ) {
			sep = ',';
		}
		if ( sep ) {
			var parts = body.split( sep );
			if ( parts.length >= 2 ) {
				var stops = [];
				var ok = true;
				for ( var i = 0; i < parts.length; i++ ) {
					var tk = parts[ i ].trim();
					if ( Utils.isValidColorToken( tk ) ) {
						stops.push( Utils.parseRGB( tk ) );
					} else {
						ok = false;
						break;
					}
				}
				if ( ok && stops.length >= 2 ) {
					cmapCache[ s ] = { kind: 'stops', stops: stops, isDiscrete: isDiscrete, nSteps: stops.length };
					return cmapCache[ s ];
				}
			}
		}
		cmapCache[ s ] = null;
		return null;
	};

	Utils.isColormapName = function ( name ) {
		return Utils.parseColormapSpec( name ) !== null;
	};

	Utils.sampleColormap = function ( t, mapName ) {
		t = Math.max( 0, Math.min( 1, Number( t ) || 0 ) );
		var spec = Utils.parseColormapSpec( mapName ) || Utils.parseColormapSpec( 'viridis' );
		if ( spec.isDiscrete ) {
			var nSteps = Math.max( 2, spec.nSteps || ( spec.stops && spec.stops.length ) || 5 );
			var bucket = Math.min( nSteps, Math.max( 1, Math.floor( t * nSteps ) + 1 ) );
			if ( spec.kind === 'jet' ) {
				var bt = ( bucket - 1 ) / Math.max( 1, nSteps - 1 );
				var jRgb = evalJetRGB( bt );
				return Utils.rgbToHex( jRgb[ 0 ], jRgb[ 1 ], jRgb[ 2 ] );
			}
			var dCol = spec.stops[ Math.min( spec.stops.length - 1, bucket - 1 ) ];
			return Utils.rgbToHex( dCol[ 0 ], dCol[ 1 ], dCol[ 2 ] );
		}
		if ( spec.kind === 'jet' ) {
			var rgb = evalJetRGB( t );
			return Utils.rgbToHex( rgb[ 0 ], rgb[ 1 ], rgb[ 2 ] );
		}
		var stops = spec.stops || Utils.COLORMAPS.viridis;
		var n = stops.length;
		var scaled = t * ( n - 1 );
		var idx = Math.min( n - 2, Math.floor( scaled ) );
		var frac = scaled - idx;
		var c0 = stops[ idx ], c1 = stops[ idx + 1 ];
		return Utils.rgbToHex(
			c0[ 0 ] + ( c1[ 0 ] - c0[ 0 ] ) * frac,
			c0[ 1 ] + ( c1[ 1 ] - c0[ 1 ] ) * frac,
			c0[ 2 ] + ( c1[ 2 ] - c0[ 2 ] ) * frac
		);
	};

	Utils.interpolateColorRamp = function ( val, domain, range ) {
		if ( !Array.isArray( domain ) || !Array.isArray( range ) || domain.length === 0 || range.length === 0 ) {
			return '#3182bd';
		}
		var v = Number( val ) || 0;
		if ( v <= Number( domain[ 0 ] ) ) {
			return range[ 0 ];
		}
		var n = Math.min( domain.length, range.length );
		if ( v >= Number( domain[ n - 1 ] ) ) {
			return range[ n - 1 ];
		}
		for ( var i = 0; i < n - 1; i++ ) {
			var d0 = Number( domain[ i ] ), d1 = Number( domain[ i + 1 ] );
			if ( v >= d0 && v <= d1 ) {
				var t = d1 > d0 ? ( v - d0 ) / ( d1 - d0 ) : 0;
				var c0 = Utils.parseRGB( range[ i ] );
				var c1 = Utils.parseRGB( range[ i + 1 ] );
				return Utils.rgbToHex(
					c0[ 0 ] + ( c1[ 0 ] - c0[ 0 ] ) * t,
					c0[ 1 ] + ( c1[ 1 ] - c0[ 1 ] ) * t,
					c0[ 2 ] + ( c1[ 2 ] - c0[ 2 ] ) * t
				);
			}
		}
		return range[ n - 1 ];
	};

	function appendGradientStops( gradEl, mapName ) {
		var spec = Utils.parseColormapSpec( mapName );
		if ( spec && spec.isDiscrete ) {
			var n = Math.max( 2, spec.nSteps || ( spec.stops && spec.stops.length ) || 5 );
			for ( var k = 1; k <= n; k++ ) {
				var p0 = Math.round( ( ( k - 1 ) / n ) * 100 );
				var p1 = Math.round( ( k / n ) * 100 );
				var col = Utils.sampleColormap( ( k - 0.5 ) / n, mapName );
				gradEl.appendChild( Utils.createSvgEl( 'stop', { offset: p0 + '%', 'stop-color': col } ) );
				gradEl.appendChild( Utils.createSvgEl( 'stop', { offset: p1 + '%', 'stop-color': col } ) );
			}
		} else {
			var nStops = ( spec && spec.stops && spec.stops.length > 10 ) ? spec.stops.length : 10;
			for ( var s = 0; s <= nStops; s++ ) {
				var t = s / nStops;
				gradEl.appendChild( Utils.createSvgEl( 'stop', {
					offset: Math.round( t * 100 ) + '%',
					'stop-color': Utils.sampleColormap( t, mapName )
				} ) );
			}
		}
	}

	Utils.buildColormapGradientEl = function ( gradId, mapName, x1, y1, x2, y2, useUserSpace ) {
		var attrs = {
			id: gradId,
			x1: typeof x1 === 'number' ? x1.toFixed( 2 ) : ( x1 || '0%' ),
			y1: typeof y1 === 'number' ? y1.toFixed( 2 ) : ( y1 || '0%' ),
			x2: typeof x2 === 'number' ? x2.toFixed( 2 ) : ( x2 || '100%' ),
			y2: typeof y2 === 'number' ? y2.toFixed( 2 ) : ( y2 || '0%' )
		};
		if ( useUserSpace ) {
			attrs.gradientUnits = 'userSpaceOnUse';
		}
		var grad = Utils.createSvgEl( 'linearGradient', attrs );
		appendGradientStops( grad, mapName );
		return grad;
	};

	Utils.buildColormapRadialEl = function ( gradId, mapName, cx, cy, r ) {
		var grad = Utils.createSvgEl( 'radialGradient', {
			id: gradId,
			cx: cx.toFixed( 2 ),
			cy: cy.toFixed( 2 ),
			r: r.toFixed( 2 ),
			gradientUnits: 'userSpaceOnUse'
		} );
		appendGradientStops( grad, mapName );
		return grad;
	};

	// =========================================================================
	// 5. 折線路徑與幾何符號產生器 (buildPathD / createSymbolEl)
	// =========================================================================
	Utils.buildPathD = function ( pts, interpolate, isReverse ) {
		var n = pts.length;
		if ( n === 0 ) {
			return '';
		}
		var list = isReverse ? pts.slice().reverse() : pts;
		var mode = String( interpolate || 'linear' ).toLowerCase();
		var d = [ ( isReverse ? 'L ' : 'M ' ) + list[ 0 ][ 0 ].toFixed( 2 ) + ',' + list[ 0 ][ 1 ].toFixed( 2 ) ];
		if ( n === 1 ) {
			return d[ 0 ];
		}
		if ( mode === 'step' ) {
			for ( var i = 0; i < n - 1; i++ ) {
				var midX = ( ( list[ i ][ 0 ] + list[ i + 1 ][ 0 ] ) / 2 ).toFixed( 2 );
				d.push( 'H ' + midX + ' V ' + list[ i + 1 ][ 1 ].toFixed( 2 ) + ' H ' + list[ i + 1 ][ 0 ].toFixed( 2 ) );
			}
		} else if ( mode === 'step-after' ) {
			for ( var sa = 0; sa < n - 1; sa++ ) {
				d.push( 'H ' + list[ sa + 1 ][ 0 ].toFixed( 2 ) + ' V ' + list[ sa + 1 ][ 1 ].toFixed( 2 ) );
			}
		} else if ( mode === 'step-before' ) {
			for ( var sb = 0; sb < n - 1; sb++ ) {
				d.push( 'V ' + list[ sb + 1 ][ 1 ].toFixed( 2 ) + ' H ' + list[ sb + 1 ][ 0 ].toFixed( 2 ) );
			}
		} else if ( ( mode === 'monotone' || mode === 'cardinal' || mode === 'basis' || mode === 'bundle' ) && n >= 3 ) {
			for ( var k = 0; k < n - 1; k++ ) {
				var p0 = list[ Math.max( 0, k - 1 ) ];
				var p1 = list[ k ];
				var p2 = list[ k + 1 ];
				var p3 = list[ Math.min( n - 1, k + 2 ) ];
				var cp1x = p1[ 0 ] + ( p2[ 0 ] - p0[ 0 ] ) / 6;
				var cp1y = p1[ 1 ] + ( p2[ 1 ] - p0[ 1 ] ) / 6;
				var cp2x = p2[ 0 ] - ( p3[ 0 ] - p1[ 0 ] ) / 6;
				var cp2y = p2[ 1 ] - ( p3[ 1 ] - p1[ 1 ] ) / 6;
				if ( mode === 'monotone' ) {
					var minY = Math.min( p1[ 1 ], p2[ 1 ] );
					var maxY = Math.max( p1[ 1 ], p2[ 1 ] );
					cp1y = Math.max( minY, Math.min( maxY, cp1y ) );
					cp2y = Math.max( minY, Math.min( maxY, cp2y ) );
				}
				d.push(
					'C ' + cp1x.toFixed( 2 ) + ',' + cp1y.toFixed( 2 ) + ' ' +
					cp2x.toFixed( 2 ) + ',' + cp2y.toFixed( 2 ) + ' ' +
					p2[ 0 ].toFixed( 2 ) + ',' + p2[ 1 ].toFixed( 2 )
				);
			}
		} else {
			for ( var j = 1; j < n; j++ ) {
				d.push( 'L ' + list[ j ][ 0 ].toFixed( 2 ) + ',' + list[ j ][ 1 ].toFixed( 2 ) );
			}
		}
		return d.join( ' ' );
	};

	Utils.createSymbolEl = function ( shapeKey, cx, cy, r, fill, stroke, strokeW, alpha ) {
		var shape = Utils.SHAPES[ shapeKey ] || shapeKey || 'circle';
		var attrs = { fill: fill || 'none' };
		if ( strokeW && strokeW > 0 ) {
			attrs.stroke = stroke;
			attrs[ 'stroke-width' ] = strokeW.toFixed( 1 );
		}
		if ( alpha !== undefined && alpha < 1 ) {
			attrs[ 'fill-opacity' ] = alpha.toFixed( 2 );
			attrs[ 'stroke-opacity' ] = alpha.toFixed( 2 );
		}

		if ( shape === 'square' ) {
			var half = r * 0.9;
			attrs.x = ( cx - half ).toFixed( 2 );
			attrs.y = ( cy - half ).toFixed( 2 );
			attrs.width = ( half * 2 ).toFixed( 2 );
			attrs.height = ( half * 2 ).toFixed( 2 );
			return Utils.createSvgEl( 'rect', attrs );
		} else if ( shape === 'diamond' ) {
			var d = r * 1.3;
			attrs.points = [
				cx.toFixed( 2 ) + ',' + ( cy - d ).toFixed( 2 ),
				( cx + d ).toFixed( 2 ) + ',' + cy.toFixed( 2 ),
				cx.toFixed( 2 ) + ',' + ( cy + d ).toFixed( 2 ),
				( cx - d ).toFixed( 2 ) + ',' + cy.toFixed( 2 )
			].join( ' ' );
			return Utils.createSvgEl( 'polygon', attrs );
		} else if ( shape === 'triangle-up' ) {
			var du = r * 1.25;
			attrs.points = [
				cx.toFixed( 2 ) + ',' + ( cy - du ).toFixed( 2 ),
				( cx + du ).toFixed( 2 ) + ',' + ( cy + du * 0.75 ).toFixed( 2 ),
				( cx - du ).toFixed( 2 ) + ',' + ( cy + du * 0.75 ).toFixed( 2 )
			].join( ' ' );
			return Utils.createSvgEl( 'polygon', attrs );
		} else if ( shape === 'triangle-down' ) {
			var dd = r * 1.25;
			attrs.points = [
				cx.toFixed( 2 ) + ',' + ( cy + dd ).toFixed( 2 ),
				( cx + dd ).toFixed( 2 ) + ',' + ( cy - dd * 0.75 ).toFixed( 2 ),
				( cx - dd ).toFixed( 2 ) + ',' + ( cy - dd * 0.75 ).toFixed( 2 )
			].join( ' ' );
			return Utils.createSvgEl( 'polygon', attrs );
		} else if ( shape === 'triangle-right' ) {
			var dr = r * 1.25;
			attrs.points = [
				( cx + dr ).toFixed( 2 ) + ',' + cy.toFixed( 2 ),
				( cx - dr * 0.75 ).toFixed( 2 ) + ',' + ( cy - dr ).toFixed( 2 ),
				( cx - dr * 0.75 ).toFixed( 2 ) + ',' + ( cy + dr ).toFixed( 2 )
			].join( ' ' );
			return Utils.createSvgEl( 'polygon', attrs );
		} else if ( shape === 'triangle-left' ) {
			var dl = r * 1.25;
			attrs.points = [
				( cx - dl ).toFixed( 2 ) + ',' + cy.toFixed( 2 ),
				( cx + dl * 0.75 ).toFixed( 2 ) + ',' + ( cy - dl ).toFixed( 2 ),
				( cx + dl * 0.75 ).toFixed( 2 ) + ',' + ( cy + dl ).toFixed( 2 )
			].join( ' ' );
			return Utils.createSvgEl( 'polygon', attrs );
		} else if ( shape === 'cross' ) {
			var dc = r * 0.95;
			return Utils.createSvgEl( 'path', {
				d: 'M ' + ( cx - dc ).toFixed( 2 ) + ',' + cy.toFixed( 2 ) + ' H ' + ( cx + dc ).toFixed( 2 ) +
					' M ' + cx.toFixed( 2 ) + ',' + ( cy - dc ).toFixed( 2 ) + ' V ' + ( cy + dc ).toFixed( 2 ),
				stroke: ( strokeW && strokeW > 0 ) ? stroke : ( fill !== 'none' ? fill : '#1f77b4' ),
				'stroke-width': ( strokeW && strokeW > 0 ) ? strokeW : 2.0,
				'stroke-linecap': 'round'
			} );
		} else if ( typeof shape === 'string' && shape !== 'circle' ) {
			attrs.d = shape;
			attrs.transform = 'translate(' + cx.toFixed( 2 ) + ',' + cy.toFixed( 2 ) + ') scale(' + ( r * 2.2 ).toFixed( 2 ) + ')';
			return Utils.createSvgEl( 'path', attrs );
		}
		attrs.cx = cx.toFixed( 2 );
		attrs.cy = cy.toFixed( 2 );
		attrs.r = r.toFixed( 2 );
		return Utils.createSvgEl( 'circle', attrs );
	};

	// 建立共用浮動 Tooltip 元件
	Utils.createTooltipUI = function () {
		var tipEl = Utils.createHtmlEl(
			'div',
			'gr-async-tooltip',
			'display:none;position:absolute;z-index:20;pointer-events:none;background:rgba(255,255,255,0.96);' +
			'border:1px solid #a2a9b1;border-radius:4px;padding:4px 8px;font-size:12px;color:#202122;' +
			'box-shadow:0 2px 6px rgba(0,0,0,0.15);white-space:nowrap;transform:translate(-50%,-125%)'
		);
		var swatchEl = Utils.createHtmlEl(
			'span',
			'',
			'display:inline-block;width:9px;height:9px;border-radius:50%;margin-right:6px;vertical-align:middle'
		);
		var titleEl = Utils.createHtmlEl( 'strong', '', 'margin-right:4px' );
		var valEl = Utils.createHtmlEl( 'span', '' );
		tipEl.appendChild( swatchEl );
		tipEl.appendChild( titleEl );
		tipEl.appendChild( valEl );

		return {
			el: tipEl,
			show: function ( px, py, totalW, totalH, color, labelText, valueText ) {
				if ( color ) {
					swatchEl.style.display = 'inline-block';
					swatchEl.style.backgroundColor = color;
				} else {
					swatchEl.style.display = 'none';
				}
				titleEl.textContent = labelText ? ( labelText + ( valueText ? ': ' : '' ) ) : '';
				valEl.textContent = valueText || '';
				var leftPct = ( px / Math.max( 1, totalW ) ) * 100;
				var topPct = ( py / Math.max( 1, totalH ) ) * 100;
				tipEl.style.left = leftPct.toFixed( 2 ) + '%';
				tipEl.style.top = topPct.toFixed( 2 ) + '%';
				var tx = leftPct < 20 ? '0%' : ( leftPct > 80 ? '-100%' : '-50%' );
				var ty = topPct < 25 ? '25%' : '-125%';
				tipEl.style.transform = 'translate(' + tx + ',' + ty + ')';
				tipEl.style.display = 'block';
			},
			hide: function () {
				tipEl.style.display = 'none';
			}
		};
	};

	GW.Utils = Utils;
}() );