瀏覽代碼

Restructuring code

= 9 月之前
父節點
當前提交
00b9c8b831

+ 1 - 0
reports/.gitignore

@@ -0,0 +1 @@
+node_modules/

File diff suppressed because it is too large
+ 0 - 0
reports/data/knownCompanies.json


File diff suppressed because it is too large
+ 0 - 0
reports/data/knownCompanies2.json


+ 1 - 9
reports/index.html

@@ -6,7 +6,6 @@
     <title>PrUn Financial Reports</title>
 	<link rel="stylesheet" href="styles.css">
 	<link rel="icon" type="image/x-icon" href="icon128.png">
-	<script src="material-info.js"></script>
 	<script src="main.js"></script>
 	<script src="https://cdn.plot.ly/plotly-3.0.1.min.js" charset="utf-8"></script>
 </head>
@@ -52,14 +51,7 @@
 	<div class="plotSelectorContainer" id="graphTypeContainer">
 		<label>
 			Graph: 
-			<select class="plotSelector" id="graphType">
-				<option value="topProduction">Top Production</option>
-				<option value="topCompanies">Top Companies</option>
-				<option value="matHistory">MAT History</option>
-				<option value="compTotals">Company Totals</option>
-				<option value="compHistory">Company History</option>
-				<option value="compRank">Company Rank</option>
-				
+			<select class="plotSelector" id="graphType">	
 			</select>
 		</label>
 	</div>

+ 1112 - 0
reports/main - Copy.js

@@ -0,0 +1,1112 @@
+const loadedData = {};	// Data loaded from files
+const monthsPretty = ["March 3025", "April 3025", "May 3025", "June 3025", "July 3025"];
+const months = ["mar25", "apr25", "may25", "jun25", "jul25"];
+const currentMonth = "jul25";
+	
+window.onload = function() {
+	const graphTypeSelector = document.getElementById("graphType");
+	const selectorSubtypes = document.getElementById("selectorSubtypes");
+	
+	graphTypeSelector.addEventListener("change", function() {
+		updateSelectors(graphTypeSelector, selectorSubtypes);
+		switchPlot();
+	});
+	
+	updateSelectors(graphTypeSelector, selectorSubtypes, (new URLSearchParams(window.location.search)).has('type'));
+	switchPlot();
+	
+	// Permalink stuff
+	const permalinkContainer = document.getElementById("permalinkContainer");
+	const permalinkButton = document.getElementById("permalinkButton");
+	const permalinkCopyButton = document.getElementById("permalinkCopyButton");
+	const rprunCopyButton = document.getElementById("permalinkCopyButton-rprun");
+	const permalinkOptionsButton = document.getElementById("hideOptions");
+	const permalinkLatestMonth = document.getElementById("latestMonth");
+	
+	permalinkButton.addEventListener("click", function(e) {
+		e.stopPropagation();
+		const currentDisplay = permalinkContainer.style.display;
+		if(currentDisplay == "none")
+		{
+			permalinkContainer.style.display = "block";
+		}
+		else
+		{
+			permalinkContainer.style.display = "none";
+		}
+	});
+
+	document.addEventListener("click", function(e) {
+		if(!permalinkContainer.contains(e.target) && !permalinkButton.contains(e.target))
+		{
+			permalinkContainer.style.display = "none";
+		}
+	});
+
+	permalinkCopyButton.addEventListener("click", function() {
+		const permalinkElem = document.getElementById("permalink");
+		if(permalinkElem.value && permalinkElem.value != "")
+		{
+			navigator.clipboard.writeText(permalinkElem.value);
+		}
+	});
+	
+	rprunCopyButton.addEventListener("click", function() {
+		const permalinkElem = document.getElementById("permalink-rprun");
+		if(permalinkElem.value && permalinkElem.value != "")
+		{
+			navigator.clipboard.writeText(permalinkElem.value);
+		}
+	});
+
+	permalinkOptionsButton.addEventListener("change", function() {
+		updatePermalink(graphTypeSelector);
+	});
+	permalinkLatestMonth.addEventListener("change", function() {
+		updatePermalink(graphTypeSelector);
+	});
+};
+
+// Update selectors based on graph type
+function updateSelectors(graphTypeSelector, selectorSubtypes, useURLParams)
+{
+	clearChildren(selectorSubtypes);
+	
+	const urlParams = new URLSearchParams(window.location.search);
+	
+	if(urlParams.has('hideOptions'))
+	{
+		const graphTypeContainer = document.getElementById('graphTypeContainer');
+		const topTabs = document.getElementById('topTabContainer');
+		topTabContainer.style.display = 'none';
+		graphTypeContainer.style.display = 'none';
+		selectorSubtypes.style.display = 'none';
+	}
+	
+	if(useURLParams)
+	{
+		graphTypeSelector.value = urlParams.get('type');
+	}
+	
+	if(graphTypeSelector.value == "topProduction")
+	{
+		selectorSubtypes.appendChild(addInput('select', 'metric', 'Metric: ', [['Volume', 'Profit', 'Deficit'], ['volume', 'profit', 'deficit']], useURLParams && urlParams.get('metric')));
+		
+		selectorSubtypes.appendChild(addInput('select', 'month', 'Month: ', [monthsPretty, months], useURLParams && urlParams.has('month') ? urlParams.get('month') : currentMonth));
+	}
+	else if(graphTypeSelector.value == "topCompanies")
+	{
+		selectorSubtypes.appendChild(addInput('select', 'metric', 'Metric: ', [['Volume', 'Profit'], ['volume', 'profit']], useURLParams && urlParams.get('metric')));
+		
+		selectorSubtypes.appendChild(addInput('select', 'month', 'Month: ', [monthsPretty, months], useURLParams && urlParams.has('month') ? urlParams.get('month') : currentMonth));
+	}
+	else if(graphTypeSelector.value == "matHistory")
+	{
+		selectorSubtypes.appendChild(addInput('select', 'metric', 'Metric: ', [['Volume', 'Profit', 'Price', 'Produced', 'Consumption', 'Surplus'], ['volume', 'profit', 'price', 'amount', 'consumed', 'surplus']], useURLParams && urlParams.get('metric')));
+		
+		selectorSubtypes.appendChild(addInput('input', 'ticker', 'Ticker: ', undefined, useURLParams && urlParams.get('ticker')));
+	}
+	else if(graphTypeSelector.value == "compTotals")
+	{
+		const chartTypeElem = addInput('select', 'chartType', 'Chart Type: ', [['Bar', 'Pie', 'Treemap (Mat)', 'Treemap (Cat)'], ['bar', 'pie', 'treemap', 'treemap-categories']], useURLParams && urlParams.get('chartType'));
+		chartTypeElem.style.marginLeft = "-30px";
+		selectorSubtypes.appendChild(chartTypeElem);
+		
+		selectorSubtypes.appendChild(addInput('select', 'metric', 'Metric: ', [['Volume', 'Profit'], ['volume', 'profit']], useURLParams && urlParams.get('metric')));
+		
+		selectorSubtypes.appendChild(addInput('select', 'month', 'Month: ', [monthsPretty, months], useURLParams && urlParams.has('month') ? urlParams.get('month') : currentMonth));
+		
+		// Username input and query button
+		const usernameInput = addInput('input', 'username', 'Username: ', undefined, useURLParams && urlParams.get('companyName'));
+		
+		const submitButton = document.createElement("button");
+		submitButton.textContent = "Query";
+		submitButton.classList.add("queryButton");
+		usernameInput.appendChild(submitButton);
+		
+		submitButton.addEventListener('click', getCompanyInfo);
+		usernameInput.addEventListener('keypress', function(e) {if(e.key === 'Enter'){getCompanyInfo();}})
+		
+		usernameInput.style.marginLeft = "33px";
+		
+		selectorSubtypes.appendChild(usernameInput);
+		
+		// Hidden company ID input, autofilled by query
+		const companyIDInput = addInput('input', 'companyID', 'Company ID: ', undefined, useURLParams && urlParams.get('companyID'));
+		companyIDInput.style.visibility = 'hidden';
+		
+		selectorSubtypes.appendChild(companyIDInput);
+		
+		const companyNameInput = addInput('input', 'companyName', 'Company Name: ', undefined, useURLParams && urlParams.get('companyName'));
+		companyNameInput.style.visibility = 'hidden';
+		
+		selectorSubtypes.appendChild(companyNameInput);
+		
+	}
+	else if(graphTypeSelector.value == "compHistory")
+	{
+		selectorSubtypes.appendChild(addInput('select', 'metric', 'Metric: ', [['Volume', 'Profit'], ['volume', 'profit']], useURLParams && urlParams.get('metric')));
+		
+		// Username input and query button
+		const usernameInput = addInput('input', 'username', 'Username: ', undefined, useURLParams && urlParams.get('companyName'));
+		
+		const submitButton = document.createElement("button");
+		submitButton.textContent = "Query";
+		submitButton.classList.add("queryButton");
+		usernameInput.appendChild(submitButton);
+		
+		submitButton.addEventListener('click', getCompanyInfo);
+		usernameInput.addEventListener('keypress', function(e) {if(e.key === 'Enter'){getCompanyInfo();}})
+		
+		usernameInput.style.marginLeft = "33px";
+		
+		selectorSubtypes.appendChild(usernameInput);
+		
+		// Hidden company ID input, autofilled by query
+		const companyIDInput = addInput('input', 'companyID', 'Company ID: ', undefined, useURLParams && urlParams.get('companyID'));
+		companyIDInput.style.visibility = 'hidden';
+		
+		selectorSubtypes.appendChild(companyIDInput);
+		
+		const companyNameInput = addInput('input', 'companyName', 'Company Name: ', undefined, useURLParams && urlParams.get('companyName'));
+		companyNameInput.style.visibility = 'hidden';
+		
+		selectorSubtypes.appendChild(companyNameInput);
+	}
+	else if(graphTypeSelector.value == "compRank")
+	{
+		selectorSubtypes.appendChild(addInput('select', 'month', 'Month: ', [monthsPretty, months], useURLParams && urlParams.has('month') ? urlParams.get('month') : currentMonth));
+		
+		// Username input and query button
+		const usernameInput = addInput('input', 'username', 'Username: ', undefined, useURLParams && urlParams.get('companyName'));
+		
+		const submitButton = document.createElement("button");
+		submitButton.textContent = "Query";
+		submitButton.classList.add("queryButton");
+		usernameInput.appendChild(submitButton);
+		
+		submitButton.addEventListener('click', getCompanyInfo);
+		usernameInput.addEventListener('keypress', function(e) {if(e.key === 'Enter'){getCompanyInfo();}})
+		
+		usernameInput.style.marginLeft = "33px";
+		
+		selectorSubtypes.appendChild(usernameInput);
+		
+		// Hidden company ID input, autofilled by query
+		const companyIDInput = addInput('input', 'companyID', 'Company ID: ', undefined, useURLParams && urlParams.get('companyID'));
+		companyIDInput.style.visibility = 'hidden';
+		
+		selectorSubtypes.appendChild(companyIDInput);
+		
+		const companyNameInput = addInput('input', 'companyName', 'Company Name: ', undefined, useURLParams && urlParams.get('companyName'));
+		companyNameInput.style.visibility = 'hidden';
+		
+		selectorSubtypes.appendChild(companyNameInput);
+		
+	}
+}
+
+function switchPlot()
+{
+	const urlParams = new URLSearchParams(window.location.search);
+	const fullScreen = urlParams.has("hideOptions");
+	
+	const typeElem = document.getElementById("graphType");
+	
+	var subtypeElem;
+	var monthElem;
+	var metricElem;
+	var matElem;
+	var nameElem;
+	var idElem;
+	
+	const oldGraph = document.getElementById("mainPlot");
+	oldGraph.remove();
+	const newGraph = document.createElement("div");
+	newGraph.id = "mainPlot";
+	const graphContainer = document.getElementById("mainPlotContainer");
+	graphContainer.appendChild(newGraph);
+	switch(typeElem.value)
+	{
+		case "topProduction":
+			metricElem = document.getElementById("metric");
+			monthElem = document.getElementById("month");
+			promiseGenerateTopProdGraph("mainPlot", monthElem.value, metricElem.value, fullScreen);
+			break;
+		case "topCompanies":
+			metricElem = document.getElementById("metric");
+			monthElem = document.getElementById("month");
+			promiseGenerateTopCompanyGraph("mainPlot", monthElem.value, metricElem.value, fullScreen);
+			break;
+		case "matHistory":
+			metricElem = document.getElementById("metric");
+			matElem = document.getElementById("ticker");
+			promiseGenerateMatGraph("mainPlot", matElem.value, metricElem.value, months, fullScreen);
+			break;
+		case "compTotals":
+			subtypeElem = document.getElementById("chartType");
+			metricElem = document.getElementById("metric");
+			monthElem = document.getElementById("month");
+			nameElem = document.getElementById("companyName");
+			idElem = document.getElementById("companyID");
+			promiseGenerateCompanyGraph("mainPlot", subtypeElem.value, nameElem.value, idElem.value, monthElem.value, metricElem.value, fullScreen);
+			break;
+		case "compHistory":
+			metricElem = document.getElementById("metric");
+			nameElem = document.getElementById("companyName");
+			idElem = document.getElementById("companyID");
+			promiseGenerateCompanyHistoryGraph("mainPlot", nameElem.value, idElem.value, metricElem.value, months, fullScreen);
+			break;
+		case "compRank":
+			monthElem = document.getElementById("month");
+			nameElem = document.getElementById("companyName");
+			idElem = document.getElementById("companyID");
+			promiseGenerateRankChart("mainPlot", nameElem.value, idElem.value, monthElem.value, months, fullScreen);
+	}
+
+	updatePermalink(typeElem);
+}
+
+function updatePermalink(typeElem)
+{
+	const permalinkInput = document.getElementById("permalink");
+	const rprunInput = document.getElementById("permalink-rprun");
+	const hideOptionsButton = document.getElementById("hideOptions");
+	const latestMonthButton = document.getElementById("latestMonth");
+	
+	var permalink = "https://pmmg-products.github.io/reports/?type=" + typeElem.value;
+	var rprunLink = "XIT PRUNSTATS type-" + typeElem.value;
+	
+	const relevantSubtypes = {
+		"topProduction": ["metric", "month"],
+		"topCompanies": ["metric", "month"],
+		"matHistory": ["metric", "ticker"],
+		"compTotals": ["chartType", "metric", "month", "companyName", "companyID"],
+		"compHistory": ["metric", "companyName", "companyID"],
+		"compRank": ["month", "companyName", "companyID"]
+	}
+	
+	relevantSubtypes[typeElem.value].forEach(subtype => {
+		if(subtype == "month" && latestMonthButton.checked){return;}
+		
+		const inputElem = document.getElementById(subtype);
+		if(inputElem.value && inputElem.value != "")
+		{
+			permalink += "&" + subtype + "=" + inputElem.value;
+			rprunLink += " " + subtype + "-" + inputElem.value;
+		}
+	});
+
+	if(hideOptionsButton.checked)
+	{
+		permalink += "&hideOptions";
+		rprunLink += " hideOptions";
+	}
+
+	permalinkInput.value = permalink;
+	rprunInput.value = rprunLink;
+	return;
+}
+
+function promiseGenerateRankChart(container, companyName, companyID, currentMonth, months, fullScreen)
+{
+	const monthIndex = months.indexOf(currentMonth);
+	const prevMonth = months[monthIndex == 0 ? 0 : monthIndex - 1];	// Get previous month to determine change
+	
+	(async () => {
+		if(!loadedData['company-data-' + currentMonth])
+		{
+			loadedData['company-data-' + currentMonth] = await fetch('data/company-data-' + currentMonth + '.json?cb=' + Date.now()).then(response => response.json())
+		}
+		
+		if(!loadedData['company-data-' + prevMonth])
+		{
+			loadedData['company-data-' + prevMonth] = await fetch('data/company-data-' + prevMonth + '.json?cb=' + Date.now()).then(response => response.json())
+		}
+		
+		generateRankChart(container, loadedData['company-data-' + currentMonth].individual[companyID], (monthIndex == 0 ? undefined : loadedData['company-data-' + prevMonth].individual[companyID]), companyName, currentMonth, fullScreen);  // Use the JSON data
+	})();
+}
+
+function promiseGenerateCompanyHistoryGraph(container, companyName, companyID, metric, months, fullScreen)	// Metric is either 'profit' or 'volume'
+{
+	if(!companyID){return;}
+	const validMonths = [];
+	const data = []
+	var hasData = false;
+	
+	(async () => {
+		for(const month of months) {
+			if(!loadedData['company-data-' + month])
+			{
+				loadedData['company-data-' + month] = await fetch('data/company-data-' + month + '.json?cb=' + Date.now()).then(response => response.json())
+			}
+			
+			const dataPoint = loadedData['company-data-' + month].totals[companyID]
+			if(dataPoint)
+			{
+				data.push(dataPoint[metric])
+				validMonths.push(month);
+				hasData = true;
+			}
+		};
+		
+		if(hasData)
+		{
+			generateCompanyHistoryGraph(container, months.map(month => prettyMonthName(month)), data, companyName, metric, fullScreen)
+		}
+	})();
+}
+
+function promiseGenerateCompanyGraph(container, chartType, companyName, companyID, month, metric, fullScreen)	// Metric is either 'profit' or 'volume'. chartType is either 'bar' or 'pie'
+{
+	(async () => {
+		if(!loadedData['company-data-' + month])
+		{
+			loadedData['company-data-' + month] = await fetch('data/company-data-' + month + '.json?cb=' + Date.now()).then(response => response.json())
+		}
+		
+		generateCompanyGraph(container, chartType, loadedData['company-data-' + month].individual[companyID], companyName, month, metric, fullScreen);  // Use the JSON data
+	})();
+}
+
+function promiseGenerateTopCompanyGraph(container, month, metric, fullScreen)	// Metric is either 'profit' or 'volume'
+{
+	(async () => {
+		if(!loadedData['company-data-' + month])
+		{
+			loadedData['company-data-' + month] = await fetch('data/company-data-' + month + '.json?cb=' + Date.now()).then(response => response.json())
+		}
+		
+		if(!loadedData['known-companies'])
+		{
+			loadedData['known-companies'] = await fetch('data/knownCompanies2.json?cb=' + Date.now()).then(response => response.json())
+		}
+		
+		generateTopCompanyGraph(container, loadedData['company-data-' + month], loadedData['known-companies'], month, metric, fullScreen);  // Use the JSON data
+	})();
+}
+
+function promiseGenerateTopProdGraph(container, month, metric, fullScreen)	// Metric is either 'profit' or 'volume'
+{
+	(async () => {
+		if(!loadedData['prod-data-' + month])
+		{
+			loadedData['prod-data-' + month] = await fetch('data/prod-data-' + month + '.json?cb=' + Date.now()).then(response => response.json())
+		}
+		
+		const data = loadedData['prod-data-' + month];
+		if(metric == 'deficit')	// Populate deficit into data
+		{
+			Object.keys(data).forEach(ticker => {
+				if(!data[ticker]['amount'] || data[ticker]['amount'] == 0){data[ticker]['deficit'] = 0; return;}
+				data[ticker]['deficit'] = (data[ticker]['amount'] - (data[ticker]['consumed'] || 0)) * data[ticker]['volume'] / data[ticker]['amount'];
+			});
+		}
+		generateTopProdGraph(container, data, month, metric, fullScreen);  // Use the JSON data
+	})();
+}
+
+function promiseGenerateMatGraph(container, ticker, metric, months, fullScreen)	// Metric is either 'profit', 'volume', or 'amount'
+{
+	// Validation/sanitizing
+	if(!ticker){return;}
+	ticker = ticker.toUpperCase();
+	
+	const validMonths = [];
+	const data = [];
+	
+	(async () => {
+		for(const month of months) {
+			if(!loadedData['prod-data-' + month])
+			{
+				loadedData['prod-data-' + month] = await fetch('data/prod-data-' + month + '.json?cb=' + Date.now()).then(response => response.json())
+			}
+			
+			const dataPoint = loadedData['prod-data-' + month][ticker]
+			if(dataPoint)
+			{
+				if(metric == 'price')
+				{
+					data.push(dataPoint['amount'] == 0 ? 0 : dataPoint['volume'] / dataPoint['amount']);
+				}
+				else if(metric == 'surplus')
+				{
+					data.push(dataPoint['amount'] - dataPoint['consumed'])
+				}
+				else
+				{
+					data.push(dataPoint[metric])
+				}
+				validMonths.push(month)
+				hasData = true;
+			}
+		};
+		
+		if(hasData)
+		{
+			generateMatGraph(container, validMonths.map(month => prettyMonthName(month)), data, ticker, metric, fullScreen)
+		}
+	})();
+}
+
+function generateTopProdGraph(container, prodData, month, metric, fullScreen)
+{
+	if(fullScreen){setFullscreen(container);}
+	const titles = {
+		'profit': 'Profit Materials',
+		'volume': 'Production Volumes',
+		'deficit': 'Deficits'
+	}
+	// Convert the data object into an array of [ticker, volume] pairs
+	const volumeArray = Object.entries(prodData).map(([ticker, info]) => ({
+		ticker,
+		volume: info[metric]
+	}));
+
+	// Sort the array by volume in descending order
+	if(metric == 'deficit')
+	{
+		volumeArray.sort((a, b) => a.volume - b.volume);
+	}
+	else
+	{
+		volumeArray.sort((a, b) => b.volume - a.volume);
+	}
+	
+	
+	// Extract tickers and volumes into separate arrays
+	const tickers = volumeArray.map(item => item.ticker);
+	const volumes = volumeArray.map(item => item.volume);
+	Plotly.newPlot(container, {
+        data: [{ x: tickers, y: volumes, type: 'bar' , marker: {color: 'rgb(247, 166, 0)'}, hovertemplate: '%{x}: %{y:,.3~s}<extra></extra>'}],
+        layout: {width: fullScreen ? undefined : 800, height: fullScreen ? undefined : 400, autosize: fullScreen, ...(fullScreen ? {margin: {
+					l: 60,  // left
+					r: 10,  // right
+					t: 40,  // top
+					b: 60   // bottom
+				}} : {}),
+			title: {text: 'Top ' + titles[metric] + ' - ' + prettyMonthName(month),
+					font: {color: '#eee', family: '"Droid Sans", sans-serif'},
+			},
+			xaxis: {
+				title: {
+					text: 'Ticker',
+					font: {color: '#bbb', family: '"Droid Sans", sans-serif'}
+				},
+				tickfont: {color: '#666', family: '"Droid Sans", sans-serif'},
+				range: [-0.5, 29.5],
+				tickangle: -45
+			},
+			yaxis: {
+				title: {
+					text: prettyModeNames[metric] + ' [$/day]',
+					font: {color: '#bbb', family: '"Droid Sans", sans-serif'}
+				},
+				tickfont: {color: '#666', family: '"Droid Sans", sans-serif'},
+				range: [(metric == 'deficit' ? null : 0), (metric == 'deficit' ? 0 : null)],
+				gridcolor: '#323232'
+			},
+			plot_bgcolor: '#252525',
+			paper_bgcolor: '#252525',
+			dragmode: 'pan'
+		},
+		config: {
+			displayModeBar: true,
+			modeBarButtonsToRemove: ['lasso2d'],  // Remove unwanted buttons
+			displaylogo: false,
+			scrollZoom: true,
+			responsive: true
+		}
+    });
+}
+
+function generateTopCompanyGraph(container, companyData, knownCompanies, month, metric, fullScreen)
+{
+	if(fullScreen){setFullscreen(container);}
+	
+	// Convert the data object into an array of [companyID, volume] pairs
+	const volumeArray = Object.entries(companyData.totals).map(([companyID, info]) => ({
+		companyID,
+		volume: info[metric]
+	}));
+
+	// Sort the array by volume in descending order
+	volumeArray.sort((a, b) => b.volume - a.volume);
+
+	// Extract tickers and volumes into separate arrays
+	const companyIDs = volumeArray.map(item => item.companyID);
+	const volumes = volumeArray.map(item => item.volume);
+	
+	const companyNames = [];
+	
+	// Print unknown top 40 companies
+	companyIDs.slice(0,40).forEach(id => {
+		if(!knownCompanies[id])
+		{
+			console.log(id)
+		}
+	});
+	
+	companyIDs.forEach(id => {
+		companyNames.push(knownCompanies[id] || (id.slice(0, 5) + "..."));
+	});
+	Plotly.newPlot(container, {
+        data: [{ x: companyNames, y: volumes, type: 'bar' , marker: {color: 'rgb(247, 166, 0)'}, hovertemplate: '%{x}: %{y:,.3~s}<extra></extra>'}],
+        layout: { width: fullScreen ? undefined : 800, height: fullScreen ? undefined : 400, autosize: fullScreen, ...(fullScreen ? {margin: {
+					l: 60,  // left
+					r: 10,  // right
+					t: 40,  // top
+					b: 100   // bottom
+				}} : {}),
+			title: {text: 'Top Companies (' + prettyModeNames[metric] + ') - ' + prettyMonthName(month),
+					font: {color: '#eee', family: '"Droid Sans", sans-serif'},
+			},
+			xaxis: {
+				title: {
+					text: 'Player',
+					font: {color: '#bbb', family: '"Droid Sans", sans-serif'}
+				},
+				tickfont: {color: '#666', family: '"Droid Sans", sans-serif'},
+				range: [-0.5, 29.5],
+				tickangle: -45
+			},
+			yaxis: {
+				title: {
+					text: prettyModeNames[metric] + ' [$/day]',
+					font: {color: '#bbb', family: '"Droid Sans", sans-serif'}
+				},
+				tickfont: {color: '#666', family: '"Droid Sans", sans-serif'},
+				range: [0, null],
+				gridcolor: '#323232'
+			},
+			plot_bgcolor: '#252525',
+			paper_bgcolor: '#252525',
+			dragmode: 'pan'
+		},
+		config: {
+			displayModeBar: true,
+			modeBarButtonsToRemove: ['lasso2d'],  // Remove unwanted buttons
+			displaylogo: false,
+			scrollZoom: true,
+			responsive: true
+		}
+    });
+}
+
+function generateMatGraph(container, months, data, ticker, metric, fullScreen)
+{
+	console.log(fullScreen);
+	if(fullScreen){setFullscreen(container);}
+	
+	const titles = {
+		'profit': 'Production Profit History of ',
+		'volume': 'Production Volume History of ',
+		'amount': 'Production Amount History of ',
+		'price': 'Price History of ',
+		'consumed': 'Consumption History of ',
+		'surplus': 'Surplus Production History of '
+	}
+	const yAxis = {
+		'profit': 'Daily Profit [$/day]',
+		'volume': 'Daily Volume [$/day]',
+		'amount': 'Daily Production [per day]',
+		'price': 'Price [$]',
+		'consumed': 'Daily Consumption [per day]',
+		'surplus': 'Daily Surplus [per day]'
+	}
+	
+	Plotly.newPlot(container, {
+        data: [{ x: months, y: data, type: 'bar' , marker: {color: 'rgb(247, 166, 0)'}, hovertemplate: '%{x}: %{y:,.3~s}<extra></extra>'}],
+        layout: { width: fullScreen ? undefined : 800, height: fullScreen ? undefined : 400, autosize: fullScreen, ...(fullScreen ? {margin: {
+					l: 60,  // left
+					r: 10,  // right
+					t: 40,  // top
+					b: 60   // bottom
+				}} : {}),
+			title: {text: titles[metric] + ticker,
+					font: {color: '#eee', family: '"Droid Sans", sans-serif'},
+			},
+			xaxis: {
+				title: {
+					text: 'Month',
+					font: {color: '#bbb', family: '"Droid Sans", sans-serif'}
+				},
+				tickfont: {color: '#666', family: '"Droid Sans", sans-serif'},
+				tickangle: -45
+			},
+			yaxis: {
+				title: {
+					text: yAxis[metric],
+					font: {color: '#bbb', family: '"Droid Sans", sans-serif'}
+				},
+				tickfont: {color: '#666', family: '"Droid Sans", sans-serif'},
+				gridcolor: '#323232'
+			},
+			plot_bgcolor: '#252525',
+			paper_bgcolor: '#252525',
+			dragmode: 'pan'
+		},
+		config: {
+			displayModeBar: true,
+			modeBarButtonsToRemove: ['lasso2d'],  // Remove unwanted buttons
+			displaylogo: false,
+			scrollZoom: true,
+			responsive: true
+		}
+    });
+}
+
+function generateCompanyGraph(container, chartType, data, companyName, month, metric, fullScreen)
+{
+	if(fullScreen){setFullscreen(container);}
+	
+	if(!data){return;}
+	
+	const titles = {
+		'profit': 'Production Profit Breakdown of ',
+		'volume': 'Production Volume Breakdown of ',
+	}
+	
+	var mats = Object.keys(data);
+	var values = mats.map(ticker => data[ticker][metric]);
+	
+	var indices = values.map((_, i) => i).sort((a, b) => values[b] - values[a]);
+	mats = indices.map(i => mats[i]);
+	values = indices.map(i => values[i]);
+	
+	if(chartType == 'treemap' || chartType == 'treemap-categories')
+	{
+		// Filter out negative values
+		indices = values
+			.map((v, i) => i)
+			.filter(i => values[i] >= 0);
+		mats = indices.map(i => mats[i]);
+		values = indices.map(i => values[i]);
+
+		var colors = mats.map(m => materialsToColors[m] || '#000000');
+		var parents = chartType == 'treemap-categories'
+			? mats.map(m => materialsToCategories[m] || 'Other')
+			: mats.map(m => 'Total');
+
+		var totalValue = 0;
+		var categoryValues = {};
+		for (const i of indices) {
+			totalValue += values[i];
+			const category = parents[i];
+			categoryValues[category] = (categoryValues[category] || 0) + values[i];
+		}
+
+		if (chartType == 'treemap-categories') {
+			for (const category in categoryValues) {
+				mats.push(category);
+				values.push(categoryValues[category]);
+				colors.push(materialCategoryColors[category] || '#000000');
+				parents.push('Total');
+			}
+		}
+
+		values.push(totalValue);
+		mats.push('Total');
+		parents.push('');
+		colors.push('#252525');
+
+		Plotly.newPlot(container, {
+			data: [
+				{
+					type: 'treemap',
+					labels: mats,
+					parents: parents,
+					values: values,
+					maxdepth: 2,
+					branchvalues: 'total',
+					marker: {
+						colors: colors,
+					},
+					tiling: {
+						pad: 0,
+					},
+					textposition: 'middle center',
+					hovertemplate: '%{label}<br>$%{value:,.3~s}/day<br>%{percentEntry:.2%}<extra></extra>'
+				}
+			],
+			layout: { width: fullScreen ? undefined : 800, height: fullScreen ? undefined : 400, autosize: fullScreen, ...(fullScreen ? {margin: {
+					l: 10,  // left
+					r: 10,  // right
+					t: 40,  // top
+					b: 10   // bottom
+				}} : {}),
+				title: {text: titles[metric] + companyName + ' - ' + prettyMonthName(month),
+					font: {color: '#eee', family: '"Droid Sans", sans-serif'},
+				},
+				plot_bgcolor: '#252525',
+				paper_bgcolor: '#252525',
+			},
+			config: {
+				displaylogo: false,
+				responsive: true
+			}
+		});
+	}
+	else if(chartType == 'pie')
+	{
+		// Filter out negative values
+		indices = values
+			.map((v, i) => i)
+			.filter(i => values[i] >= 0);
+		mats = indices.map(i => mats[i]);
+		values = indices.map(i => values[i]);
+		
+		Plotly.newPlot(container, {
+			data: [{ labels: mats, values: values, type: 'pie', textinfo: 'label',textposition: 'inside', insidetextorientation: 'none', automargin: false, hovertemplate: '%{label}<br>$%{value:,.3~s}/day<br>%{percent}<extra></extra>'}],
+			layout: { width: fullScreen ? undefined : 800, height: fullScreen ? undefined : 400, autosize: fullScreen, ...(fullScreen ? {margin: {
+					l: 10,  // left
+					r: 10,  // right
+					t: 40,  // top
+					b: 10   // bottom
+				}} : {}),
+				title: {text: titles[metric] + companyName + ' - ' + prettyMonthName(month),
+						font: {color: '#eee', family: '"Droid Sans", sans-serif'},
+				},
+				plot_bgcolor: '#252525',
+				paper_bgcolor: '#252525',
+			},
+			config: {
+				displayModeBar: true,
+				modeBarButtonsToRemove: ['lasso2d'],  // Remove unwanted buttons
+				displaylogo: false,
+				scrollZoom: true,
+				responsive: true
+			}
+		});
+	}
+	else
+	{
+		Plotly.newPlot(container, {
+			data: [{ x: mats, y: values, type: 'bar' , marker: {color: 'rgb(247, 166, 0)'}, hovertemplate: '%{x}: %{y:,.3~s}<extra></extra>'}],
+			layout: { width: fullScreen ? undefined : 800, height: fullScreen ? undefined : 400, autosize: fullScreen, ...(fullScreen ? {margin: {
+					l: 60,  // left
+					r: 10,  // right
+					t: 40,  // top
+					b: 60   // bottom
+				}} : {}),
+				title: {text: titles[metric] + companyName + ' - ' + prettyMonthName(month),
+						font: {color: '#eee', family: '"Droid Sans", sans-serif'},
+				},
+				xaxis: {
+					title: {
+						text: 'Ticker',
+						font: {color: '#bbb', family: '"Droid Sans", sans-serif'}
+					},
+					tickfont: {color: '#666', family: '"Droid Sans", sans-serif'},
+					range: [-0.5, Math.min(mats.length, 30) - 0.5],
+					tickangle: -45
+				},
+				yaxis: {
+					title: {
+						text: prettyModeNames[metric] + ' [$/day]',
+						font: {color: '#bbb', family: '"Droid Sans", sans-serif'}
+					},
+					tickfont: {color: '#666', family: '"Droid Sans", sans-serif'},
+					range: [0, null],
+					gridcolor: '#323232'
+				},
+				plot_bgcolor: '#252525',
+				paper_bgcolor: '#252525',
+				dragmode: 'pan'
+			},
+			config: {
+				displayModeBar: true,
+				modeBarButtonsToRemove: ['lasso2d'],  // Remove unwanted buttons
+				displaylogo: false,
+				scrollZoom: true,
+				responsive: true
+			}
+		});
+	}
+}
+
+function generateCompanyHistoryGraph(container, months, data, companyName, metric, fullScreen)
+{
+	if(fullScreen){setFullscreen(container);}
+	
+	const titles = {
+		'profit': 'Production Profit History of ',
+		'volume': 'Production Volume History of ',
+	}
+	const yAxis = {
+		'profit': 'Daily Profit [$/day]',
+		'volume': 'Daily Volume [$/day]'
+	}
+	Plotly.newPlot(container, {
+        data: [{ x: months, y: data, type: 'bar' , marker: {color: 'rgb(247, 166, 0)'}, hovertemplate: '%{x}: %{y:,.3~s}<extra></extra>'}],
+        layout: { width: fullScreen ? undefined : 800, height: fullScreen ? undefined : 400, autosize: fullScreen, ...(fullScreen ? {margin: {
+					l: 60,  // left
+					r: 10,  // right
+					t: 40,  // top
+					b: 60   // bottom
+				}} : {}),
+			title: {text: titles[metric] + companyName,
+					font: {color: '#eee', family: '"Droid Sans", sans-serif'},
+			},
+			xaxis: {
+				title: {
+					text: 'Month',
+					font: {color: '#bbb', family: '"Droid Sans", sans-serif'}
+				},
+				tickfont: {color: '#666', family: '"Droid Sans", sans-serif'},
+				tickangle: -45
+			},
+			yaxis: {
+				title: {
+					text: yAxis[metric],
+					font: {color: '#bbb', family: '"Droid Sans", sans-serif'}
+				},
+				tickfont: {color: '#666', family: '"Droid Sans", sans-serif'},
+				gridcolor: '#323232'
+			},
+			plot_bgcolor: '#252525',
+			paper_bgcolor: '#252525',
+			dragmode: 'pan'
+		},
+		config: {
+			displayModeBar: true,
+			modeBarButtonsToRemove: ['lasso2d'],  // Remove unwanted buttons
+			displaylogo: false,
+			scrollZoom: true,
+			responsive: true
+		}
+    });
+}
+
+function generateRankChart(containerID, currentData, prevData, companyName, currentMonth, fullScreen)
+{
+	if(fullScreen){setFullscreen(containerID);}
+	
+	if(!currentData){return;}
+		
+	const container = document.getElementById(containerID);
+	
+	const tickers = Object.keys(currentData);
+	const currentRanks = tickers.map(ticker => ({'ticker': ticker, 'currentRank': currentData[ticker].rank, 'data': currentData[ticker]}));
+	
+	currentRanks.forEach(mat => {
+		if(prevData && prevData[mat.ticker])
+		{
+			mat.prevRank = prevData[mat.ticker].rank;
+		}
+	});
+	
+	currentRanks.sort((x, y) => x.currentRank - y.currentRank);
+	
+	// Create title
+	const title = document.createElement("div");
+	title.textContent = "Production Ranking of " + companyName + " - " + prettyMonthName(currentMonth);
+	title.classList.add("title");
+	container.appendChild(title);
+	
+	// Start creating table
+	const table = document.createElement("table");
+	container.appendChild(table);
+	
+	// Table header
+	const header = document.createElement("thead");
+	table.appendChild(header);
+	const headRow = document.createElement("tr");
+	header.appendChild(headRow);
+	
+	const headers = ["Rank", "Ticker", "Amount [/day]", "Volume [$/day]", "Profit [$/day]"]
+	headers.forEach(label => {
+		const headerColumn = document.createElement("th");
+		headerColumn.textContent = label;
+		headRow.appendChild(headerColumn);
+	});
+	
+	const body = document.createElement("tbody");
+	table.appendChild(body);
+	
+	currentRanks.forEach(mat =>
+	{
+		const row = document.createElement("tr");
+		body.appendChild(row);
+		
+		const rankColumn = document.createElement("td");
+		const rankWrapper = document.createElement("div");
+		rankWrapper.style.display = "flex";
+		rankColumn.appendChild(rankWrapper);
+		
+		if(prevData)
+		{
+			const rankSymbol = document.createElement("div");
+			rankSymbol.style.width = "14px";
+			rankSymbol.style.minWidth = "14px";
+			rankSymbol.style.marginRight = "2px";
+			if(mat.prevRank && mat.prevRank != mat.currentRank)
+			{
+				const increasing = mat.prevRank && mat.prevRank < mat.currentRank;
+				rankSymbol.textContent = increasing ? "▼" : "▲";
+				rankSymbol.style.color = increasing ? "#d9534f" : "#5cb85c";
+			}
+			rankWrapper.appendChild(rankSymbol);
+		}
+		
+		const rankNum = document.createElement("div");
+		rankNum.textContent = mat.currentRank;
+		rankWrapper.appendChild(rankNum);
+		
+		row.appendChild(rankColumn);
+		
+		const tickerColumn = document.createElement("td")
+		tickerColumn.textContent = mat.ticker;
+		row.appendChild(tickerColumn);
+		
+		const amountColumn = document.createElement("td")
+		amountColumn.textContent = mat.data.amount.toLocaleString(undefined, {maximumFractionDigits: 1});
+		row.appendChild(amountColumn);
+		
+		const volumeColumn = document.createElement("td")
+		volumeColumn.textContent = "$" + mat.data.volume.toLocaleString(undefined, {notation: 'compact', maxixmumSignificantDigits: 3});
+		row.appendChild(volumeColumn);
+		
+		const profitColumn = document.createElement("td")
+		profitColumn.textContent = "$" + mat.data.profit.toLocaleString(undefined, {notation: 'compact', maxixmumSignificantDigits: 3});
+		row.appendChild(profitColumn);
+	});
+}
+
+// Util functions
+function prettyMonthName(monthStr)
+{
+	const monthAbv = monthStr.substring(0,3);
+	const monthNum = monthStr.substring(3);
+	
+	return fullMonthNames[monthAbv] + " 30" + monthNum;
+}
+
+// Remove all the children of a given element
+function clearChildren(elem)
+{
+	elem.textContent = "";
+	while(elem.children[0])
+	{
+		elem.removeChild(elem.children[0]);
+	}
+	return;
+}
+
+// Add options to a selector
+function addOptions(selector, options, values)
+{
+	for(var i = 0; i < options.length; i++)
+	{
+		const optionElem = document.createElement("option");
+		optionElem.textContent = options[i];
+		optionElem.value = values ? values[i] : options[i];
+		selector.appendChild(optionElem);
+	}
+}
+
+function wrapInDiv(elem)	// Wrap selector element in a div to center it and give it margin
+{
+	const div = document.createElement('div');
+	
+	div.appendChild(elem);
+	
+	return div;
+}
+
+function addInput(inputType, id, label, values, defaultValue)
+{
+	const labelElem = document.createElement('label');
+	labelElem.textContent = label;
+	
+	const inputElem = document.createElement(inputType);
+	inputElem.id = id;
+	inputElem.classList.add("plotSelector");
+	if(inputType == 'select')
+	{
+		addOptions(inputElem, values[0], values[1]);
+	}
+	
+	if(defaultValue)
+	{
+		inputElem.value = defaultValue;
+	}
+	
+	inputElem.addEventListener("change", function() {
+		switchPlot();
+	});
+	
+	labelElem.appendChild(inputElem);
+	
+	return wrapInDiv(labelElem);
+}
+
+async function getCompanyInfo()
+{
+	const usernameInput = document.getElementById('username');
+	var companyID;
+	var companyName;
+	
+	if(!usernameInput.value){return;}
+	
+	(async () => {
+		if(!loadedData['known-companies'])
+		{
+			loadedData['known-companies'] = await fetch('data/knownCompanies2.json?cb=' + Date.now()).then(response => response.json())
+		}
+		
+		const match = Object.entries(loadedData['known-companies']).find(([id, username]) => username && (username.toLowerCase() === usernameInput.value.toLowerCase()));
+		
+		if(match)
+		{
+			[companyID, companyName] = match;
+		}
+		else
+		{
+			const fioResult = fetch('https://rest.fnar.net/user/' + usernameInput.value).then(response => response.json()).catch(error => {alert('Bad Response: Check Username'); console.error(error)});
+			companyID = fioResult.CompanyId;
+			companyName = fioResult.UserName;
+		}
+		
+		if(!companyID || !companyName){return;}
+		
+		const companyIDInput = document.getElementById('companyID');
+		companyIDInput.value = companyID;
+		
+		const companyNameInput = document.getElementById('companyName');
+		companyNameInput.value = companyName;
+		
+		switchPlot();
+	})();
+}
+
+function setFullscreen(container)
+{
+	const elem = document.getElementById(container);
+	if(!elem){return;}
+	elem.classList.add("fullScreen");
+}
+
+const fullMonthNames = {
+	"jan": "January",
+	"feb": "February",
+	"mar": "March",
+	"apr": "April",
+	"may": "May",
+	"jun": "June",
+	"jul": "July",
+	"aug": "August",
+	"sep": "September",
+	"oct": "October",
+	"nov": "November",
+	"dec": "December"
+}
+
+const prettyModeNames = {
+	"amount": "Amount",
+	"profit": "Profit",
+	"volume": "Volume",
+	"price": "Price",
+	"deficit": "Deficit"
+}

File diff suppressed because it is too large
+ 16 - 58
reports/main.js


+ 1564 - 0
reports/package-lock.json

@@ -0,0 +1,1564 @@
+{
+  "name": "reports",
+  "version": "1.0.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "reports",
+      "version": "1.0.0",
+      "license": "ISC",
+      "devDependencies": {
+        "ts-loader": "^9.5.2",
+        "typescript": "^5.9.2",
+        "webpack": "^5.101.0",
+        "webpack-cli": "^6.0.1"
+      }
+    },
+    "node_modules/@discoveryjs/json-ext": {
+      "version": "0.6.3",
+      "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.6.3.tgz",
+      "integrity": "sha512-4B4OijXeVNOPZlYA2oEwWOTkzyltLao+xbotHQeqN++Rv27Y6s818+n2Qkp8q+Fxhn0t/5lA5X1Mxktud8eayQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=14.17.0"
+      }
+    },
+    "node_modules/@jridgewell/gen-mapping": {
+      "version": "0.3.12",
+      "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz",
+      "integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==",
+      "dev": true,
+      "dependencies": {
+        "@jridgewell/sourcemap-codec": "^1.5.0",
+        "@jridgewell/trace-mapping": "^0.3.24"
+      }
+    },
+    "node_modules/@jridgewell/resolve-uri": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+      "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+      "dev": true,
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/@jridgewell/source-map": {
+      "version": "0.3.10",
+      "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.10.tgz",
+      "integrity": "sha512-0pPkgz9dY+bijgistcTTJ5mR+ocqRXLuhXHYdzoMmmoJ2C9S46RCm2GMUbatPEUK9Yjy26IrAy8D/M00lLkv+Q==",
+      "dev": true,
+      "dependencies": {
+        "@jridgewell/gen-mapping": "^0.3.5",
+        "@jridgewell/trace-mapping": "^0.3.25"
+      }
+    },
+    "node_modules/@jridgewell/sourcemap-codec": {
+      "version": "1.5.4",
+      "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz",
+      "integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==",
+      "dev": true
+    },
+    "node_modules/@jridgewell/trace-mapping": {
+      "version": "0.3.29",
+      "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz",
+      "integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==",
+      "dev": true,
+      "dependencies": {
+        "@jridgewell/resolve-uri": "^3.1.0",
+        "@jridgewell/sourcemap-codec": "^1.4.14"
+      }
+    },
+    "node_modules/@types/eslint": {
+      "version": "9.6.1",
+      "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
+      "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==",
+      "dev": true,
+      "dependencies": {
+        "@types/estree": "*",
+        "@types/json-schema": "*"
+      }
+    },
+    "node_modules/@types/eslint-scope": {
+      "version": "3.7.7",
+      "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz",
+      "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==",
+      "dev": true,
+      "dependencies": {
+        "@types/eslint": "*",
+        "@types/estree": "*"
+      }
+    },
+    "node_modules/@types/estree": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+      "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+      "dev": true
+    },
+    "node_modules/@types/json-schema": {
+      "version": "7.0.15",
+      "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
+      "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
+      "dev": true
+    },
+    "node_modules/@types/node": {
+      "version": "24.2.1",
+      "resolved": "https://registry.npmjs.org/@types/node/-/node-24.2.1.tgz",
+      "integrity": "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ==",
+      "dev": true,
+      "dependencies": {
+        "undici-types": "~7.10.0"
+      }
+    },
+    "node_modules/@webassemblyjs/ast": {
+      "version": "1.14.1",
+      "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz",
+      "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==",
+      "dev": true,
+      "dependencies": {
+        "@webassemblyjs/helper-numbers": "1.13.2",
+        "@webassemblyjs/helper-wasm-bytecode": "1.13.2"
+      }
+    },
+    "node_modules/@webassemblyjs/floating-point-hex-parser": {
+      "version": "1.13.2",
+      "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz",
+      "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==",
+      "dev": true
+    },
+    "node_modules/@webassemblyjs/helper-api-error": {
+      "version": "1.13.2",
+      "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz",
+      "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==",
+      "dev": true
+    },
+    "node_modules/@webassemblyjs/helper-buffer": {
+      "version": "1.14.1",
+      "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz",
+      "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==",
+      "dev": true
+    },
+    "node_modules/@webassemblyjs/helper-numbers": {
+      "version": "1.13.2",
+      "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz",
+      "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==",
+      "dev": true,
+      "dependencies": {
+        "@webassemblyjs/floating-point-hex-parser": "1.13.2",
+        "@webassemblyjs/helper-api-error": "1.13.2",
+        "@xtuc/long": "4.2.2"
+      }
+    },
+    "node_modules/@webassemblyjs/helper-wasm-bytecode": {
+      "version": "1.13.2",
+      "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz",
+      "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==",
+      "dev": true
+    },
+    "node_modules/@webassemblyjs/helper-wasm-section": {
+      "version": "1.14.1",
+      "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz",
+      "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==",
+      "dev": true,
+      "dependencies": {
+        "@webassemblyjs/ast": "1.14.1",
+        "@webassemblyjs/helper-buffer": "1.14.1",
+        "@webassemblyjs/helper-wasm-bytecode": "1.13.2",
+        "@webassemblyjs/wasm-gen": "1.14.1"
+      }
+    },
+    "node_modules/@webassemblyjs/ieee754": {
+      "version": "1.13.2",
+      "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz",
+      "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==",
+      "dev": true,
+      "dependencies": {
+        "@xtuc/ieee754": "^1.2.0"
+      }
+    },
+    "node_modules/@webassemblyjs/leb128": {
+      "version": "1.13.2",
+      "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz",
+      "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==",
+      "dev": true,
+      "dependencies": {
+        "@xtuc/long": "4.2.2"
+      }
+    },
+    "node_modules/@webassemblyjs/utf8": {
+      "version": "1.13.2",
+      "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz",
+      "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==",
+      "dev": true
+    },
+    "node_modules/@webassemblyjs/wasm-edit": {
+      "version": "1.14.1",
+      "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz",
+      "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==",
+      "dev": true,
+      "dependencies": {
+        "@webassemblyjs/ast": "1.14.1",
+        "@webassemblyjs/helper-buffer": "1.14.1",
+        "@webassemblyjs/helper-wasm-bytecode": "1.13.2",
+        "@webassemblyjs/helper-wasm-section": "1.14.1",
+        "@webassemblyjs/wasm-gen": "1.14.1",
+        "@webassemblyjs/wasm-opt": "1.14.1",
+        "@webassemblyjs/wasm-parser": "1.14.1",
+        "@webassemblyjs/wast-printer": "1.14.1"
+      }
+    },
+    "node_modules/@webassemblyjs/wasm-gen": {
+      "version": "1.14.1",
+      "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz",
+      "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==",
+      "dev": true,
+      "dependencies": {
+        "@webassemblyjs/ast": "1.14.1",
+        "@webassemblyjs/helper-wasm-bytecode": "1.13.2",
+        "@webassemblyjs/ieee754": "1.13.2",
+        "@webassemblyjs/leb128": "1.13.2",
+        "@webassemblyjs/utf8": "1.13.2"
+      }
+    },
+    "node_modules/@webassemblyjs/wasm-opt": {
+      "version": "1.14.1",
+      "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz",
+      "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==",
+      "dev": true,
+      "dependencies": {
+        "@webassemblyjs/ast": "1.14.1",
+        "@webassemblyjs/helper-buffer": "1.14.1",
+        "@webassemblyjs/wasm-gen": "1.14.1",
+        "@webassemblyjs/wasm-parser": "1.14.1"
+      }
+    },
+    "node_modules/@webassemblyjs/wasm-parser": {
+      "version": "1.14.1",
+      "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz",
+      "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==",
+      "dev": true,
+      "dependencies": {
+        "@webassemblyjs/ast": "1.14.1",
+        "@webassemblyjs/helper-api-error": "1.13.2",
+        "@webassemblyjs/helper-wasm-bytecode": "1.13.2",
+        "@webassemblyjs/ieee754": "1.13.2",
+        "@webassemblyjs/leb128": "1.13.2",
+        "@webassemblyjs/utf8": "1.13.2"
+      }
+    },
+    "node_modules/@webassemblyjs/wast-printer": {
+      "version": "1.14.1",
+      "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz",
+      "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==",
+      "dev": true,
+      "dependencies": {
+        "@webassemblyjs/ast": "1.14.1",
+        "@xtuc/long": "4.2.2"
+      }
+    },
+    "node_modules/@webpack-cli/configtest": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/@webpack-cli/configtest/-/configtest-3.0.1.tgz",
+      "integrity": "sha512-u8d0pJ5YFgneF/GuvEiDA61Tf1VDomHHYMjv/wc9XzYj7nopltpG96nXN5dJRstxZhcNpV1g+nT6CydO7pHbjA==",
+      "dev": true,
+      "engines": {
+        "node": ">=18.12.0"
+      },
+      "peerDependencies": {
+        "webpack": "^5.82.0",
+        "webpack-cli": "6.x.x"
+      }
+    },
+    "node_modules/@webpack-cli/info": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/@webpack-cli/info/-/info-3.0.1.tgz",
+      "integrity": "sha512-coEmDzc2u/ffMvuW9aCjoRzNSPDl/XLuhPdlFRpT9tZHmJ/039az33CE7uH+8s0uL1j5ZNtfdv0HkfaKRBGJsQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=18.12.0"
+      },
+      "peerDependencies": {
+        "webpack": "^5.82.0",
+        "webpack-cli": "6.x.x"
+      }
+    },
+    "node_modules/@webpack-cli/serve": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/@webpack-cli/serve/-/serve-3.0.1.tgz",
+      "integrity": "sha512-sbgw03xQaCLiT6gcY/6u3qBDn01CWw/nbaXl3gTdTFuJJ75Gffv3E3DBpgvY2fkkrdS1fpjaXNOmJlnbtKauKg==",
+      "dev": true,
+      "engines": {
+        "node": ">=18.12.0"
+      },
+      "peerDependencies": {
+        "webpack": "^5.82.0",
+        "webpack-cli": "6.x.x"
+      },
+      "peerDependenciesMeta": {
+        "webpack-dev-server": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@xtuc/ieee754": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz",
+      "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==",
+      "dev": true
+    },
+    "node_modules/@xtuc/long": {
+      "version": "4.2.2",
+      "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz",
+      "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==",
+      "dev": true
+    },
+    "node_modules/acorn": {
+      "version": "8.15.0",
+      "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
+      "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
+      "dev": true,
+      "bin": {
+        "acorn": "bin/acorn"
+      },
+      "engines": {
+        "node": ">=0.4.0"
+      }
+    },
+    "node_modules/acorn-import-phases": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz",
+      "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=10.13.0"
+      },
+      "peerDependencies": {
+        "acorn": "^8.14.0"
+      }
+    },
+    "node_modules/ajv": {
+      "version": "8.17.1",
+      "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
+      "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
+      "dev": true,
+      "dependencies": {
+        "fast-deep-equal": "^3.1.3",
+        "fast-uri": "^3.0.1",
+        "json-schema-traverse": "^1.0.0",
+        "require-from-string": "^2.0.2"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/epoberezkin"
+      }
+    },
+    "node_modules/ajv-formats": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz",
+      "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==",
+      "dev": true,
+      "dependencies": {
+        "ajv": "^8.0.0"
+      },
+      "peerDependencies": {
+        "ajv": "^8.0.0"
+      },
+      "peerDependenciesMeta": {
+        "ajv": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/ajv-keywords": {
+      "version": "5.1.0",
+      "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz",
+      "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==",
+      "dev": true,
+      "dependencies": {
+        "fast-deep-equal": "^3.1.3"
+      },
+      "peerDependencies": {
+        "ajv": "^8.8.2"
+      }
+    },
+    "node_modules/ansi-styles": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+      "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+      "dev": true,
+      "dependencies": {
+        "color-convert": "^2.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+      }
+    },
+    "node_modules/braces": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
+      "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
+      "dev": true,
+      "dependencies": {
+        "fill-range": "^7.1.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/browserslist": {
+      "version": "4.25.2",
+      "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.2.tgz",
+      "integrity": "sha512-0si2SJK3ooGzIawRu61ZdPCO1IncZwS8IzuX73sPZsXW6EQ/w/DAfPyKI8l1ETTCr2MnvqWitmlCUxgdul45jA==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/browserslist"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/browserslist"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "dependencies": {
+        "caniuse-lite": "^1.0.30001733",
+        "electron-to-chromium": "^1.5.199",
+        "node-releases": "^2.0.19",
+        "update-browserslist-db": "^1.1.3"
+      },
+      "bin": {
+        "browserslist": "cli.js"
+      },
+      "engines": {
+        "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+      }
+    },
+    "node_modules/buffer-from": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
+      "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
+      "dev": true
+    },
+    "node_modules/caniuse-lite": {
+      "version": "1.0.30001733",
+      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001733.tgz",
+      "integrity": "sha512-e4QKw/O2Kavj2VQTKZWrwzkt3IxOmIlU6ajRb6LP64LHpBo1J67k2Hi4Vu/TgJWsNtynurfS0uK3MaUTCPfu5Q==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/browserslist"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ]
+    },
+    "node_modules/chalk": {
+      "version": "4.1.2",
+      "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+      "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+      "dev": true,
+      "dependencies": {
+        "ansi-styles": "^4.1.0",
+        "supports-color": "^7.1.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/chalk?sponsor=1"
+      }
+    },
+    "node_modules/chrome-trace-event": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz",
+      "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=6.0"
+      }
+    },
+    "node_modules/clone-deep": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz",
+      "integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==",
+      "dev": true,
+      "dependencies": {
+        "is-plain-object": "^2.0.4",
+        "kind-of": "^6.0.2",
+        "shallow-clone": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/color-convert": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+      "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+      "dev": true,
+      "dependencies": {
+        "color-name": "~1.1.4"
+      },
+      "engines": {
+        "node": ">=7.0.0"
+      }
+    },
+    "node_modules/color-name": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+      "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+      "dev": true
+    },
+    "node_modules/colorette": {
+      "version": "2.0.20",
+      "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz",
+      "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==",
+      "dev": true
+    },
+    "node_modules/commander": {
+      "version": "2.20.3",
+      "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
+      "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
+      "dev": true
+    },
+    "node_modules/cross-spawn": {
+      "version": "7.0.6",
+      "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+      "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+      "dev": true,
+      "dependencies": {
+        "path-key": "^3.1.0",
+        "shebang-command": "^2.0.0",
+        "which": "^2.0.1"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/electron-to-chromium": {
+      "version": "1.5.199",
+      "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.199.tgz",
+      "integrity": "sha512-3gl0S7zQd88kCAZRO/DnxtBKuhMO4h0EaQIN3YgZfV6+pW+5+bf2AdQeHNESCoaQqo/gjGVYEf2YM4O5HJQqpQ==",
+      "dev": true
+    },
+    "node_modules/enhanced-resolve": {
+      "version": "5.18.3",
+      "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz",
+      "integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==",
+      "dev": true,
+      "dependencies": {
+        "graceful-fs": "^4.2.4",
+        "tapable": "^2.2.0"
+      },
+      "engines": {
+        "node": ">=10.13.0"
+      }
+    },
+    "node_modules/envinfo": {
+      "version": "7.14.0",
+      "resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.14.0.tgz",
+      "integrity": "sha512-CO40UI41xDQzhLB1hWyqUKgFhs250pNcGbyGKe1l/e4FSaI/+YE4IMG76GDt0In67WLPACIITC+sOi08x4wIvg==",
+      "dev": true,
+      "bin": {
+        "envinfo": "dist/cli.js"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/es-module-lexer": {
+      "version": "1.7.0",
+      "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
+      "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
+      "dev": true
+    },
+    "node_modules/escalade": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+      "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+      "dev": true,
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/eslint-scope": {
+      "version": "5.1.1",
+      "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz",
+      "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==",
+      "dev": true,
+      "dependencies": {
+        "esrecurse": "^4.3.0",
+        "estraverse": "^4.1.1"
+      },
+      "engines": {
+        "node": ">=8.0.0"
+      }
+    },
+    "node_modules/esrecurse": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
+      "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
+      "dev": true,
+      "dependencies": {
+        "estraverse": "^5.2.0"
+      },
+      "engines": {
+        "node": ">=4.0"
+      }
+    },
+    "node_modules/esrecurse/node_modules/estraverse": {
+      "version": "5.3.0",
+      "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
+      "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
+      "dev": true,
+      "engines": {
+        "node": ">=4.0"
+      }
+    },
+    "node_modules/estraverse": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz",
+      "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==",
+      "dev": true,
+      "engines": {
+        "node": ">=4.0"
+      }
+    },
+    "node_modules/events": {
+      "version": "3.3.0",
+      "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
+      "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.8.x"
+      }
+    },
+    "node_modules/fast-deep-equal": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+      "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+      "dev": true
+    },
+    "node_modules/fast-uri": {
+      "version": "3.0.6",
+      "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz",
+      "integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/fastify"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/fastify"
+        }
+      ]
+    },
+    "node_modules/fastest-levenshtein": {
+      "version": "1.0.16",
+      "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz",
+      "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==",
+      "dev": true,
+      "engines": {
+        "node": ">= 4.9.1"
+      }
+    },
+    "node_modules/fill-range": {
+      "version": "7.1.1",
+      "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
+      "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
+      "dev": true,
+      "dependencies": {
+        "to-regex-range": "^5.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/find-up": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
+      "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
+      "dev": true,
+      "dependencies": {
+        "locate-path": "^5.0.0",
+        "path-exists": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/flat": {
+      "version": "5.0.2",
+      "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz",
+      "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==",
+      "dev": true,
+      "bin": {
+        "flat": "cli.js"
+      }
+    },
+    "node_modules/function-bind": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+      "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+      "dev": true,
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/glob-to-regexp": {
+      "version": "0.4.1",
+      "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz",
+      "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==",
+      "dev": true
+    },
+    "node_modules/graceful-fs": {
+      "version": "4.2.11",
+      "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
+      "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+      "dev": true
+    },
+    "node_modules/has-flag": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+      "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/hasown": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
+      "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+      "dev": true,
+      "dependencies": {
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/import-local": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz",
+      "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==",
+      "dev": true,
+      "dependencies": {
+        "pkg-dir": "^4.2.0",
+        "resolve-cwd": "^3.0.0"
+      },
+      "bin": {
+        "import-local-fixture": "fixtures/cli.js"
+      },
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/interpret": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/interpret/-/interpret-3.1.1.tgz",
+      "integrity": "sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=10.13.0"
+      }
+    },
+    "node_modules/is-core-module": {
+      "version": "2.16.1",
+      "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
+      "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
+      "dev": true,
+      "dependencies": {
+        "hasown": "^2.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-number": {
+      "version": "7.0.0",
+      "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+      "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.12.0"
+      }
+    },
+    "node_modules/is-plain-object": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz",
+      "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==",
+      "dev": true,
+      "dependencies": {
+        "isobject": "^3.0.1"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/isexe": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+      "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+      "dev": true
+    },
+    "node_modules/isobject": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz",
+      "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/jest-worker": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz",
+      "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==",
+      "dev": true,
+      "dependencies": {
+        "@types/node": "*",
+        "merge-stream": "^2.0.0",
+        "supports-color": "^8.0.0"
+      },
+      "engines": {
+        "node": ">= 10.13.0"
+      }
+    },
+    "node_modules/jest-worker/node_modules/supports-color": {
+      "version": "8.1.1",
+      "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
+      "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
+      "dev": true,
+      "dependencies": {
+        "has-flag": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/supports-color?sponsor=1"
+      }
+    },
+    "node_modules/json-parse-even-better-errors": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
+      "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
+      "dev": true
+    },
+    "node_modules/json-schema-traverse": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+      "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+      "dev": true
+    },
+    "node_modules/kind-of": {
+      "version": "6.0.3",
+      "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
+      "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/loader-runner": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz",
+      "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==",
+      "dev": true,
+      "engines": {
+        "node": ">=6.11.5"
+      }
+    },
+    "node_modules/locate-path": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
+      "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
+      "dev": true,
+      "dependencies": {
+        "p-locate": "^4.1.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/merge-stream": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
+      "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==",
+      "dev": true
+    },
+    "node_modules/micromatch": {
+      "version": "4.0.8",
+      "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
+      "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
+      "dev": true,
+      "dependencies": {
+        "braces": "^3.0.3",
+        "picomatch": "^2.3.1"
+      },
+      "engines": {
+        "node": ">=8.6"
+      }
+    },
+    "node_modules/mime-db": {
+      "version": "1.52.0",
+      "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+      "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+      "dev": true,
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mime-types": {
+      "version": "2.1.35",
+      "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+      "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+      "dev": true,
+      "dependencies": {
+        "mime-db": "1.52.0"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/neo-async": {
+      "version": "2.6.2",
+      "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
+      "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
+      "dev": true
+    },
+    "node_modules/node-releases": {
+      "version": "2.0.19",
+      "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
+      "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==",
+      "dev": true
+    },
+    "node_modules/p-limit": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
+      "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+      "dev": true,
+      "dependencies": {
+        "p-try": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/p-locate": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
+      "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
+      "dev": true,
+      "dependencies": {
+        "p-limit": "^2.2.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/p-try": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
+      "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/path-exists": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
+      "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/path-key": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+      "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/path-parse": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
+      "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
+      "dev": true
+    },
+    "node_modules/picocolors": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+      "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+      "dev": true
+    },
+    "node_modules/picomatch": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
+      "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+      "dev": true,
+      "engines": {
+        "node": ">=8.6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/jonschlinkert"
+      }
+    },
+    "node_modules/pkg-dir": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz",
+      "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==",
+      "dev": true,
+      "dependencies": {
+        "find-up": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/randombytes": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
+      "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==",
+      "dev": true,
+      "dependencies": {
+        "safe-buffer": "^5.1.0"
+      }
+    },
+    "node_modules/rechoir": {
+      "version": "0.8.0",
+      "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz",
+      "integrity": "sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==",
+      "dev": true,
+      "dependencies": {
+        "resolve": "^1.20.0"
+      },
+      "engines": {
+        "node": ">= 10.13.0"
+      }
+    },
+    "node_modules/require-from-string": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+      "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/resolve": {
+      "version": "1.22.10",
+      "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
+      "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==",
+      "dev": true,
+      "dependencies": {
+        "is-core-module": "^2.16.0",
+        "path-parse": "^1.0.7",
+        "supports-preserve-symlinks-flag": "^1.0.0"
+      },
+      "bin": {
+        "resolve": "bin/resolve"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/resolve-cwd": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz",
+      "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==",
+      "dev": true,
+      "dependencies": {
+        "resolve-from": "^5.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/resolve-from": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz",
+      "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/safe-buffer": {
+      "version": "5.2.1",
+      "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+      "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ]
+    },
+    "node_modules/schema-utils": {
+      "version": "4.3.2",
+      "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.2.tgz",
+      "integrity": "sha512-Gn/JaSk/Mt9gYubxTtSn/QCV4em9mpAPiR1rqy/Ocu19u/G9J5WWdNoUT4SiV6mFC3y6cxyFcFwdzPM3FgxGAQ==",
+      "dev": true,
+      "dependencies": {
+        "@types/json-schema": "^7.0.9",
+        "ajv": "^8.9.0",
+        "ajv-formats": "^2.1.1",
+        "ajv-keywords": "^5.1.0"
+      },
+      "engines": {
+        "node": ">= 10.13.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/webpack"
+      }
+    },
+    "node_modules/semver": {
+      "version": "7.7.2",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
+      "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
+      "dev": true,
+      "bin": {
+        "semver": "bin/semver.js"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/serialize-javascript": {
+      "version": "6.0.2",
+      "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz",
+      "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==",
+      "dev": true,
+      "dependencies": {
+        "randombytes": "^2.1.0"
+      }
+    },
+    "node_modules/shallow-clone": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz",
+      "integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==",
+      "dev": true,
+      "dependencies": {
+        "kind-of": "^6.0.2"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/shebang-command": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+      "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+      "dev": true,
+      "dependencies": {
+        "shebang-regex": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/shebang-regex": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+      "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/source-map": {
+      "version": "0.7.6",
+      "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz",
+      "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==",
+      "dev": true,
+      "engines": {
+        "node": ">= 12"
+      }
+    },
+    "node_modules/source-map-support": {
+      "version": "0.5.21",
+      "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
+      "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
+      "dev": true,
+      "dependencies": {
+        "buffer-from": "^1.0.0",
+        "source-map": "^0.6.0"
+      }
+    },
+    "node_modules/source-map-support/node_modules/source-map": {
+      "version": "0.6.1",
+      "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
+      "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/supports-color": {
+      "version": "7.2.0",
+      "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+      "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+      "dev": true,
+      "dependencies": {
+        "has-flag": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/supports-preserve-symlinks-flag": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
+      "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
+      "dev": true,
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/tapable": {
+      "version": "2.2.2",
+      "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.2.tgz",
+      "integrity": "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==",
+      "dev": true,
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/terser": {
+      "version": "5.43.1",
+      "resolved": "https://registry.npmjs.org/terser/-/terser-5.43.1.tgz",
+      "integrity": "sha512-+6erLbBm0+LROX2sPXlUYx/ux5PyE9K/a92Wrt6oA+WDAoFTdpHE5tCYCI5PNzq2y8df4rA+QgHLJuR4jNymsg==",
+      "dev": true,
+      "dependencies": {
+        "@jridgewell/source-map": "^0.3.3",
+        "acorn": "^8.14.0",
+        "commander": "^2.20.0",
+        "source-map-support": "~0.5.20"
+      },
+      "bin": {
+        "terser": "bin/terser"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/terser-webpack-plugin": {
+      "version": "5.3.14",
+      "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.14.tgz",
+      "integrity": "sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==",
+      "dev": true,
+      "dependencies": {
+        "@jridgewell/trace-mapping": "^0.3.25",
+        "jest-worker": "^27.4.5",
+        "schema-utils": "^4.3.0",
+        "serialize-javascript": "^6.0.2",
+        "terser": "^5.31.1"
+      },
+      "engines": {
+        "node": ">= 10.13.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/webpack"
+      },
+      "peerDependencies": {
+        "webpack": "^5.1.0"
+      },
+      "peerDependenciesMeta": {
+        "@swc/core": {
+          "optional": true
+        },
+        "esbuild": {
+          "optional": true
+        },
+        "uglify-js": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/to-regex-range": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+      "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+      "dev": true,
+      "dependencies": {
+        "is-number": "^7.0.0"
+      },
+      "engines": {
+        "node": ">=8.0"
+      }
+    },
+    "node_modules/ts-loader": {
+      "version": "9.5.2",
+      "resolved": "https://registry.npmjs.org/ts-loader/-/ts-loader-9.5.2.tgz",
+      "integrity": "sha512-Qo4piXvOTWcMGIgRiuFa6nHNm+54HbYaZCKqc9eeZCLRy3XqafQgwX2F7mofrbJG3g7EEb+lkiR+z2Lic2s3Zw==",
+      "dev": true,
+      "dependencies": {
+        "chalk": "^4.1.0",
+        "enhanced-resolve": "^5.0.0",
+        "micromatch": "^4.0.0",
+        "semver": "^7.3.4",
+        "source-map": "^0.7.4"
+      },
+      "engines": {
+        "node": ">=12.0.0"
+      },
+      "peerDependencies": {
+        "typescript": "*",
+        "webpack": "^5.0.0"
+      }
+    },
+    "node_modules/typescript": {
+      "version": "5.9.2",
+      "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz",
+      "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
+      "dev": true,
+      "bin": {
+        "tsc": "bin/tsc",
+        "tsserver": "bin/tsserver"
+      },
+      "engines": {
+        "node": ">=14.17"
+      }
+    },
+    "node_modules/undici-types": {
+      "version": "7.10.0",
+      "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz",
+      "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==",
+      "dev": true
+    },
+    "node_modules/update-browserslist-db": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz",
+      "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/browserslist"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/browserslist"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "dependencies": {
+        "escalade": "^3.2.0",
+        "picocolors": "^1.1.1"
+      },
+      "bin": {
+        "update-browserslist-db": "cli.js"
+      },
+      "peerDependencies": {
+        "browserslist": ">= 4.21.0"
+      }
+    },
+    "node_modules/watchpack": {
+      "version": "2.4.4",
+      "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.4.tgz",
+      "integrity": "sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA==",
+      "dev": true,
+      "dependencies": {
+        "glob-to-regexp": "^0.4.1",
+        "graceful-fs": "^4.1.2"
+      },
+      "engines": {
+        "node": ">=10.13.0"
+      }
+    },
+    "node_modules/webpack": {
+      "version": "5.101.0",
+      "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.101.0.tgz",
+      "integrity": "sha512-B4t+nJqytPeuZlHuIKTbalhljIFXeNRqrUGAQgTGlfOl2lXXKXw+yZu6bicycP+PUlM44CxBjCFD6aciKFT3LQ==",
+      "dev": true,
+      "dependencies": {
+        "@types/eslint-scope": "^3.7.7",
+        "@types/estree": "^1.0.8",
+        "@types/json-schema": "^7.0.15",
+        "@webassemblyjs/ast": "^1.14.1",
+        "@webassemblyjs/wasm-edit": "^1.14.1",
+        "@webassemblyjs/wasm-parser": "^1.14.1",
+        "acorn": "^8.15.0",
+        "acorn-import-phases": "^1.0.3",
+        "browserslist": "^4.24.0",
+        "chrome-trace-event": "^1.0.2",
+        "enhanced-resolve": "^5.17.2",
+        "es-module-lexer": "^1.2.1",
+        "eslint-scope": "5.1.1",
+        "events": "^3.2.0",
+        "glob-to-regexp": "^0.4.1",
+        "graceful-fs": "^4.2.11",
+        "json-parse-even-better-errors": "^2.3.1",
+        "loader-runner": "^4.2.0",
+        "mime-types": "^2.1.27",
+        "neo-async": "^2.6.2",
+        "schema-utils": "^4.3.2",
+        "tapable": "^2.1.1",
+        "terser-webpack-plugin": "^5.3.11",
+        "watchpack": "^2.4.1",
+        "webpack-sources": "^3.3.3"
+      },
+      "bin": {
+        "webpack": "bin/webpack.js"
+      },
+      "engines": {
+        "node": ">=10.13.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/webpack"
+      },
+      "peerDependenciesMeta": {
+        "webpack-cli": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/webpack-cli": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-6.0.1.tgz",
+      "integrity": "sha512-MfwFQ6SfwinsUVi0rNJm7rHZ31GyTcpVE5pgVA3hwFRb7COD4TzjUUwhGWKfO50+xdc2MQPuEBBJoqIMGt3JDw==",
+      "dev": true,
+      "dependencies": {
+        "@discoveryjs/json-ext": "^0.6.1",
+        "@webpack-cli/configtest": "^3.0.1",
+        "@webpack-cli/info": "^3.0.1",
+        "@webpack-cli/serve": "^3.0.1",
+        "colorette": "^2.0.14",
+        "commander": "^12.1.0",
+        "cross-spawn": "^7.0.3",
+        "envinfo": "^7.14.0",
+        "fastest-levenshtein": "^1.0.12",
+        "import-local": "^3.0.2",
+        "interpret": "^3.1.1",
+        "rechoir": "^0.8.0",
+        "webpack-merge": "^6.0.1"
+      },
+      "bin": {
+        "webpack-cli": "bin/cli.js"
+      },
+      "engines": {
+        "node": ">=18.12.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/webpack"
+      },
+      "peerDependencies": {
+        "webpack": "^5.82.0"
+      },
+      "peerDependenciesMeta": {
+        "webpack-bundle-analyzer": {
+          "optional": true
+        },
+        "webpack-dev-server": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/webpack-cli/node_modules/commander": {
+      "version": "12.1.0",
+      "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
+      "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==",
+      "dev": true,
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/webpack-merge": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-6.0.1.tgz",
+      "integrity": "sha512-hXXvrjtx2PLYx4qruKl+kyRSLc52V+cCvMxRjmKwoA+CBbbF5GfIBtR6kCvl0fYGqTUPKB+1ktVmTHqMOzgCBg==",
+      "dev": true,
+      "dependencies": {
+        "clone-deep": "^4.0.1",
+        "flat": "^5.0.2",
+        "wildcard": "^2.0.1"
+      },
+      "engines": {
+        "node": ">=18.0.0"
+      }
+    },
+    "node_modules/webpack-sources": {
+      "version": "3.3.3",
+      "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.3.tgz",
+      "integrity": "sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==",
+      "dev": true,
+      "engines": {
+        "node": ">=10.13.0"
+      }
+    },
+    "node_modules/which": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+      "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+      "dev": true,
+      "dependencies": {
+        "isexe": "^2.0.0"
+      },
+      "bin": {
+        "node-which": "bin/node-which"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/wildcard": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz",
+      "integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==",
+      "dev": true
+    }
+  }
+}

+ 18 - 0
reports/package.json

@@ -0,0 +1,18 @@
+{
+  "name": "reports",
+  "version": "1.0.0",
+  "description": "",
+  "main": "main.js",
+  "scripts": {
+    "test": "echo \"Error: no test specified\" && exit 1"
+  },
+  "keywords": [],
+  "author": "",
+  "license": "ISC",
+  "devDependencies": {
+    "ts-loader": "^9.5.2",
+    "typescript": "^5.9.2",
+    "webpack": "^5.101.0",
+    "webpack-cli": "^6.0.1"
+  }
+}

+ 119 - 0
reports/src/core.ts

@@ -0,0 +1,119 @@
+import { graphs } from "./main";
+import { updatePermalink } from "./permalink";
+import { deepMerge } from "./utils";
+
+export function switchPlot()
+{
+    const graphSelect = document.getElementById("graphType") as HTMLSelectElement;
+    const graph = graphs.find(obj => obj.id == graphSelect.value);
+
+    // Configure layout
+    const oldGraph = document.getElementById("mainPlot");
+	oldGraph?.remove();
+	const newGraph = document.createElement("div");
+	newGraph.id = "mainPlot";
+	const graphContainer = document.getElementById("mainPlotContainer");
+	graphContainer?.appendChild(newGraph);
+
+    // Get data
+    const configValues = {} as any;
+    graph?.configFieldIDs.forEach(id => {
+        const inputElem = document.getElementById(id) as HTMLInputElement;
+        configValues[id] = inputElem?.value;
+    });
+
+    updatePermalink();
+
+    (async () => {
+        graph?.generatePlot(configValues, "mainPlot");
+    })();
+}
+
+// Creating a Plotly graph using several defaults
+// Define defaults
+const defaultData = {marker: {color: 'rgb(247, 166, 0)'}, hovertemplate: '%{x}: %{y:,.3~s}<extra></extra>'};
+const defaultLayout = {
+    title: {
+        font: {color: '#eee', family: '"Droid Sans", sans-serif'}},
+    xaxis: {
+        title: {
+            font: {color: '#bbb', family: '"Droid Sans", sans-serif'}
+        },
+        tickfont: {color: '#666', family: '"Droid Sans", sans-serif'},
+        tickangle: -45
+    },
+    yaxis: {
+        title: {
+            font: {color: '#bbb', family: '"Droid Sans", sans-serif'}
+        },
+        tickfont: {color: '#666', family: '"Droid Sans", sans-serif'},
+        gridcolor: '#323232'
+    }, 
+    plot_bgcolor: '#252525',
+	paper_bgcolor: '#252525',
+	dragmode: 'pan'
+};
+const defaultConfig = {
+    displayModeBar: true,
+    modeBarButtonsToRemove: ['lasso2d'],  // Remove unwanted buttons
+    displaylogo: false,
+    scrollZoom: true,
+    responsive: true
+};
+// Actually create the graph
+export function createGraph(plotContainerID: string, data: any[], layout: any, config: any)
+{
+    // Assign default values
+    for(var i = 0; i < data.length; i++)
+    {
+        data[i] = deepMerge(structuredClone(defaultData), data[i]);
+    }
+    layout = deepMerge(structuredClone(defaultLayout), layout);
+    config = deepMerge(structuredClone(defaultConfig), config);
+    
+    // @ts-ignore
+    Plotly.newPlot(plotContainerID, {'data': data, 'layout': layout, 'config': config})
+}
+
+// Create a table
+export function createTable(plotContainerID: string, titleText: string, headers: string[], data: any[], dataDisplay: any[])
+{
+    const container = document.getElementById(plotContainerID);
+    if(!container){return;}
+
+    // Create title
+	const title = document.createElement("div");
+	title.textContent = titleText;
+	title.classList.add("title");
+	container.appendChild(title);
+
+    const table = document.createElement("table");
+    
+    // Create header
+    const header = document.createElement("thead");
+    const headRow = document.createElement("tr");
+
+    headers.forEach(label => {
+        const column = document.createElement("th");
+        column.textContent = label;
+        headRow.appendChild(column);
+    });
+    header.appendChild(headRow);
+    table.appendChild(header);
+
+    // Create body
+    const body = document.createElement("tbody");
+    dataDisplay.forEach((dataRow: HTMLElement[]) => {
+        const row = document.createElement("tr");
+        dataRow.forEach(dataElem => {
+            const td = document.createElement("td");
+            td.appendChild(dataElem);
+            row.appendChild(td);
+        });
+        body.appendChild(row);
+    });
+
+    table.appendChild(body);
+
+    container.appendChild(table);
+}

+ 98 - 0
reports/src/graphs/companyHistory.ts

@@ -0,0 +1,98 @@
+import { createGraph, switchPlot } from "../core";
+import { months, monthsPretty } from "../staticData/constants";
+import { addConfigField, clearChildren, getCompanyId, getData } from "../utils";
+import { Graph } from "./graph";
+
+export class CompanyHistory implements Graph {
+    id = "compHistory";
+    displayName = "Company History";
+    configFieldIDs = ["metric", "companyName"];
+    loadedData: any;
+    urlParams: any;
+
+    constructor(loadedData: any, urlParams: any)
+    {
+        this.loadedData = loadedData;
+        this.urlParams = urlParams;
+    }
+
+    setConfigs(useURLParams?: boolean)
+    {
+        const updateFunc = function() {switchPlot();}
+
+        const configDiv = document.getElementById("selectorSubtypes");
+        if(configDiv)
+        {
+            clearChildren(configDiv);
+        }
+
+        configDiv?.appendChild(addConfigField("select", "metric", "Metric: ", {prettyValues: ["Volume", "Profit"], values: ["volume", "profit"]}, useURLParams ? this.urlParams.metric : undefined, updateFunc));
+        configDiv?.appendChild(addConfigField("input", "companyName", "Username: ", undefined, useURLParams ? this.urlParams.companyName : undefined, updateFunc, "-27px"));
+        
+    }
+
+    async generatePlot(configValues: any, plotContainerID: string)
+    {
+        if(!configValues.companyName || configValues.companyName == ""){return;}
+
+        // Get Company Data
+        const knownCompanies = await getData(this.loadedData, "knownCompanies");
+
+        // Get Company ID
+        var companyID = await getCompanyId(configValues.companyName, this.loadedData) as string;
+        if(!companyID){ return; }
+        var companyName = knownCompanies[companyID];
+
+        // Get Data
+        const fullCompanyData = [] as any[];    // Company data across the months
+        for(var i = 0; i < months.length; i++)
+        {
+            const monthData = await getData(this.loadedData, "company", months[i]);
+            fullCompanyData.push(monthData.individual[companyID]);
+        }
+
+        const validMonths = [] as string[]; // Months with data
+        const companyData = [] as number[]; // Company data for specific metric
+        fullCompanyData.forEach((data, i) => {
+            if(!data){return;}
+
+            var metric = 0;   // Metric value for this month
+            validMonths.push(monthsPretty[i]);
+            Object.keys(data as any).forEach((ticker: string) => {
+                metric += data[ticker]?.[configValues.metric] ?? 0;
+            });
+
+            companyData.push(metric);
+        });
+
+        // Create graph
+        const titles = {
+            'profit': 'Production Profit History of ',
+            'volume': 'Production Volume History of ',
+        } as any
+        const yAxis = {
+            'profit': 'Daily Profit [$/day]',
+            'volume': 'Daily Volume [$/day]'
+        } as any
+        createGraph(plotContainerID, [{x: validMonths, y: companyData, type: 'bar'}], 
+        {
+            width: this.urlParams.hideOptions !== undefined ? undefined : 800,
+            height: this.urlParams.hideOptions !== undefined ? undefined : 400,
+            autosize: this.urlParams.hideOptions !== undefined,
+            ...(this.urlParams.hideOptions !== undefined ? {margin: {
+                l: 60,  // left
+                r: 10,  // right
+                t: 40,  // top
+                b: 60   // bottom
+            }} : {}),
+            title: {text: titles[configValues.metric] + companyName},
+            xaxis: {
+                title: {text: 'Month'}
+            },
+            yaxis: {
+                title: {text: yAxis[configValues.metric]}
+            }
+        }, {})
+
+    }
+}

+ 143 - 0
reports/src/graphs/companyRank.ts

@@ -0,0 +1,143 @@
+import { createGraph, createTable, switchPlot } from "../core";
+import { months, monthsPretty } from "../staticData/constants";
+import { addConfigField, clearChildren, getCompanyId, getData, prettyMonthName } from "../utils";
+import { Graph } from "./graph";
+
+export class CompanyRank implements Graph {
+    id = "compRank";
+    displayName = "Company Rank";
+    configFieldIDs = ["month", "companyName"];
+    loadedData: any;
+    urlParams: any;
+
+    constructor(loadedData: any, urlParams: any)
+    {
+        this.loadedData = loadedData;
+        this.urlParams = urlParams;
+    }
+
+    setConfigs(useURLParams?: boolean)
+    {
+        const updateFunc = function() {switchPlot();}
+
+        const configDiv = document.getElementById("selectorSubtypes");
+        if(configDiv)
+        {
+            clearChildren(configDiv);
+        }
+
+        configDiv?.appendChild(addConfigField("select", "month", "Month: ", {prettyValues: monthsPretty, "values": months}, useURLParams && this.urlParams.month ? this.urlParams.month : months[months.length - 1], updateFunc));
+        configDiv?.appendChild(addConfigField("input", "companyName", "Username: ", undefined, useURLParams ? this.urlParams.companyName : undefined, updateFunc, "-27px"));
+        
+    }
+
+    async generatePlot(configValues: any, plotContainerID: string)
+    {
+        if(!configValues.companyName || configValues.companyName == ""){return;}
+
+        // Get Company Data
+        const knownCompanies = await getData(this.loadedData, "knownCompanies");
+
+        // Get Company ID
+        var companyID = await getCompanyId(configValues.companyName, this.loadedData) as string;
+        if(!companyID){ return; }
+        var companyName = knownCompanies[companyID];
+
+        // Get Data
+        const fullCompanyData = await getData(this.loadedData, "company", configValues.month);    // Company data for the current month
+        const fullPrevCompanyData = configValues.month == months[0] ? {individual: {}} : await getData(this.loadedData, "company", months[months.indexOf(configValues.month) - 1])
+        
+        const companyData = fullCompanyData.individual[companyID];  // Company data for this company for this month
+        const prevCompanyData = fullPrevCompanyData.individual[companyID];  // Company data for this company for last month. May be undefined.
+
+        if(!companyData){return;}
+
+        var tableData = [] as any[];   // Entries in the table in raw data form
+        var tableDisplay = [] as any[];    // Entries in the table in presentable form
+
+        Object.keys(companyData).forEach(ticker => {
+            const tableRow = [companyData[ticker].rank, ticker, companyData[ticker].amount, companyData[ticker].volume, companyData[ticker].profit];
+            const tableDisplayRow = [] as any[];
+
+            // Add company rank
+            if(prevCompanyData)
+            {
+                const outerRankDiv = document.createElement("div");
+                const symbolDiv = document.createElement("div");
+                const rankDiv = document.createElement("div");
+                
+                outerRankDiv.style.display = "flex";
+
+                symbolDiv.style.width = "14px";
+                symbolDiv.style.minWidth = "14px";
+                symbolDiv.style.marginRight = "2px";
+
+                const prevRank = prevCompanyData[ticker]?.rank;
+                const increasing = prevRank < companyData[ticker].rank;
+
+                if(prevRank && prevRank != companyData[ticker].rank)
+                {
+                    symbolDiv.textContent = increasing ? "▼" : "▲";
+				    symbolDiv.style.color = increasing ? "#d9534f" : "#5cb85c";
+                }
+                
+                rankDiv.textContent = companyData[ticker].rank;
+
+                outerRankDiv.appendChild(symbolDiv);
+                outerRankDiv.appendChild(rankDiv);
+                tableDisplayRow.push(outerRankDiv);
+            }
+            else
+            {
+                const rankDiv = document.createElement("div");
+                rankDiv.textContent = companyData[ticker].rank;
+                tableDisplayRow.push(rankDiv);
+            }
+
+            // Add ticker
+            const tickerDiv = document.createElement("div");
+            tickerDiv.textContent = ticker;
+            tableDisplayRow.push(tickerDiv);
+
+            // Add amount
+            const amountDiv = document.createElement("div");
+            amountDiv.textContent = companyData[ticker].amount.toLocaleString(undefined, {maximumFractionDigits: 1});
+            tableDisplayRow.push(amountDiv);
+
+            // Add volume
+            const volumeDiv = document.createElement("div");
+            volumeDiv.textContent = "$" + companyData[ticker].volume.toLocaleString(undefined, {notation: "compact", maximumSignificantDigits: 3});
+            tableDisplayRow.push(volumeDiv);
+
+            // Add profit
+            const profitDiv = document.createElement("div");
+            var profitText: string;
+            if(companyData[ticker].profit < 0)
+            {
+                profitText = "-$" + (-companyData[ticker].profit).toLocaleString(undefined, {notation: "compact", maximumSignificantDigits: 3});
+            }
+            else
+            {
+                profitText = "$" + companyData[ticker].profit.toLocaleString(undefined, {notation: "compact", maximumSignificantDigits: 3});
+            }
+            profitDiv.textContent = profitText;
+            tableDisplayRow.push(profitDiv);
+
+            tableData.push(tableRow);
+            tableDisplay.push(tableDisplayRow);
+        });
+
+        // Sort by rank by default
+        const indices = tableData.map((_, i) => i)
+            .sort((a, b) => tableData[a][0] - tableData[b][0]);
+
+        tableData = indices.map(i => tableData[i]);
+        tableDisplay = indices.map(i => tableDisplay[i]);
+
+        const title = "Production Ranking of " + companyName + " - " + prettyMonthName(configValues.month);
+
+        const headers = ["Rank", "Ticker", "Amount [/day]", "Volume [$/day]", "Profit [$/day]"];
+
+        createTable(plotContainerID, title, headers, tableData, tableDisplay);
+    }
+}

+ 195 - 0
reports/src/graphs/companyTotals.ts

@@ -0,0 +1,195 @@
+import { createGraph, switchPlot } from "../core";
+import { materialCategoryColors, months, monthsPretty, prettyModeNames } from "../staticData/constants";
+import { addConfigField, clearChildren, getCompanyId, getData, getMatCategory, getMatColor, prettyMonthName } from "../utils";
+import { Graph } from "./graph";
+
+export class CompanyTotals implements Graph {
+    id = "compTotals";
+    displayName = "Company Totals";
+    configFieldIDs = ["chartType", "metric", "month", "companyName"];
+    loadedData: any;
+    urlParams: any;
+
+    constructor(loadedData: any, urlParams: any)
+    {
+        this.loadedData = loadedData;
+        this.urlParams = urlParams;
+    }
+
+    setConfigs(useURLParams?: boolean)
+    {
+        const updateFunc = function() {switchPlot();}
+
+        const configDiv = document.getElementById("selectorSubtypes");
+        if(configDiv)
+        {
+            clearChildren(configDiv);
+        }
+
+        configDiv?.appendChild(addConfigField("select", "chartType", "Chart Type: ", {prettyValues: ["Bar", "Pie", "Treemap (Mat)", "Treemap (Cat)"], values: ["bar", "pie", "treemap", "treemap-categories"]}, useURLParams ? this.urlParams.chartType : undefined, updateFunc, "-30px"));
+        configDiv?.appendChild(addConfigField("select", "metric", "Metric: ", {prettyValues: ["Volume", "Profit"], values: ["volume", "profit"]}, useURLParams ? this.urlParams.metric : undefined, updateFunc));
+        configDiv?.appendChild(addConfigField("select", "month", "Month: ", {prettyValues: monthsPretty, "values": months}, useURLParams && this.urlParams.month ? this.urlParams.month : months[months.length - 1], updateFunc));
+        configDiv?.appendChild(addConfigField("input", "companyName", "Username: ", undefined, useURLParams ? this.urlParams.companyName : undefined, updateFunc, "-27px"));
+        
+    }
+
+    async generatePlot(configValues: any, plotContainerID: string)
+    {
+        if(!configValues.companyName || configValues.companyName == ""){return;}
+        // Get Company Data
+        const companyData = await getData(this.loadedData, "company", configValues.month);
+        const knownCompanies = await getData(this.loadedData, "knownCompanies");
+
+        // Get Company ID
+        var companyID = await getCompanyId(configValues.companyName, this.loadedData) as string;
+        if(!companyID){ return; }
+        var companyName = knownCompanies[companyID];
+
+        if(!companyData.individual[companyID]){return;}
+        // Parse Data
+        var catData = [] as number[]; // Y-axis of chart
+        var categories = [] as any[];  // X-axis of chart
+        var totalValue = 0; // Total of metric
+        Object.keys(companyData.individual[companyID]).forEach((ticker: string) => {
+            const metric = companyData.individual[companyID][ticker][configValues.metric];
+            if(metric < 0 && (configValues.chartType == "treemap" || configValues.chartType == "treemap-categories")){return;}
+            totalValue += metric;
+
+            if(configValues.chartType == "treemap-categories")
+            {
+                const category = getMatCategory(ticker);
+                
+                const catIndex = categories.indexOf(category);
+                if(catIndex == -1)
+                {
+                    categories.push(category);
+                    catData.push(metric);
+                }
+                else
+                {
+                    catData[catIndex] += metric;
+                }
+            }
+            else
+            {
+                catData.push(metric);
+                categories.push(ticker);
+            }
+        });
+
+        // Sort data from largest to smallest categories
+        const indices = Array.from(categories.keys());
+        indices.sort((a, b) => catData[b] - catData[a]);
+        catData = indices.map(i => catData[i]);
+        categories = indices.map(i => categories[i]);
+
+        // Create graph
+        const titles = {
+		    'profit': 'Production Profit Breakdown of ',
+		    'volume': 'Production Volume Breakdown of ',
+	    } as any;
+
+        if(configValues.chartType == "treemap" || configValues.chartType == "treemap-categories")
+        {
+            // Get Colors
+            const colors = [] as string[];
+            if(configValues.chartType == "treemap")
+            {
+                categories.forEach(cat => {
+                    colors.push(getMatColor(cat));
+                }); 
+            }
+            else
+            {
+                categories.forEach(cat => {
+                    colors.push(materialCategoryColors[cat] ?? "#000000");
+                });
+            }
+            
+            const parents = categories.map(m => "Total");
+            categories.push("Total");
+            catData.push(totalValue);
+            parents.push('');
+            colors.push('#252525');
+
+            // Make graph
+            createGraph(plotContainerID, [{
+                labels: categories, 
+                values: catData, 
+                parents: parents, 
+                type: 'treemap', 
+                maxdepth: 2, 
+                branchvalues: 'total',
+                marker: {
+                    colors: colors,
+                },
+                tiling: {
+                    pad: 0,
+                },
+                textposition: 'middle center',
+                hovertemplate: '%{label}<br>$%{value:,.3~s}/day<br>%{percentEntry:.2%}<extra></extra>'}],
+            {
+                width: this.urlParams.hideOptions !== undefined ? undefined : 800,
+                height: this.urlParams.hideOptions !== undefined ? undefined : 400,
+                autosize: this.urlParams.hideOptions !== undefined,
+                ...(this.urlParams.hideOptions !== undefined ? {margin: {
+                    l: 10,  // left
+                    r: 10,  // right
+                    t: 40,  // top
+                    b: 10   // bottom
+                }} : {}),
+                title: {text: titles[configValues.metric] + companyName + ' - ' + prettyMonthName(configValues.month)}
+            }, {})
+        }
+        else if(configValues.chartType == "bar")
+        {
+            // Create graph
+            createGraph(plotContainerID, [{x: categories, y: catData, type: 'bar'}], 
+                {
+                    width: this.urlParams.hideOptions !== undefined ? undefined : 800,
+                    height: this.urlParams.hideOptions !== undefined ? undefined : 400,
+                    autosize: this.urlParams.hideOptions !== undefined,
+                    ...(this.urlParams.hideOptions !== undefined ? {margin: {
+                        l: 60,  // left
+                        r: 10,  // right
+                        t: 40,  // top
+                        b: 60   // bottom
+                    }} : {}),
+                    title: {text: titles[configValues.metric] + companyName + ' - ' + prettyMonthName(configValues.month)},
+                    xaxis: {
+                        title: {text: 'Ticker'},
+                        range: [-0.5, Math.min(categories.length, 30) - 0.5]
+                    },
+                    yaxis: {
+                        title: {text: prettyModeNames[configValues.metric] + ' [$/day]'},
+                        range: [0, null]
+                    }
+                }, {})
+        }
+        else if(configValues.chartType == "pie")
+        {
+            // Create graph
+            createGraph(plotContainerID, [{labels: categories, values: catData, type: 'pie', textinfo: 'label',textposition: 'inside', insidetextorientation: 'none', automargin: false}], 
+                {
+                    width: this.urlParams.hideOptions !== undefined ? undefined : 800,
+                    height: this.urlParams.hideOptions !== undefined ? undefined : 400,
+                    autosize: this.urlParams.hideOptions !== undefined,
+                    ...(this.urlParams.hideOptions !== undefined ? {margin: {
+                        l: 10,  // left
+                        r: 10,  // right
+                        t: 40,  // top
+                        b: 10   // bottom
+                    }} : {}),
+                    title: {text: titles[configValues.metric] + companyName + ' - ' + prettyMonthName(configValues.month)},
+                    xaxis: {
+                        title: {text: 'Ticker'},
+                        range: [-0.5, Math.min(categories.length, 30) - 0.5]
+                    },
+                    yaxis: {
+                        title: {text: prettyModeNames[configValues.metric] + ' [$/day]'},
+                        range: [0, null]
+                    }
+                }, {})
+        }
+    }
+}

+ 7 - 0
reports/src/graphs/graph.ts

@@ -0,0 +1,7 @@
+export interface Graph {
+    id: string; // Value of the option
+    displayName: string;    // Displayed text for the option
+    configFieldIDs: string[];    // Array of IDs for the config fields
+    setConfigs(useURLParams?: boolean): void;  // Set the configuration fields
+    generatePlot(configValues: any, plotContainerID: string): void;  // Kicks of data gathering then creates the plot as a child of "plotContainerID". configValues have values corresponding to keys in configFieldIDs. Needs to be async
+}

+ 115 - 0
reports/src/graphs/matHistory.ts

@@ -0,0 +1,115 @@
+import { createGraph, switchPlot } from "../core";
+import { months, monthsPretty } from "../staticData/constants";
+import { addConfigField, clearChildren, getData, prettyMonthName } from "../utils";
+import { Graph } from "./graph";
+
+export class MatHistory implements Graph {
+    id = "matHistory";
+    displayName = "MAT History";
+    configFieldIDs = ["metric", "ticker"];
+    loadedData: any;
+    urlParams: any;
+
+    constructor(loadedData: any, urlParams: any)
+    {
+        this.loadedData = loadedData;
+        this.urlParams = urlParams;
+    }
+
+    setConfigs(useURLParams?: boolean)
+    {
+        const updateFunc = function() {switchPlot();}
+
+        const configDiv = document.getElementById("selectorSubtypes");
+        if(configDiv)
+        {
+            clearChildren(configDiv);
+        }
+
+        configDiv?.appendChild(addConfigField("select", "metric", "Metric: ", {prettyValues: ["Volume", "Profit", "Price", "Produced", "Consumption", "Surplus"], values: ["volume", "profit", "price", "amount", "consumed", "surplus"]}, useURLParams ? this.urlParams.metric : undefined, updateFunc));
+        configDiv?.appendChild(addConfigField("input", "ticker", "Ticker: ", undefined, useURLParams && this.urlParams.ticker ? this.urlParams.ticker : undefined, updateFunc));
+        
+    }
+
+    async generatePlot(configValues: any, plotContainerID: string)
+    {
+        if(!configValues.ticker || configValues.ticker == ""){return;}
+
+        // Get Data
+        const totalTickerData = [];
+        for(var i = 0; i < months.length; i++)
+        {
+            const monthData = await getData(this.loadedData, "prod", months[i]);
+            totalTickerData.push(monthData[(configValues.ticker ?? "").toUpperCase()])
+        }
+        
+        const tickerData = [] as number[];  // Data for the specific metric
+        const validMonths = [] as string[]; // Months with data
+        totalTickerData.forEach((data, i) => {
+            if(!data){return;}
+            validMonths.push(monthsPretty[i]);
+
+            switch(configValues.metric)
+            {
+                case "volume":
+                    tickerData.push(data.volume);
+                    break;
+                case "profit":
+                    tickerData.push(data.profit);
+                    break;
+                case "price":
+                    tickerData.push(data.amount == 0 ? 0 : data.volume / data.amount);
+                    break;
+                case "amount":
+                    tickerData.push(data.amount);
+                    break;
+                case "consumed":
+                    tickerData.push(data.consumed);
+                    break;
+                case "surplus":
+                    tickerData.push(data.amount - data.consumed);
+                    break;
+            }
+        });
+
+        if(validMonths.length == 0){return;}
+
+        const titles = {
+            'profit': 'Production Profit History of ',
+            'volume': 'Production Volume History of ',
+            'amount': 'Production Amount History of ',
+            'price': 'Price History of ',
+            'consumed': 'Consumption History of ',
+            'surplus': 'Surplus Production History of '
+        } as any
+        const yAxis = {
+            'profit': 'Daily Profit [$/day]',
+            'volume': 'Daily Volume [$/day]',
+            'amount': 'Daily Production [per day]',
+            'price': 'Price [$]',
+            'consumed': 'Daily Consumption [per day]',
+            'surplus': 'Daily Surplus [per day]'
+        } as any
+
+        // Create graph
+        createGraph(plotContainerID, [{x: validMonths, y: tickerData, type: 'bar'}], 
+            {
+                width: this.urlParams.hideOptions !== undefined ? undefined : 800,
+                height: this.urlParams.hideOptions !== undefined ? undefined : 400,
+                autosize: this.urlParams.hideOptions !== undefined,
+                ...(this.urlParams.hideOptions !== undefined ? {margin: {
+                    l: 60,  // left
+                    r: 10,  // right
+                    t: 40,  // top
+                    b: 60   // bottom
+                }} : {}),
+                title: {text: titles[configValues.metric] + (configValues.ticker ?? "").toUpperCase()},
+                xaxis: {
+                    title: {text: 'Month'}
+                },
+                yaxis: {
+                    title: {text: yAxis[configValues.metric]}
+                }
+            }, {})
+    }
+}

+ 81 - 0
reports/src/graphs/topCompanies.ts

@@ -0,0 +1,81 @@
+import { createGraph, switchPlot } from "../core";
+import { months, monthsPretty, prettyModeNames } from "../staticData/constants";
+import { addConfigField, clearChildren, getData, prettyMonthName } from "../utils";
+import { Graph } from "./graph";
+
+export class TopCompanies implements Graph {
+    id = "topCompanies";
+    displayName = "Top Companies";
+    configFieldIDs = ["metric", "month"];
+    loadedData: any;
+    urlParams: any;
+
+    constructor(loadedData: any, urlParams: any)
+    {
+        this.loadedData = loadedData;
+        this.urlParams = urlParams;
+    }
+
+    setConfigs(useURLParams?: boolean)
+    {
+        const updateFunc = function() {switchPlot();}
+
+        const configDiv = document.getElementById("selectorSubtypes");
+        if(configDiv)
+        {
+            clearChildren(configDiv);
+        }
+
+        configDiv?.appendChild(addConfigField("select", "metric", "Metric: ", {prettyValues: ["Volume", "Profit"], values: ["volume", "profit"]}, useURLParams ? this.urlParams.metric : undefined, updateFunc));
+        configDiv?.appendChild(addConfigField("select", "month", "Month: ", {prettyValues: monthsPretty, "values": months}, useURLParams && this.urlParams.month ? this.urlParams.month : months[months.length - 1], updateFunc));
+        
+    }
+
+    async generatePlot(configValues: any, plotContainerID: string)
+    {
+        // Get Data
+        const companyData = await getData(this.loadedData, "company", configValues.month);
+        const knownCompanies = await getData(this.loadedData, "knownCompanies");
+        
+        // Convert the data object into an array of [companyID, volume] pairs
+        const volumeArray = Object.entries(companyData.totals).map(([companyID, info]) => ({
+            companyID,
+            volume: (info as any)[configValues.metric]
+        }));
+
+        // Sort the array by volume in descending order
+        volumeArray.sort((a, b) => b.volume - a.volume);
+
+        // Extract tickers and volumes into separate arrays
+        const companyIDs = volumeArray.map(item => item.companyID);
+        const volumes = volumeArray.map(item => item.volume);
+
+        const companyNames = [] as any[];
+        companyIDs.forEach(id => {
+            companyNames.push(knownCompanies[id] || (id.slice(0, 5) + "..."));
+        });
+
+        // Create graph
+        createGraph(plotContainerID, [{x: companyNames, y: volumes, type: 'bar'}], 
+            {
+                width: this.urlParams.hideOptions !== undefined ? undefined : 800,
+                height: this.urlParams.hideOptions !== undefined ? undefined : 400,
+                autosize: this.urlParams.hideOptions !== undefined,
+                ...(this.urlParams.hideOptions !== undefined ? {margin: {
+                    l: 60,  // left
+                    r: 10,  // right
+                    t: 40,  // top
+                    b: 100   // bottom
+                }} : {}),
+                title: {text: 'Top Companies (' + prettyModeNames[configValues.metric] + ') - ' + prettyMonthName(configValues.month)},
+                xaxis: {
+                    title: {text: 'Ticker'},
+                    range: [-0.5, 29.5]
+                },
+                yaxis: {
+                    title: {text: prettyModeNames[configValues.metric] + ' [$/day]'},
+                    range: [0, null]
+                }
+            }, {})
+    }
+}

+ 97 - 0
reports/src/graphs/topProduction.ts

@@ -0,0 +1,97 @@
+import { Graph } from "./graph"
+import { addConfigField, clearChildren, getData, prettyMonthName } from "../utils";
+import { createGraph, switchPlot } from "../core";
+import { months, monthsPretty, prettyModeNames } from "../staticData/constants";
+
+export class TopProduction implements Graph {
+    id = "topProduction";
+    displayName = "Top Production";
+    configFieldIDs = ["metric", "month"];
+    loadedData: any;
+    urlParams: any;
+
+    constructor(loadedData: any, urlParams: any)
+    {
+        this.loadedData = loadedData;
+        this.urlParams = urlParams;
+    }
+
+    setConfigs(useURLParams?: boolean)
+    {
+        const updateFunc = function() {switchPlot();}
+
+        const configDiv = document.getElementById("selectorSubtypes");
+        if(configDiv)
+        {
+            clearChildren(configDiv);
+        }
+
+        configDiv?.appendChild(addConfigField("select", "metric", "Metric: ", {prettyValues: ["Volume", "Profit", "Deficit"], values: ["volume", "profit", "deficit"]}, useURLParams ? this.urlParams.metric : undefined, updateFunc));
+        configDiv?.appendChild(addConfigField("select", "month", "Month: ", {prettyValues: monthsPretty, "values": months}, useURLParams && this.urlParams.month ? this.urlParams.month : months[months.length - 1], updateFunc));
+        
+    }
+
+    async generatePlot(configValues: any, plotContainerID: string)
+    {
+        // Get Data
+        const prodData = await getData(this.loadedData, "prod", configValues.month)
+        
+        // Process Data
+        if(configValues.metric == 'deficit')	// Populate deficit into data
+		{
+			Object.keys(prodData).forEach(ticker => {
+				if(!prodData[ticker].amount || prodData[ticker].amount == 0){prodData[ticker].deficit = 0; return;}
+				prodData[ticker].deficit = (prodData[ticker].amount - (prodData[ticker].consumed || 0)) * prodData[ticker].volume / prodData[ticker].amount;
+			});
+		}
+
+        const titles = {
+            'profit': 'Profit Materials',
+            'volume': 'Production Volumes',
+            'deficit': 'Deficits'
+	    } as any;
+
+        // Convert the data object into an array of [ticker, volume] pairs
+        const volumeArray = Object.entries(prodData).map(([ticker, info]) => ({
+            ticker,
+            volume: (info as any)[configValues.metric]
+        }));
+
+        // Sort the array by volume in descending order
+        if(configValues.metric == 'deficit')
+        {
+            volumeArray.sort((a, b) => a.volume - b.volume);
+        }
+        else
+        {
+            volumeArray.sort((a, b) => b.volume - a.volume);
+        }
+
+        // Extract tickers and volumes into separate arrays
+	    const tickers = volumeArray.map(item => item.ticker);
+	    const volumes = volumeArray.map(item => item.volume);
+
+        // Create graph
+        createGraph(plotContainerID, [{x: tickers, y: volumes, type: 'bar'}], 
+            {
+                width: this.urlParams.hideOptions !== undefined ? undefined : 800,
+                height: this.urlParams.hideOptions !== undefined ? undefined : 400,
+                autosize: this.urlParams.hideOptions !== undefined,
+                ...(this.urlParams.hideOptions !== undefined ? {margin: {
+					l: 60,  // left
+					r: 10,  // right
+					t: 40,  // top
+					b: 60   // bottom
+				}} : {}),
+                title: {text: 'Top ' + titles[configValues.metric] + ' - ' + prettyMonthName(configValues.month)},
+                xaxis: {
+                    title: {text: 'Ticker'},
+                    range: [-0.5, 29.5]
+                },
+                yaxis: {
+                    title: {text: prettyModeNames[configValues.metric] + ' [$/day]'},
+                    range: [(configValues.metric == 'deficit' ? null : 0), (configValues.metric == 'deficit' ? 0 : null)]
+                }
+            }, {})
+    }
+}

+ 63 - 0
reports/src/main.ts

@@ -0,0 +1,63 @@
+import { switchPlot } from "./core";
+import { CompanyHistory } from "./graphs/companyHistory";
+import { CompanyRank } from "./graphs/companyRank";
+import { CompanyTotals } from "./graphs/companyTotals";
+import { Graph } from "./graphs/graph";
+import { MatHistory } from "./graphs/matHistory";
+import { TopCompanies } from "./graphs/topCompanies";
+import { TopProduction } from "./graphs/topProduction";
+import { addPermalink, updatePermalink } from "./permalink";
+import { addOption } from "./utils";
+
+window.onload = function() {
+	// Do permalink stuff
+	addPermalink();
+
+	// Populate the graph select with options
+	const graphSelect = document.getElementById("graphType") as HTMLSelectElement;
+
+	graphs.forEach(graph => {
+		addOption(graphSelect, graph.displayName, graph.id);
+	});
+
+	if(urlParams.type)
+	{
+		graphSelect.value = urlParams.type;
+	}
+
+	graphSelect.addEventListener("change", function() {
+		graphs.find(graph => graph.id == graphSelect.value)?.setConfigs();
+		switchPlot();
+	});
+
+	// Initialize default values
+	graphs.find(graph => graph.id == graphSelect.value)?.setConfigs(true);
+	switchPlot();
+
+	// Set the graphs to fullscreen
+	if(urlParams.hideOptions !== undefined)
+	{
+		const graphTypeContainer = document.getElementById('graphTypeContainer');
+		const topTabs = document.getElementById('topTabContainer');
+		const configDiv = document.getElementById("selectorSubtypes");
+		const plotContainer = document.getElementById("mainPlot")
+		if(topTabs && graphTypeContainer && configDiv)
+		{
+			topTabs.style.display = 'none';
+			graphTypeContainer.style.display = 'none';
+			configDiv.style.display = 'none';
+			plotContainer?.classList.add("fullScreen")
+		}
+	}
+}
+
+const urlParams = Object.fromEntries(new URLSearchParams(window.location.search));
+const loadedData = {};
+export const graphs: Graph[] = [
+	new TopProduction(loadedData, urlParams),
+	new TopCompanies(loadedData, urlParams),
+	new MatHistory(loadedData, urlParams),
+	new CompanyTotals(loadedData, urlParams),
+	new CompanyHistory(loadedData, urlParams),
+	new CompanyRank(loadedData, urlParams)
+];

+ 0 - 0
reports/material-info.js → reports/src/material-info.js


+ 90 - 0
reports/src/permalink.ts

@@ -0,0 +1,90 @@
+import { Graph } from "./graphs/graph";
+import { graphs } from "./main";
+
+export function addPermalink()
+{
+    // Permalink stuff
+	const permalinkContainer = document.getElementById("permalinkContainer") as HTMLDivElement;
+	const permalinkButton = document.getElementById("permalinkButton") as HTMLButtonElement;
+	const permalinkCopyButton = document.getElementById("permalinkCopyButton") as HTMLButtonElement;
+	const rprunCopyButton = document.getElementById("permalinkCopyButton-rprun") as HTMLButtonElement;
+	const permalinkOptionsButton = document.getElementById("hideOptions") as HTMLInputElement;
+	const permalinkLatestMonth = document.getElementById("latestMonth") as HTMLInputElement;
+
+	permalinkButton?.addEventListener("click", function(e) {
+		e.stopPropagation();
+		const currentDisplay = permalinkContainer.style.display;
+		if(currentDisplay == "none")
+		{
+			permalinkContainer.style.display = "block";
+		}
+		else
+		{
+			permalinkContainer.style.display = "none";
+		}
+	});
+
+	document.addEventListener("click", function(e: any) {
+		if(!permalinkContainer.contains(e.target) && !permalinkButton.contains(e.target))
+		{
+			permalinkContainer.style.display = "none";
+		}
+	});
+
+	permalinkCopyButton.addEventListener("click", function() {
+		const permalinkElem = document.getElementById("permalink") as HTMLInputElement;
+		if(permalinkElem.value && permalinkElem.value != "")
+		{
+			navigator.clipboard.writeText(permalinkElem.value);
+		}
+	});
+	
+	rprunCopyButton.addEventListener("click", function() {
+		const permalinkElem = document.getElementById("permalink-rprun") as HTMLInputElement;
+		if(permalinkElem.value && permalinkElem.value != "")
+		{
+			navigator.clipboard.writeText(permalinkElem.value);
+		}
+	});
+
+	permalinkOptionsButton.addEventListener("change", function() {
+		updatePermalink();
+	});
+	permalinkLatestMonth.addEventListener("change", function() {
+		updatePermalink();
+	});
+}
+
+export function updatePermalink()
+{
+	const graphSelect = document.getElementById("graphType") as HTMLSelectElement;
+    const permalinkInput = document.getElementById("permalink") as HTMLInputElement;
+	const rprunInput = document.getElementById("permalink-rprun") as HTMLInputElement;
+	const hideOptionsButton = document.getElementById("hideOptions") as HTMLInputElement;
+	const latestMonthButton = document.getElementById("latestMonth") as HTMLInputElement;
+	
+	var permalink = "https://pmmg-products.github.io/reports/?type=" + graphSelect.value;
+	var rprunLink = "XIT PRUNSTATS type-" + graphSelect.value;
+	
+    const graph = graphs.find(obj => obj.id == graphSelect.value);
+	graph?.configFieldIDs.forEach(subtype => {
+		if(subtype == "month" && latestMonthButton.checked){return;}
+		
+		const inputElem = document.getElementById(subtype) as HTMLInputElement;
+		if(inputElem.value && inputElem.value != "")
+		{
+			permalink += "&" + subtype + "=" + inputElem.value;
+			rprunLink += " " + subtype + "-" + inputElem.value;
+		}
+	});
+
+	if(hideOptionsButton.checked)
+	{
+		permalink += "&hideOptions";
+		rprunLink += " hideOptions";
+	}
+
+	permalinkInput.value = permalink;
+	rprunInput.value = rprunLink;
+	return;
+}

+ 483 - 0
reports/src/staticData/constants.ts

@@ -0,0 +1,483 @@
+export const months = ["mar25", "apr25", "may25", "jun25", "jul25"];
+export const monthsPretty = ["March 3025", "April 3025", "May 3025", "June 3025", "July 3025"];
+
+export const fullMonthNames = {
+	"jan": "January",
+	"feb": "February",
+	"mar": "March",
+	"apr": "April",
+	"may": "May",
+	"jun": "June",
+	"jul": "July",
+	"aug": "August",
+	"sep": "September",
+	"oct": "October",
+	"nov": "November",
+	"dec": "December"
+} as any
+
+export const prettyModeNames = {
+	"amount": "Amount",
+	"profit": "Profit",
+	"volume": "Volume",
+	"price": "Price",
+	"deficit": "Deficit"
+} as any
+
+export const materialCategories: Record<string, string[]>= {
+	'Consumables (Luxury)': [
+		'ALE',
+		'COF',
+		'GIN',
+		'KOM',
+		'NST',
+		'PWO',
+		'REP',
+		'SC',
+		'VG',
+		'WIN'
+	],
+	'Ship Engines': [
+		'AEN',
+		'AFP',
+		'AFR',
+		'ANZ',
+		'BFP',
+		'BFR',
+		'CHA',
+		'ENG',
+		'FIR',
+		'FSE',
+		'GCH',
+		'GEN',
+		'GNZ',
+		'HNZ',
+		'HPR',
+		'HTE',
+		'HYR',
+		'LFE',
+		'LFP',
+		'MFE',
+		'NOZ',
+		'QCR',
+		'RAG',
+		'RCS',
+		'RCT',
+		'SFE'
+	],
+	'Software Tools': [
+		'DA',
+		'DD',
+		'DV',
+		'EDC',
+		'NN',
+		'OS'
+	],
+	'Construction Parts': [
+		'AEF',
+		'AIR',
+		'DEC',
+		'FC',
+		'FLO',
+		'FLP',
+		'GC',
+		'GV',
+		'LIT',
+		'MGC',
+		'MHL',
+		'PSH',
+		'RSH',
+		'TCS',
+		'TRU',
+		'TSH'
+	],
+	'Alloys': [
+		'AST',
+		'BGO',
+		'BOS',
+		'BRO',
+		'FAL',
+		'FET',
+		'RGO',
+		'WAL'
+	],
+	'Consumable Bundles': [
+		'CBU',
+		'EBU',
+		'PBU',
+		'SBU',
+		'TBU'
+	],
+	'Medical Equipment': [
+		'ADR',
+		'BND',
+		'PK',
+		'SEQ',
+		'STR',
+		'TUB'
+	],
+	'Electronic Parts': [
+		'CD',
+		'DIS',
+		'FAN',
+		'MB',
+		'MPC',
+		'PCB',
+		'RAM',
+		'ROM',
+		'SEN',
+		'TPU',
+		'TRA'
+	],
+	'Energy Systems': [
+		'CBL',
+		'CBM',
+		'CBS',
+		'POW',
+		'SOL',
+		'SP'
+	],
+	'Minerals': [
+		'BER',
+		'BOR',
+		'BRM',
+		'CLI',
+		'GAL',
+		'HAL',
+		'LST',
+		'MAG',
+		'MGS',
+		'SCR',
+		'TAI',
+		'TCO',
+		'TS',
+		'ZIR'
+	],
+	'Construction Materials': [
+		'CMK',
+		'EPO',
+		'GL',
+		'INS',
+		'MCG',
+		'MTC',
+		'NCS',
+		'NFI',
+		'NG',
+		'RG',
+		'SEA'
+	],
+	'Consumables (Basic)': [
+		'DW',
+		'EXO',
+		'FIM',
+		'HMS',
+		'HSS',
+		'LC',
+		'MEA',
+		'MED',
+		'OVE',
+		'PDA',
+		'PT',
+		'RAT',
+		'SCN',
+		'WS'
+	],
+	'Software Systems': [
+		'IDC',
+		'IMM',
+		'SNM',
+		'WAI'
+	],
+	'Electronic Pieces': [
+		'BCO',
+		'BGC',
+		'CAP',
+		'HCC',
+		'LDI',
+		'MFK',
+		'MWF',
+		'SFK',
+		'SWF',
+		'TRN'
+	],
+	'Software Components': [
+		'BAI',
+		'LD',
+		'MLI',
+		'NF',
+		'SA',
+		'SAL',
+		'WM'
+	],
+	'Ores': [
+		'ALO',
+		'AUO',
+		'CUO',
+		'FEO',
+		'LIO',
+		'SIO',
+		'TIO'
+	],
+	'Unit Prefabs': [
+		'BR1',
+		'BR2',
+		'BRS',
+		'CQL',
+		'CQM',
+		'CQS',
+		'CQT',
+		'DOU',
+		'FUN',
+		'HAB',
+		'LU',
+		'RDL',
+		'RDS',
+		'SU',
+		'TCU',
+		'WOR'
+	],
+	'Ship Shields': [
+		'APT',
+		'ARP',
+		'AWH',
+		'BPT',
+		'BRP',
+		'BWH',
+		'SRP'
+	],
+	'Electronic Devices': [
+		'AAR',
+		'AWF',
+		'BID',
+		'BMF',
+		'BSC',
+		'BWS',
+		'HD',
+		'HOG',
+		'HPC',
+		'MHP',
+		'RAD',
+		'SAR'
+	],
+	'Metals': [
+		'AL',
+		'AU',
+		'CU',
+		'FE',
+		'LI',
+		'SI',
+		'STL',
+		'TI',
+		'W'
+	],
+	'Electronic Systems': [
+		'ACS',
+		'ADS',
+		'CC',
+		'COM',
+		'CRU',
+		'FFC',
+		'LIS',
+		'LOG',
+		'STS',
+		'TAC',
+		'WR'
+	],
+	'Textiles': [
+		'CF',
+		'COT',
+		'CTF',
+		'KV',
+		'NL',
+		'SIL',
+		'TK'
+	],
+	'Plastics': [
+		'DCL',
+		'DCM',
+		'DCS',
+		'PE',
+		'PG',
+		'PSL',
+		'PSM',
+		'PSS'
+	],
+	'Chemicals': [
+		'BAC',
+		'BL',
+		'BLE',
+		'CST',
+		'DDT',
+		'EES',
+		'ETC',
+		'FLX',
+		'IND',
+		'JUI',
+		'LCR',
+		'NAB',
+		'NR',
+		'NS',
+		'OLF',
+		'PFE',
+		'REA',
+		'SOI',
+		'TCL',
+		'THF'
+	],
+	'Elements': [
+		'BE',
+		'C',
+		'CA',
+		'CL',
+		'ES',
+		'I',
+		'MG',
+		'NA',
+		'S',
+		'TA',
+		'TC',
+		'ZR'
+	],
+	'Gases': [
+		'AMM',
+		'AR',
+		'F',
+		'H',
+		'HE',
+		'HE3',
+		'N',
+		'NE',
+		'O'
+	],
+	'Ship Parts': [
+		'AGS',
+		'AHP',
+		'ATP',
+		'BGS',
+		'BHP',
+		'HHP',
+		'LHP',
+		'NV1',
+		'NV2',
+		'RHP',
+		'SSC',
+		'THP'
+	],
+	'Drones': [
+		'CCD',
+		'DCH',
+		'DRF',
+		'RED',
+		'SDR',
+		'SRD',
+		'SUD'
+	],
+	'Agricultural Products': [
+		'ALG',
+		'BEA',
+		'CAF',
+		'FOD',
+		'GRA',
+		'GRN',
+		'HCP',
+		'HER',
+		'HOP',
+		'MAI',
+		'MTP',
+		'MUS',
+		'NUT',
+		'PIB',
+		'PPA',
+		'RCO',
+		'RSI',
+		'VEG',
+		'VIT'
+	],
+	'Construction Prefabs': [
+		'ABH',
+		'ADE',
+		'ASE',
+		'ATA',
+		'BBH',
+		'BDE',
+		'BSE',
+		'BTA',
+		'HSE',
+		'LBH',
+		'LDE',
+		'LSE',
+		'LTA',
+		'RBH',
+		'RDE',
+		'RSE',
+		'RTA'
+	],
+	'Fuels': [
+		'FF',
+		'SF'
+	],
+	'Ship Kits': [
+		'HCB',
+		'LCB',
+		'LFL',
+		'LSL',
+		'MCB',
+		'MFL',
+		'MSL',
+		'SCB',
+		'SFL',
+		'SSL',
+		'TCB',
+		'VCB',
+		'VSC',
+		'WCB'
+	],
+	'Liquids': [
+		'BTS',
+		'H2O',
+		'HEX',
+		'LES'
+	],
+	'Utility': [
+		'OFF',
+		'SUN',
+		'UTS'
+	]
+}
+
+export const materialCategoryColors = {
+	"Agricultural Products": "#0a4708",
+	"Alloys": "#946537",
+	"Chemicals": "#d04774",
+	"Construction Materials": "#3174ec",
+	"Construction Parts": "#426684",
+	"Construction Prefabs": "#28377b",
+	"Consumable Bundles": "#57232a",
+	"Consumables (Basic)": "#ba363c",
+	"Consumables (Luxury)": "#680000",
+	"Drones": "#a54d2b",
+	"Electronic Devices": "#6f2dac",
+	"Electronic Parts": "#7447d0",
+	"Electronic Pieces": "#906bd6",
+	"Electronic Systems": "#4c3365",
+	"Elements": "#564739",
+	"Energy Systems": "#2e5740",
+	"Fuels": "#6ba23c",
+	"Gases": "#198284",
+	"Liquids": "#6098c3",
+	"Medical Equipment": "#6ec36e",
+	"Metals": "#4f4f4f",
+	"Minerals": "#b28a62",
+	"Ores": "#6b707a",
+	"Plastics": "#791f62",
+	"Ship Engines": "#b24219",
+	"Ship Kits": "#b26d19",
+	"Ship Parts": "#b27c19",
+	"Ship Shields": "#d98c21",
+	"Software Components": "#a19248",
+	"Software Systems": "#554e1e",
+	"Software Tools": "#9a7b2c",
+	"Textiles": "#6b733a",
+	"Unit Prefabs": "#363435",
+	"Utility": "#baada1"
+} as any;

+ 152 - 0
reports/src/utils.ts

@@ -0,0 +1,152 @@
+import { fullMonthNames, materialCategories, materialCategoryColors } from "./staticData/constants";
+
+// Add an option to a selector
+export function addOption(selector: HTMLSelectElement, displayName: string, id: string)
+{
+    const optionElem = document.createElement("option");
+    optionElem.textContent = displayName;
+    optionElem.value = id ?? displayName;
+    selector.appendChild(optionElem);
+}
+
+// Remove all the children of a given element
+export function clearChildren(elem: HTMLElement)
+{
+	elem.textContent = "";
+	while(elem.children[0])
+	{
+		elem.removeChild(elem.children[0]);
+	}
+	return;
+}
+
+// Add a config field
+export function addConfigField(inputType: string, id: string, label: string, value: any, defaultValue: any, updateFunc: (() => void), marginShift?: string)
+{
+	const labelElem = document.createElement('label');
+	labelElem.textContent = label;
+	
+	const inputElem = document.createElement(inputType);
+	inputElem.id = id;
+	inputElem.classList.add("plotSelector");
+	if(inputType == 'select')
+	{
+		addOptions(inputElem as HTMLSelectElement, value.prettyValues, value.values);
+	}
+	
+	if(defaultValue)
+	{
+		(inputElem as HTMLInputElement).value = defaultValue;
+	}
+	
+	inputElem.addEventListener("change", updateFunc);
+	
+	labelElem.appendChild(inputElem);
+
+	const output = wrapInDiv(labelElem);
+	if(marginShift)
+	{
+		output.style.marginLeft = marginShift
+	}
+	
+	return output;
+}
+
+// Wrap an element in an extra div
+function wrapInDiv(elem: HTMLElement)	// Wrap selector element in a div to center it and give it margin
+{
+	const div = document.createElement('div');
+	
+	div.appendChild(elem);
+	
+	return div;
+}
+
+// Add options to a selector
+function addOptions(selector: HTMLSelectElement, prettyValues: any[], values: any[])
+{
+	for(var i = 0; i < prettyValues.length; i++)
+	{
+		const optionElem = document.createElement("option");
+		optionElem.textContent = prettyValues[i];
+		optionElem.value = values ? values[i] : prettyValues[i];
+		selector.appendChild(optionElem);
+	}
+}
+
+// Merge a default object with one entered by the user.
+export function deepMerge<T>(target: T, source: Partial<T>): T {
+  for (const key in source) {
+    if (
+      source[key] &&
+      typeof source[key] === "object" &&
+      !Array.isArray(source[key])
+    ) {
+      // @ts-ignore
+      target[key] = deepMerge(target[key] || {}, source[key]);
+    } else {
+      // @ts-ignore
+      target[key] = source[key];
+    }
+  }
+  return target;
+}
+
+// Generate pretty month name from id format: mar25
+export function prettyMonthName(monthStr: string)
+{
+	const monthAbv = monthStr.substring(0,3);
+	const monthNum = monthStr.substring(3);
+	
+	return fullMonthNames[monthAbv] + " 30" + monthNum;
+}
+
+// Functions to deal with loading data
+export async function getData(loadedData: any, dataType: string, month?: string)	// dataType is either: prod, company, or knownCompanies
+{
+	switch(dataType)
+	{
+		case "prod":
+		case "company":
+			if(!loadedData[dataType + '-data-' + month])
+			{
+				loadedData[dataType + '-data-' + month] = await fetch('data/' + dataType + '-data-' + month + '.json?cb=' + Date.now()).then(response => response.json());
+			}
+			return loadedData[dataType + '-data-' + month];
+		case "knownCompanies":
+			if(!loadedData['known-companies']) 
+			{ 
+				loadedData['known-companies'] = await fetch('data/knownCompanies.json?cb=' + Date.now()).then(response => response.json());
+			}
+			return loadedData['known-companies']
+	}
+}
+
+export function getMatCategory(ticker: string): string | undefined {
+  return Object.entries(materialCategories).find(
+    ([_, tickers]) => tickers.includes(ticker)
+  )?.[0];
+}
+
+export function getMatColor(ticker: string): string {
+	return materialCategoryColors[getMatCategory(ticker) ?? ""] ?? "#000000";
+}
+
+export async function getCompanyId(companyName: string, loadedData: any) {
+	const knownCompanies = await getData(loadedData, "knownCompanies") as any;
+
+	// Pull from known companies
+	var companyID = Object.keys(knownCompanies).find(id => (knownCompanies[id] ?? "").toLowerCase() == companyName.toLowerCase()) as string;
+	if(companyID) { return companyID; }
+
+	// Resort to FIO
+	console.log("Unknown username, querying FIO.");
+	const fioResult = await fetch('https://rest.fnar.net/user/' + companyName).then(response => response.json()).catch(error => {alert('Bad Response: Check Username'); console.error(error)});
+	companyID = fioResult?.CompanyId;
+	companyName = fioResult?.UserName;
+
+	// Temporarily add to the list of known companies preventing multiple FIO queries
+	knownCompanies[companyID] = companyName;
+
+	return companyID as string | undefined;
+}

+ 8 - 0
reports/tsconfig.json

@@ -0,0 +1,8 @@
+{
+  "compilerOptions": {
+    "target": "ES6",
+    "module": "ES6",
+    "outDir": "./dist",
+    "strict": true
+  }
+}

+ 14 - 0
reports/webpack.config.js

@@ -0,0 +1,14 @@
+const path = require('path');
+
+module.exports = {
+  entry: './src/main.ts',
+  module: {
+    rules: [{ test: /\.ts$/, use: 'ts-loader', exclude: /node_modules|main\.js/ }]
+  },
+  resolve: { extensions: ['.ts', '.js'] },
+  output: {
+    filename: 'main.js',
+    path: __dirname
+  },
+  mode: 'development'
+};

Some files were not shown because too many files changed in this diff